feat(team): add scoped onboarding tokens and guide

Co-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
2026-09-16 09:19:26 +08:00
co-authored by Codex
parent 7972dd347c
commit a435ea5cb8
15 changed files with 640 additions and 39 deletions
+1
View File
@@ -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' }
]
+57
View File
@@ -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 放入群聊、截图、代码仓库或工单。
+5 -4
View File
@@ -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<string, unknown> | 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
+62
View File
@@ -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()
}
})
})
+153
View File
@@ -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
}
}
+39
View File
@@ -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 (
+15 -20
View File
@@ -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')
})
})
+29
View File
@@ -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
}
+6 -1
View File
@@ -17,7 +17,12 @@ const jwtPayloadSchema = z.object({
export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler<WebAppEnv> {
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
}
+7 -4
View File
@@ -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<WebA
// Access Token authentication (CLI_API_TOKEN)
if ('accessToken' in parsed.data) {
const configuration = getConfiguration()
const parsedToken = parseAccessToken(parsed.data.accessToken)
if (!parsedToken || !constantTimeEquals(parsedToken.baseToken, configuration.cliApiToken)) {
const parsedToken = resolveAccessToken(
parsed.data.accessToken,
configuration.cliApiToken,
store.accessTokens
)
if (!parsedToken) {
return c.json({ error: 'Invalid access token' }, 401)
}
userId = await getOrCreateOwnerId()
+7 -4
View File
@@ -2,8 +2,7 @@ import { Hono } from 'hono'
import { SignJWT } from 'jose'
import { z } from 'zod'
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'
@@ -25,8 +24,12 @@ export function createBindRoutes(jwtSecret: Uint8Array, store: Store): Hono<WebA
}
const configuration = getConfiguration()
const parsedToken = parseAccessToken(parsed.data.accessToken)
if (!parsedToken || !constantTimeEquals(parsedToken.baseToken, configuration.cliApiToken)) {
const parsedToken = resolveAccessToken(
parsed.data.accessToken,
configuration.cliApiToken,
store.accessTokens
)
if (!parsedToken) {
return c.json({ error: 'Invalid access token' }, 401)
}
const namespace = parsedToken.namespace
+7 -5
View File
@@ -9,8 +9,7 @@ import {
} from '@hapi/protocol'
import { getConfiguration } from '../../configuration'
import { readSessionSummaryContractEnabled } from '../../config/sessionSummaryContract'
import { constantTimeEquals } from '../../utils/crypto'
import { parseAccessToken } from '../../utils/accessToken'
import { resolveAccessToken } from '../../utils/accessToken'
import type { Machine, Session, SyncEngine } from '../../sync/syncEngine'
import { SessionIdentityConflictError } from '../../store/sessions'
@@ -65,7 +64,10 @@ function clearErrorStatus(code: string): 403 | 404 | 409 | 500 {
: 500
}
export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono<CliEnv> {
export function createCliRoutes(
getSyncEngine: () => SyncEngine | null,
accessTokens?: { resolve: (rawToken: string) => { namespace: string } | null }
): Hono<CliEnv> {
const app = new Hono<CliEnv>()
app.use('*', async (c, next) => {
@@ -83,8 +85,8 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono<Cl
const token = parsed.data.replace(/^Bearer\s+/i, '')
const configuration = getConfiguration()
const parsedToken = parseAccessToken(token)
if (!parsedToken || !constantTimeEquals(parsedToken.baseToken, configuration.cliApiToken)) {
const parsedToken = resolveAccessToken(token, configuration.cliApiToken, accessTokens)
if (!parsedToken) {
return c.json({ error: 'Invalid token' }, 401)
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'bun:test'
import { Hono } from 'hono'
import { Store } from '../../store'
import { createTeamOnboardingRoutes } from './teamOnboarding'
describe('Team onboarding', () => {
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()
}
})
})
+202
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
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 `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Team HAPI 使用指南</title>
<style>
:root { color-scheme: light dark; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
body { margin: 0; background: #f5f6f8; color: #17181b; }
main { max-width: 720px; margin: 0 auto; padding: 32px 20px 56px; }
section { background: white; border-radius: 18px; padding: 24px; margin: 16px 0; box-shadow: 0 5px 24px #0000000d; }
h1 { margin: 0 0 8px; font-size: 28px; }
h2 { font-size: 18px; margin: 0 0 12px; }
p, li { line-height: 1.65; }
code, pre { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; }
pre { overflow: auto; background: #f0f1f4; border-radius: 10px; padding: 14px; }
.muted { color: #6d717b; }
.success { color: #087443; }
.error { color: #b42318; }
.token { word-break: break-all; user-select: all; background: #eef8f1; border: 1px solid #b8e1c4; border-radius: 10px; padding: 12px; }
a.button { display: inline-block; background: #1769e0; color: white; padding: 10px 15px; border-radius: 9px; text-decoration: none; }
@media (prefers-color-scheme: dark) { body { background: #111315; color: #f1f2f4; } section { background: #1b1e22; } pre { background: #252930; } .muted { color: #a5abb5; } .token { background: #15271b; border-color: #285a39; } }
</style>
</head>
<body>
<main>
<section>
<h1>Team HAPI</h1>
<p class="muted">团队共享 Hub。每个人只能看到自己的会话和机器。</p>
<div id="claim-status" class="muted">如果你是通过邀请链接打开的,页面会自动领取账号。</div>
<div id="claim-result"></div>
</section>
<section>
<h2>第一次使用</h2>
<ol>
<li>打开管理员发给你的邀请链接。链接只能使用一次,过期后请联系管理员重新生成。</li>
<li>页面显示 Token 后,请复制保存到自己的密码管理器;管理员看不到你的 Token。</li>
<li>点击“打开 Team HAPI”进入网页,或者在电脑终端配置 CLI。</li>
</ol>
<pre>npm install -g @twsxtd/hapi
export HAPI_API_URL=${safeOrigin}
hapi auth login
# 粘贴页面上显示的个人 Token
hapi codex</pre>
</section>
<section>
<h2>Token 丢失怎么办</h2>
<p>联系管理员,让管理员按你的 Namespace 生成恢复链接。恢复链接会签发一个新的 Token,但不会删除你之前的会话。</p>
<p class="muted">不要把 Token 发到群聊、截图或提交到代码仓库。</p>
</section>
</main>
<script>
(() => {
const status = document.getElementById('claim-status');
const result = document.getElementById('claim-result');
const params = new URLSearchParams(location.hash.startsWith('#') ? location.hash.slice(1) : '');
const invite = params.get('invite');
if (!invite) return;
status.textContent = '正在领取你的 Team HAPI 账号…';
fetch('/api/team/onboarding/claim', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ invite })
}).then(async response => {
const body = await response.json().catch(() => ({}));
if (!response.ok || !body.accessToken) throw new Error(body.error || '邀请链接无效或已使用');
localStorage.setItem('hapi_access_token::' + location.origin, body.accessToken);
status.textContent = '账号已创建,请保存下面的 Token。';
result.innerHTML = '<p class="success">Namespace:<strong>' + body.namespace + '</strong></p>'
+ '<p class="token">' + body.accessToken + '</p>'
+ '<p><a class="button" href="/">打开 Team HAPI</a></p>';
history.replaceState(null, '', location.pathname);
}).catch(error => {
status.className = 'error';
status.textContent = error instanceof Error ? error.message : '邀请链接无效或已使用';
});
})();
</script>
</body>
</html>`
}
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
}
+3 -1
View File
@@ -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))