From a435ea5cb8cd2e389822e7d52625d703ee528811 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Wed, 16 Sep 2026 09:19:26 +0800 Subject: [PATCH] feat(team): add scoped onboarding tokens and guide Co-Authored-By: Codex --- docs/.vitepress/config.ts | 1 + docs/guide/team-hapi.md | 57 ++++++ hub/src/socket/server.ts | 9 +- hub/src/store/accessTokens.test.ts | 62 +++++++ hub/src/store/accessTokens.ts | 153 ++++++++++++++++ hub/src/store/index.ts | 39 +++++ hub/src/utils/accessToken.test.ts | 35 ++-- hub/src/utils/accessToken.ts | 29 ++++ hub/src/web/middleware/auth.ts | 7 +- hub/src/web/routes/auth.ts | 11 +- hub/src/web/routes/bind.ts | 11 +- hub/src/web/routes/cli.ts | 12 +- hub/src/web/routes/teamOnboarding.test.ts | 47 +++++ hub/src/web/routes/teamOnboarding.ts | 202 ++++++++++++++++++++++ hub/src/web/server.ts | 4 +- 15 files changed, 640 insertions(+), 39 deletions(-) create mode 100644 docs/guide/team-hapi.md create mode 100644 hub/src/store/accessTokens.test.ts create mode 100644 hub/src/store/accessTokens.ts create mode 100644 hub/src/web/routes/teamOnboarding.test.ts create mode 100644 hub/src/web/routes/teamOnboarding.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index e4c4f3fa..6da4312b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -47,6 +47,7 @@ export default defineConfig({ text: 'Advanced', items: [ { text: 'Namespace', link: '/guide/namespace' }, + { text: 'Team HAPI', link: '/guide/team-hapi' }, { text: 'Deployment', link: '/guide/deployment' }, { text: 'Notifications', link: '/guide/notifications' } ] diff --git a/docs/guide/team-hapi.md b/docs/guide/team-hapi.md new file mode 100644 index 00000000..d0006112 --- /dev/null +++ b/docs/guide/team-hapi.md @@ -0,0 +1,57 @@ +# Team HAPI 使用指南 + +Team HAPI 是团队共用的 Hub。每位成员使用独立的 Namespace 和 Token,只能看到自己 Namespace 下的会话、机器和文件。 + +## 成员第一次使用 + +1. 打开管理员发来的一次性邀请链接。 +2. 页面自动创建账号,显示你的 Namespace 和个人 Token。 +3. 立即把 Token 保存到密码管理器;管理员不会看到 Token,链接也只能领取一次。 +4. 点击页面上的 **打开 Team HAPI**,即可使用网页端。 + +如需在终端使用: + +```bash +npm install -g @twsxtd/hapi +export HAPI_API_URL=https://team-hapi.aichickenfarm.cn +hapi auth login +# 粘贴邀请页显示的个人 Token +hapi codex +``` + +也可以在 `hapi auth login` 后使用 `hapi claude`、`hapi gemini` 等本机已安装的 Agent。 + +## Token 丢失 + +联系管理员,提供自己的 Namespace。管理员生成恢复链接后,打开链接即可获得新 Token;原有会话和机器不会删除,旧 Token 会失效。 + +## 管理员生成邀请链接 + +管理员使用 Hub 的原始 `CLI_API_TOKEN` 调用管理接口。原始 Token 只应保存在管理员机器或密码管理器中,不要发给团队成员。 + +创建新成员邀请(Namespace 可省略,省略时自动生成): + +```bash +curl -fsS -X POST https://team-hapi.aichickenfarm.cn/api/team/admin/invite \ + -H "Authorization: Bearer $CLI_API_TOKEN" \ + -H 'content-type: application/json' \ + -d '{"expiresInHours":24}' +``` + +成员恢复邀请: + +```bash +curl -fsS -X POST https://team-hapi.aichickenfarm.cn/api/team/admin/recovery \ + -H "Authorization: Bearer $CLI_API_TOKEN" \ + -H 'content-type: application/json' \ + -d '{"namespace":"member-name","expiresInHours":24}' +``` + +响应里的 `inviteUrl` 就是要发给成员的一次性链接。邀请默认 24 小时有效,最长 7 天;链接本身包含秘密信息,请通过私聊发送。 + +## 安全边界 + +- 成员 Token 只对应一个 Namespace,不能通过修改 Token 后缀访问其他 Namespace。 +- 成员之间互相看不到会话、机器和文件。 +- 管理员可以创建邀请和恢复链接,但不会从数据库中读取成员 Token。 +- 不要把 Token 放入群聊、截图、代码仓库或工单。 diff --git a/hub/src/socket/server.ts b/hub/src/socket/server.ts index 5dc8ebc4..20796674 100644 --- a/hub/src/socket/server.ts +++ b/hub/src/socket/server.ts @@ -4,8 +4,7 @@ import { jwtVerify } from 'jose' import { z } from 'zod' import type { Store } from '../store' import { getConfiguration } from '../configuration' -import { constantTimeEquals } from '../utils/crypto' -import { parseAccessToken } from '../utils/accessToken' +import { resolveAccessToken } from '../utils/accessToken' import { registerCliHandlers } from './handlers/cli' import { registerTerminalHandlers } from './handlers/terminal' import { RpcRegistry } from './rpcRegistry' @@ -113,8 +112,10 @@ export function createSocketServer(deps: SocketServerDeps): { cliNs.use((socket, next) => { const auth = socket.handshake.auth as Record | undefined const token = typeof auth?.token === 'string' ? auth.token : null - const parsedToken = token ? parseAccessToken(token) : null - if (!parsedToken || !constantTimeEquals(parsedToken.baseToken, configuration.cliApiToken)) { + const parsedToken = token + ? resolveAccessToken(token, configuration.cliApiToken, deps.store.accessTokens) + : null + if (!parsedToken) { return next(new Error('Invalid token')) } socket.data.namespace = parsedToken.namespace diff --git a/hub/src/store/accessTokens.test.ts b/hub/src/store/accessTokens.test.ts new file mode 100644 index 00000000..668078f2 --- /dev/null +++ b/hub/src/store/accessTokens.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from './index' + +describe('AccessTokenStore', () => { + it('claims an enrollment invite once and resolves only its namespace', () => { + const store = new Store(':memory:') + try { + const invite = store.accessTokens.createInvite({ + kind: 'enroll', + now: 1_000, + expiresInHours: 1 + }) + const claimed = store.accessTokens.claimInvite(invite.invite, 2_000) + + expect(claimed?.namespace).toBe(invite.namespace) + expect(claimed?.accessToken).toMatch(/^hapi_team_/) + expect(store.accessTokens.resolve(claimed!.accessToken)?.namespace).toBe(invite.namespace) + expect(store.accessTokens.claimInvite(invite.invite, 3_000)).toBeNull() + } finally { + store.close() + } + }) + + it('rotates a namespace token without deleting its sessions', () => { + const store = new Store(':memory:') + try { + const enrollment = store.accessTokens.createInvite({ + kind: 'enroll', + namespace: 'alice', + now: 1_000 + }) + const oldToken = store.accessTokens.claimInvite(enrollment.invite, 2_000)! + const recovery = store.accessTokens.createInvite({ + kind: 'recovery', + namespace: 'alice', + now: 3_000 + }) + const nextToken = store.accessTokens.claimInvite(recovery.invite, 4_000)! + + expect(nextToken.namespace).toBe('alice') + expect(store.accessTokens.resolve(oldToken.accessToken)).toBeNull() + expect(store.accessTokens.resolve(nextToken.accessToken)?.namespace).toBe('alice') + } finally { + store.close() + } + }) + + it('rejects expired invites and the default namespace for user credentials', () => { + const store = new Store(':memory:') + try { + const invite = store.accessTokens.createInvite({ + kind: 'enroll', + now: 1_000, + expiresInHours: 1 + }) + expect(store.accessTokens.claimInvite(invite.invite, invite.expiresAt)).toBeNull() + expect(() => store.accessTokens.createInvite({ kind: 'recovery', namespace: 'default' })).toThrow() + } finally { + store.close() + } + }) +}) diff --git a/hub/src/store/accessTokens.ts b/hub/src/store/accessTokens.ts new file mode 100644 index 00000000..7f56e78e --- /dev/null +++ b/hub/src/store/accessTokens.ts @@ -0,0 +1,153 @@ +import { createHash, randomBytes } from 'node:crypto' +import { Database } from 'bun:sqlite' + +export type TeamInviteKind = 'enroll' | 'recovery' + +export type ResolvedAccessToken = { + namespace: string + tokenId: string +} + +export type CreatedInvite = { + invite: string + namespace: string + expiresAt: number +} + +export type ClaimedInvite = { + accessToken: string + namespace: string +} + +type InviteRow = { + id: string + invite_hash: string + kind: TeamInviteKind + namespace: string + expires_at: number + used_at: number | null +} + +function hashSecret(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +function randomSecret(prefix: string): string { + return `${prefix}_${randomBytes(32).toString('base64url')}` +} + +function randomId(prefix: string): string { + return `${prefix}_${randomBytes(12).toString('hex')}` +} + +function validateNamespace(namespace: string): string { + const trimmed = namespace.trim().toLowerCase() + if (!/^[a-z0-9][a-z0-9_-]{1,47}$/.test(trimmed) || trimmed === 'default') { + throw new Error('Namespace must use 2-48 lowercase letters, numbers, _ or - and cannot be default.') + } + return trimmed +} + +function generatedNamespace(): string { + return `member-${randomBytes(6).toString('hex')}` +} + +export class AccessTokenStore { + constructor(private readonly db: Database) {} + + resolve(rawToken: string): ResolvedAccessToken | null { + if (!rawToken) return null + const tokenHash = hashSecret(rawToken) + const row = this.db.prepare( + `SELECT id, namespace + FROM team_access_tokens + WHERE token_hash = ? AND revoked_at IS NULL` + ).get(tokenHash) as { id: string; namespace: string } | undefined + if (!row) return null + + this.db.prepare( + 'UPDATE team_access_tokens SET last_used_at = ? WHERE id = ?' + ).run(Date.now(), row.id) + return { tokenId: row.id, namespace: row.namespace } + } + + createInvite(input: { + kind: TeamInviteKind + namespace?: string + expiresInHours?: number + now?: number + }): CreatedInvite { + const now = input.now ?? Date.now() + const expiresInHours = input.expiresInHours ?? 24 + if (!Number.isFinite(expiresInHours) || expiresInHours <= 0 || expiresInHours > 168) { + throw new Error('Invite expiry must be between 1 and 168 hours.') + } + const namespace = input.kind === 'recovery' + ? validateNamespace(input.namespace ?? '') + : input.namespace + ? validateNamespace(input.namespace) + : generatedNamespace() + if (input.kind === 'enroll' && input.namespace) { + const existing = this.db.prepare( + `SELECT 1 FROM team_access_tokens WHERE namespace = ? + UNION ALL SELECT 1 FROM sessions WHERE namespace = ? + UNION ALL SELECT 1 FROM machines WHERE namespace = ? + LIMIT 1` + ).get(namespace, namespace, namespace) + if (existing) { + throw new Error('Namespace already exists; use a recovery invite instead.') + } + } + if (input.kind === 'recovery') { + const existing = this.db.prepare( + `SELECT 1 FROM team_access_tokens WHERE namespace = ? + UNION ALL SELECT 1 FROM sessions WHERE namespace = ? + UNION ALL SELECT 1 FROM machines WHERE namespace = ? + LIMIT 1` + ).get(namespace, namespace, namespace) + if (!existing) throw new Error('Namespace was not found.') + } + const invite = randomSecret('hapi_invite') + const expiresAt = now + Math.round(expiresInHours * 60 * 60 * 1000) + this.db.prepare( + `INSERT INTO team_invites (id, invite_hash, kind, namespace, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(randomId('invite'), hashSecret(invite), input.kind, namespace, now, expiresAt) + return { invite, namespace, expiresAt } + } + + claimInvite(invite: string, now = Date.now()): ClaimedInvite | null { + if (!invite) return null + const inviteHash = hashSecret(invite) + return this.db.transaction(() => { + const row = this.db.prepare( + `SELECT id, invite_hash, kind, namespace, expires_at, used_at + FROM team_invites + WHERE invite_hash = ?` + ).get(inviteHash) as InviteRow | undefined + if (!row || row.used_at !== null || row.expires_at <= now) return null + + const update = this.db.prepare( + 'UPDATE team_invites SET used_at = ? WHERE id = ? AND used_at IS NULL' + ).run(now, row.id) + if (update.changes !== 1) return null + + this.db.prepare( + 'UPDATE team_access_tokens SET revoked_at = ? WHERE namespace = ? AND revoked_at IS NULL' + ).run(now, row.namespace) + + const accessToken = randomSecret('hapi_team') + this.db.prepare( + `INSERT INTO team_access_tokens (id, token_hash, namespace, created_at) + VALUES (?, ?, ?, ?)` + ).run(randomId('access'), hashSecret(accessToken), row.namespace, now) + return { accessToken, namespace: row.namespace } + })() + } + + revokeNamespace(namespace: string, now = Date.now()): number { + return this.db.prepare( + 'UPDATE team_access_tokens SET revoked_at = ? WHERE namespace = ? AND revoked_at IS NULL' + ).run(now, validateNamespace(namespace)).changes + } +} diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 7e1e6cf0..bc1ee071 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -13,6 +13,7 @@ import { SessionStore } from './sessionStore' import { UserStore } from './userStore' import { UsageStore } from './usageStore' import { WorkGraphStore } from './workGraphStore' +import { AccessTokenStore } from './accessTokens' export type { NativeDevicePlatform, @@ -36,6 +37,7 @@ export { SessionStore } from './sessionStore' export { UserStore } from './userStore' export { UsageStore } from './usageStore' export { WorkGraphStore } from './workGraphStore' +export { AccessTokenStore } from './accessTokens' export { WorkGraphNotFoundError, WorkGraphPrincipalError, @@ -72,6 +74,7 @@ export class Store { readonly scratchlist: ScratchlistStore readonly usage: UsageStore readonly workGraph: WorkGraphStore + readonly accessTokens: AccessTokenStore /** * Filesystem path of the underlying SQLite database, or ':memory:' for @@ -126,6 +129,7 @@ export class Store { this.scratchlist = new ScratchlistStore(this.db) this.usage = new UsageStore(this.db) this.workGraph = new WorkGraphStore(this.db) + this.accessTokens = new AccessTokenStore(this.db) } /** @@ -367,11 +371,13 @@ export class Store { // a partially-built legacy DB may not have yet. this.createSchema() this.setUserVersion(SCHEMA_VERSION) + this.ensureAccessTokenSchema() return } this.createSchema() this.setUserVersion(SCHEMA_VERSION) + this.ensureAccessTokenSchema() return } @@ -383,6 +389,7 @@ export class Store { step() } this.setUserVersion(SCHEMA_VERSION) + this.ensureAccessTokenSchema() return } @@ -390,9 +397,41 @@ export class Store { throw this.buildSchemaMismatchError(currentVersion) } + this.ensureAccessTokenSchema() this.assertRequiredTablesPresent() } + /** Additive auth tables; intentionally independent from the main schema ladder. */ + private ensureAccessTokenSchema(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS team_access_tokens ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + namespace TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + revoked_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_team_access_tokens_namespace + ON team_access_tokens(namespace, created_at DESC); + CREATE UNIQUE INDEX IF NOT EXISTS idx_team_access_tokens_active_hash + ON team_access_tokens(token_hash) + WHERE revoked_at IS NULL; + + CREATE TABLE IF NOT EXISTS team_invites ( + id TEXT PRIMARY KEY, + invite_hash TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL CHECK (kind IN ('enroll', 'recovery')), + namespace TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + used_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_team_invites_expiry + ON team_invites(expires_at, used_at); + `) + } + private createSchema(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS sessions ( diff --git a/hub/src/utils/accessToken.test.ts b/hub/src/utils/accessToken.test.ts index 4a1e4e9a..669fda6a 100644 --- a/hub/src/utils/accessToken.test.ts +++ b/hub/src/utils/accessToken.test.ts @@ -1,26 +1,21 @@ import { describe, expect, it } from 'bun:test' -import { DEFAULT_NAMESPACE, parseAccessToken } from './accessToken' +import { parseAccessToken, resolveAccessToken } from './accessToken' -describe('parseAccessToken', () => { - it('defaults namespace when missing', () => { - const parsed = parseAccessToken('token') - expect(parsed).toEqual({ baseToken: 'token', namespace: DEFAULT_NAMESPACE }) +describe('resolveAccessToken', () => { + it('resolves a stored user token without treating it as a namespace suffix', () => { + const lookup = { resolve: (raw: string) => raw === 'hapi_team_secret' ? { namespace: 'member-a' } : null } + expect(resolveAccessToken('hapi_team_secret', 'shared-base', lookup)).toEqual({ + baseToken: 'hapi_team_secret', + namespace: 'member-a' + }) + expect(resolveAccessToken('hapi_team_secret:member-b', 'shared-base', lookup)).toBeNull() }) - it('parses namespace suffix', () => { - const parsed = parseAccessToken('token:alice') - expect(parsed).toEqual({ baseToken: 'token', namespace: 'alice' }) - }) - - it('rejects empty namespace', () => { - expect(parseAccessToken('token:')).toBeNull() - }) - - it('rejects missing base token', () => { - expect(parseAccessToken(':alice')).toBeNull() - }) - - it('rejects whitespace inside namespace', () => { - expect(parseAccessToken('token: alice')).toBeNull() + it('keeps the legacy base-token namespace behavior for the hub owner', () => { + expect(resolveAccessToken('shared-base:default', 'shared-base')).toEqual({ + baseToken: 'shared-base', + namespace: 'default' + }) + expect(parseAccessToken('shared-base:member-a')?.namespace).toBe('member-a') }) }) diff --git a/hub/src/utils/accessToken.ts b/hub/src/utils/accessToken.ts index ca779ba7..c4fffa12 100644 --- a/hub/src/utils/accessToken.ts +++ b/hub/src/utils/accessToken.ts @@ -1,3 +1,5 @@ +import { constantTimeEquals } from './crypto' + export const DEFAULT_NAMESPACE = 'default' export type ParsedAccessToken = { @@ -5,6 +7,10 @@ export type ParsedAccessToken = { namespace: string } +type StoredAccessTokenLookup = { + resolve: (rawToken: string) => { namespace: string } | null +} + export function parseAccessToken(raw: string): ParsedAccessToken | null { if (!raw) { return null @@ -32,3 +38,26 @@ export function parseAccessToken(raw: string): ParsedAccessToken | null { return { baseToken, namespace } } + +/** + * Resolve either a per-user Team-HAPI credential or the legacy hub token. + * The legacy path remains available for the hub owner; user credentials never + * expose the shared base token and cannot select another namespace by editing + * a suffix. + */ +export function resolveAccessToken( + raw: string, + baseToken: string, + stored?: StoredAccessTokenLookup +): ParsedAccessToken | null { + const storedToken = stored?.resolve(raw) + if (storedToken) { + return { baseToken: raw, namespace: storedToken.namespace } + } + + const parsed = parseAccessToken(raw) + if (!parsed || !constantTimeEquals(parsed.baseToken, baseToken)) { + return null + } + return parsed +} diff --git a/hub/src/web/middleware/auth.ts b/hub/src/web/middleware/auth.ts index 175d7d23..e756370e 100644 --- a/hub/src/web/middleware/auth.ts +++ b/hub/src/web/middleware/auth.ts @@ -17,7 +17,12 @@ const jwtPayloadSchema = z.object({ export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler { return async (c, next) => { const path = c.req.path - if (path === '/api/auth' || path === '/api/bind') { + if ( + path === '/api/auth' + || path === '/api/bind' + || path === '/api/team/onboarding/claim' + || path.startsWith('/api/team/admin/') + ) { await next() return } diff --git a/hub/src/web/routes/auth.ts b/hub/src/web/routes/auth.ts index ff2870c9..c4859b3a 100644 --- a/hub/src/web/routes/auth.ts +++ b/hub/src/web/routes/auth.ts @@ -2,8 +2,7 @@ import { Hono } from 'hono' import { SignJWT } from 'jose' import { AuthRequestSchema } from '@hapi/protocol' import { getConfiguration } from '../../configuration' -import { constantTimeEquals } from '../../utils/crypto' -import { parseAccessToken } from '../../utils/accessToken' +import { resolveAccessToken } from '../../utils/accessToken' import { validateTelegramInitData } from '../telegramInitData' import { getOrCreateOwnerId } from '../../config/ownerId' import type { WebAppEnv } from '../middleware/auth' @@ -28,8 +27,12 @@ export function createAuthRoutes(jwtSecret: Uint8Array, store: Store): Hono SyncEngine | null): Hono { +export function createCliRoutes( + getSyncEngine: () => SyncEngine | null, + accessTokens?: { resolve: (rawToken: string) => { namespace: string } | null } +): Hono { const app = new Hono() app.use('*', async (c, next) => { @@ -83,8 +85,8 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { + it('claims an invite and rejects a second claim', async () => { + const store = new Store(':memory:') + try { + const created = store.accessTokens.createInvite({ kind: 'enroll', namespace: 'new-member' }) + const app = new Hono() + app.route('/', createTeamOnboardingRoutes(store)) + + const first = await app.request('/api/team/onboarding/claim', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ invite: created.invite }) + }) + expect(first.status).toBe(200) + expect(await first.json()).toMatchObject({ success: true, namespace: 'new-member' }) + + const second = await app.request('/api/team/onboarding/claim', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ invite: created.invite }) + }) + expect(second.status).toBe(410) + } finally { + store.close() + } + }) + + it('does not expose the invite in the guide page URL query', async () => { + const store = new Store(':memory:') + try { + const app = new Hono() + app.route('/', createTeamOnboardingRoutes(store)) + const response = await app.request('/team-guide') + const html = await response.text() + expect(response.status).toBe(200) + expect(html).toContain('Team HAPI') + expect(html).toContain('location.hash') + } finally { + store.close() + } + }) +}) diff --git a/hub/src/web/routes/teamOnboarding.ts b/hub/src/web/routes/teamOnboarding.ts new file mode 100644 index 00000000..aaee8f5e --- /dev/null +++ b/hub/src/web/routes/teamOnboarding.ts @@ -0,0 +1,202 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import { getConfiguration } from '../../configuration' +import { constantTimeEquals } from '../../utils/crypto' +import type { Store } from '../../store' + +const inviteBodySchema = z.object({ + namespace: z.string().optional(), + expiresInHours: z.number().int().min(1).max(168).optional() +}) + +const claimBodySchema = z.object({ + invite: z.string().min(1).max(512) +}) + +function getBearerToken(value: string | undefined): string | null { + if (!value?.startsWith('Bearer ')) return null + const token = value.slice('Bearer '.length).trim() + return token || null +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function publicOrigin(request: Request): string { + let configured = '' + try { + configured = getConfiguration().publicUrl.trim().replace(/\/$/, '') + } catch { + // Route-level tests can render the guide without booting the full Hub. + } + if (configured) return configured + return new URL(request.url).origin +} + +function guideUrl(request: Request, invite: string): string { + return `${publicOrigin(request)}/team-guide#invite=${encodeURIComponent(invite)}` +} + +function isAdminRequest(request: Request): boolean { + const token = getBearerToken(request.headers.get('authorization') ?? undefined) + return Boolean(token && constantTimeEquals(token, getConfiguration().cliApiToken)) +} + +function renderGuidePage(origin: string): string { + const safeOrigin = escapeHtml(origin) + return ` + + + + +Team HAPI 使用指南 + + + +
+
+

Team HAPI

+

团队共享 Hub。每个人只能看到自己的会话和机器。

+
如果你是通过邀请链接打开的,页面会自动领取账号。
+
+
+
+

第一次使用

+
    +
  1. 打开管理员发给你的邀请链接。链接只能使用一次,过期后请联系管理员重新生成。
  2. +
  3. 页面显示 Token 后,请复制保存到自己的密码管理器;管理员看不到你的 Token。
  4. +
  5. 点击“打开 Team HAPI”进入网页,或者在电脑终端配置 CLI。
  6. +
+
npm install -g @twsxtd/hapi
+export HAPI_API_URL=${safeOrigin}
+hapi auth login
+# 粘贴页面上显示的个人 Token
+hapi codex
+
+
+

Token 丢失怎么办

+

联系管理员,让管理员按你的 Namespace 生成恢复链接。恢复链接会签发一个新的 Token,但不会删除你之前的会话。

+

不要把 Token 发到群聊、截图或提交到代码仓库。

+
+
+ + +` +} + +export function createTeamOnboardingRoutes(store: Store): Hono { + const app = new Hono() + + app.get('/team-guide', (c) => { + return c.html(renderGuidePage(publicOrigin(c.req.raw))) + }) + + app.post('/api/team/admin/invite', async (c) => { + if (!isAdminRequest(c.req.raw)) return c.json({ error: 'Admin authorization required' }, 401) + const body = await c.req.json().catch(() => null) + const parsed = inviteBodySchema.safeParse(body) + if (!parsed.success) return c.json({ error: 'Invalid invite request' }, 400) + + try { + const created = store.accessTokens.createInvite({ + kind: 'enroll', + namespace: parsed.data.namespace, + expiresInHours: parsed.data.expiresInHours + }) + return c.json({ + kind: 'enroll', + namespace: created.namespace, + expiresAt: created.expiresAt, + inviteUrl: guideUrl(c.req.raw, created.invite) + }) + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : 'Failed to create invite' }, 400) + } + }) + + app.post('/api/team/admin/recovery', async (c) => { + if (!isAdminRequest(c.req.raw)) return c.json({ error: 'Admin authorization required' }, 401) + const body = await c.req.json().catch(() => null) + const parsed = inviteBodySchema.extend({ namespace: z.string() }).safeParse(body) + if (!parsed.success) return c.json({ error: 'Namespace is required' }, 400) + + try { + const created = store.accessTokens.createInvite({ + kind: 'recovery', + namespace: parsed.data.namespace, + expiresInHours: parsed.data.expiresInHours + }) + return c.json({ + kind: 'recovery', + namespace: created.namespace, + expiresAt: created.expiresAt, + inviteUrl: guideUrl(c.req.raw, created.invite) + }) + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : 'Failed to create recovery link' }, 400) + } + }) + + app.post('/api/team/onboarding/claim', async (c) => { + const body = await c.req.json().catch(() => null) + const parsed = claimBodySchema.safeParse(body) + if (!parsed.success) return c.json({ error: 'Invalid invite' }, 400) + const claimed = store.accessTokens.claimInvite(parsed.data.invite) + if (!claimed) return c.json({ error: 'Invite is invalid, expired, or already used' }, 410) + return c.json({ + success: true, + namespace: claimed.namespace, + accessToken: claimed.accessToken + }) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 3444b88c..10f5387d 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -32,6 +32,7 @@ import { createDevicesRoutes } from './routes/devices' import { createVoiceRoutes } from './routes/voice' import { createHubSettingsRoutes } from './routes/hubSettings' import { createWorkGraphRoutes } from './routes/workGraph' +import { createTeamOnboardingRoutes } from './routes/teamOnboarding' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { Server as BunServer, ServerWebSocket } from 'bun' @@ -279,10 +280,11 @@ function createWebApp(options: { return next() }) - app.route('/cli', createCliRoutes(options.getSyncEngine)) + app.route('/cli', createCliRoutes(options.getSyncEngine, options.store.accessTokens)) app.route('/api', createAuthRoutes(options.jwtSecret, options.store)) app.route('/api', createBindRoutes(options.jwtSecret, options.store)) + app.route('/', createTeamOnboardingRoutes(options.store)) app.use('/api/*', createAuthMiddleware(options.jwtSecret)) app.route('/api', createEventsRoutes(options.getSseManager, options.getSyncEngine, options.getVisibilityTracker))