mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
feat(team): add scoped onboarding tokens and guide
Co-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
@@ -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' }
|
||||
]
|
||||
|
||||
@@ -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 放入群聊、截图、代码仓库或工单。
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 `<!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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user