Project AIRI 独立认证服务(AIRI Auth Server)架构与部署全解析:Better Auth、OIDC 与资源 API 私网协同
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
本项目托管后端将认证/身份体系拆分为独立的 Auth 服务(server/apps/auth),以 Better Auth 为核心承载会话、社交登录、Magic Link、密码与 OIDC 流程,并通过私有网络与资源 API 协同完成账号生命周期管理。读完本文,你将掌握 AIRI Auth Server 的模块边界与职责划分、本地与 Railway 两种运行方式、完整的环境变量清单,以及其账号删除、封禁、OIDC 客户端托管等关键机制的源码级实现原理。
服务定位与核心职责
AIRI Auth Server 是一个独立的身份与认证应用(standalone authentication and identity application),它从业务 API 中剥离出来,专门负责 Project AIRI 的认证面。根据 server/apps/auth/README.md,其核心职责包括:
- Better Auth 会话体系:会话(session)、社交登录(social login)、Magic Link、邮箱密码登录以及 OIDC 流程的统一承载;
- 三类 HTTP 面:
/api/auth/*、/auth/*,以及认证发现端点(discovery endpoints,如/.well-known/oauth-authorization-server/api/auth与/api/auth/.well-known/openid-configuration); - Auth 自有的基础设施:归 Auth 进程独占的 Redis 配置、事务邮件(transactional email)与认证遥测(auth telemetry);
- 私网协同删除:在删除业务数据之前,通过部署私网调用资源 API,完成跨服务的数据清理编排。
Auth 只做"身份",不做业务:产品 API、计费、模型路由、聊天、WebSocket 业务状态一律不属于它。这一边界在代码中也得到贯彻——server/apps/auth/src/server.ts 的注释明确写道:"Only authentication infrastructure is registered here; business services remain owned by the resource API process"(此处只注册认证基础设施,业务服务仍归资源 API 进程所有)。
扁平代码布局与模块边界
该服务刻意保持扁平("The runtime is intentionally flat"),主要边界如下:
| 文件 | 职责 |
|---|---|
| auth.ts | Better Auth 配置与身份生命周期钩子 |
| routes.ts | 完整公开 Auth HTTP 面与请求认证 |
| server.ts | 依赖组合、健康检查、进程生命周期 |
| resource-api.ts | 唯一的 Auth→资源 API 私网边界 |
| rate-limit.ts 与 otel.ts | 跨路由的运营策略(限流与遥测) |
| email.ts 与 oidc-jwt-bearer.ts | 重量级外部集成模块 |
| db.ts、env.ts、error.ts、origin.ts | 贴近边界的小型共享契约 |
src/tests | 测试集合 |
src/tooling(auth-config.ts) | Better Auth schema 生成接线,独立隔离 |
进程级入口是 main.ts,启动时通过 instrumentation.ts 预加载 OpenTelemetry 插桩(pg必须保持静态导入,以便插桩在应用模块求值之前完成补丁,见 db.ts 的注释)。
Auth 与共享契约包@proj-airi/auth-shared
认证表结构与主体契约并不放在 Auth 应用内,而是放在共享包 server/packages/auth-shared,由资源 API 与独立 Auth 服务共同依赖。其 README 明确了使用边界:
- 可以放:Better Auth 拥有的 PostgreSQL schema、服务端代码中交换的认证主体/会话形态(session.ts 中的
AuthSession)、必须跨进程保持一致性的授权策略(如封禁过期判断isUserBannedNow); - 不可以放:Better Auth 运行时构造或 HTTP 路由、服务环境变量解析、数据库连接池、Redis、邮件、遥测,以及任何对
server/apps/api或server/apps/auth的导入。
这种"共享协议而不互相依赖"的设计让两个进程可以在不 import 对方的前提下引用同一套契约。从 schema.ts 可以看到完整的表结构:user、session、account、verification、jwks、oauth_client、oauth_refresh_token、oauth_access_token、oauth_consent,并配齐了 Drizzle relations 与常用索引(如session_userId_idx、account_account_id_provider_id_idx)。
共享数据库迁移的所有权
一个关键约定是:API 是共享数据库迁移的所有者(migration owner),Auth 不运行迁移。Auth 启动时只做连通性探测(SELECT 1),从不与迁移竞争;就绪检查也故意探测自有表(SELECT 1 FROM "user" LIMIT 1),从而保证在迁移所有者装好 auth schema 之前就绪状态保持为 false。这一行为在 server.ts 的/readyz实现与 app.test.ts 的测试断言中都能看到。
本地运行:单服务与全栈两种姿势
仅启动 Auth 服务
pnpm -F @proj-airi/auth-server dev服务从本目录读取.env.local。package.json中的apply:env脚本使用dotenvx run -f .env.local --overload --ignore=MISSING_ENV_FILE加载环境变量,dev 与 start 均通过tsx --import ./instrumentation.ts启动。
两个关键环境变量:
PUBLIC_URL:经由 Caddy 呈现的公开 issuer 源(public issuer origin);RESOURCE_SERVER_URL:用于内部调用的私有资源 API地址。
全栈一键启动
pnpm dev:backend该命令从仓库根目录运行,使用 server/docker-compose.yaml 拉起 PostgreSQL、Redis、资源 API 与 Auth 的完整组合。注意:
- 只对外暴露本地 Caddy 网关:
http://localhost:6112; - API 与 Auth 停留在 Caddy 的私有网络内;
- 内部
/internal/*边界没有任何应用令牌,Caddy 在公网边缘直接拒绝该路径。
环境变量全景(来自 env.ts)
Auth 进程的环境变量由 valibot schemaAuthEnvSchema严格校验,非法配置会导致进程exit(1)并输出错误日志:
| 变量 | 默认值 | 必填 | 说明 |
|---|---|---|---|
HOST | 0.0.0.0 | 否 | 监听地址 |
PORT | 3000 | 否 | 监听端口(≥1 的整数) |
PUBLIC_URL | http://localhost:3000 | 否 | Better Auth 与 OIDC issuer 源 |
RESOURCE_SERVER_URL | http://localhost:3001 | 否 | 资源 API 私有地址 |
RATE_LIMIT_TRUSTED_PROXY | 无 | 否 | 可选值仅railway,显式声明代理信任边界 |
AUTH_UI_URL | https://accounts.airi.build/ui | 否 | 独立认证 UI 地址 |
ADDITIONAL_TRUSTED_ORIGINS | 空 | 否 | 逗号分隔的额外可信源,会做 URL 解析与去重、归一化为 origin |
DATABASE_URL | — | 是 | PostgreSQL 连接串 |
REDIS_URL | — | 是 | Redis 连接串 |
BETTER_AUTH_SECRET | — | 是 | Better Auth 会话签名密钥 |
AUTH_GOOGLE_CLIENT_ID/AUTH_GOOGLE_CLIENT_SECRET | — | 是 | Google OAuth 凭据 |
AUTH_GITHUB_CLIENT_ID/AUTH_GITHUB_CLIENT_SECRET | — | 是 | GitHub OAuth 凭据 |
AUTH_APPLE_CLIENT_ID | 空 | 否 | Apple 服务 ID |
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS | 空 | 否 | 逗号分隔的原生 App Bundle ID 白名单(去重) |
AUTH_APPLE_TEAM_ID | 空 | 否 | Apple Team ID |
AUTH_APPLE_KEY_ID | 空 | 否 | Apple 密钥 ID |
AUTH_APPLE_PRIVATE_KEY_PEM | 空 | 否 | Apple 私钥 PEM(自动将\n字面量还原为真实换行,兼容部署面板的转义存储) |
RESEND_API_KEY | 空 | 否 | Resend 事务邮件密钥(未配置时相关回调会报 503) |
RESEND_FROM_EMAIL | noreply@airi.moeru.ai | 否 | 发件地址 |
RESEND_FROM_NAME | Project AIRI | 否 | 发件人名称 |
DB_POOL_MAX | 20 | 否 | 连接池上限 |
DB_POOL_IDLE_TIMEOUT_MS | 30000 | 否 | 空闲超时 |
DB_POOL_CONNECTION_TIMEOUT_MS | 5000 | 否 | 连接超时 |
DB_POOL_KEEPALIVE_INITIAL_DELAY_MS | 10000 | 否 | keepalive 初始延迟 |
OTEL_SERVICE_NAME | auth-server | 否 | OpenTelemetry 服务名 |
OTEL_EXPORTER_OTLP_ENDPOINT | 无 | 否 | 设置后启用 OTLP 导出;未设置则 OTel 整体禁用(initAuthOtel返回 null) |
其中DB_POOL_*通过optionalIntegerFromString校验为整数并设下限;ADDITIONAL_TRUSTED_ORIGINS不仅校验 URL 合法性,还会统一收敛为origin并去重,这为后续 CORS 与回调校验提供了归一化的可信源集合。
Railway 部署:完整服务契约
在 Railway 上,将 Auth 部署为独立的 Auth Railway 服务:
- Config File Path:
/server/apps/auth/railway.toml; - Root Directory:必须保持在仓库根目录——因为 Dockerfile 会拷贝工作区清单与
server/packages/auth-shared; - 配置文件自身拥有 Dockerfile、启动命令、
/readyz健康检查,以及每个被拷贝构建输入的 watch 模式。
railway.toml 内容如下:
[build] builder = "DOCKERFILE" dockerfilePath = "/server/apps/auth/Dockerfile" watchPatterns = [ "server/apps/auth/**", "server/packages/auth-shared/**", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "tsconfig.json", "patches/**" ] [deploy] startCommand = "pnpm -F @proj-airi/auth-server start" healthcheckPath = "/readyz" healthcheckTimeout = 100对应的 Dockerfile 基于node:24-alpine,先corepack enable,拷贝锁文件与patches/后执行pnpm install --frozen-lockfile --ignore-scripts --filter @proj-airi/auth-server...,构建后以非 root 用户airi运行,EXPOSE 3000。
两个关键变量的一致性约束
- 将
PUBLIC_URL设置为该服务的规范公开 issuer URL; - 让资源 API 的
AUTH_SERVER_URL与此值完全一致(完全相同,不能只是同源); RESOURCE_SERVER_URL取自资源 API 的 Railway私有域名;- 不要从 Auth 运行共享数据库迁移。
server/README.md 的 Railway 部署章节给出了完整的双向服务契约表:
| 消费者 | 变量 | 值来源 | 用途 |
|---|---|---|---|
| 资源 API | AUTH_SERVER_URL | Auth 规范公开 issuer URL | JWT issuer、audience 与公开 JWKS 身份 |
| 资源 API | AUTH_SERVER_INTERNAL_URL | Auth 的 Railway 私有域名 | 私有 JWKS 拉取;不改变 issuer 校验 |
| Auth | PUBLIC_URL | Auth 规范公开 issuer URL | Better Auth 与 OIDC issuer URL;必须等于 API 的AUTH_SERVER_URL |
| Auth | RESOURCE_SERVER_URL | API 的 Railway 私有域名 | 删除用户业务数据前的私有调用 |
此外还约定:数据库、Redis、可观测性变量用 Railway 引用变量共享而非复制;RATE_LIMIT_TRUSTED_PROXY=railway仅对直接接收 Railway 代理流量的服务设置;/internal/*保持私有,公开路由不得暴露 API 的内部 Auth 路由。部署后 Railway 必须收到/readyz的200——仅"部署成功"不足以证明服务能触达依赖(这正是/readyz同时探测user表与 Redis ping 的原因)。
源码级纵深:createAuth 的完整装配
createAuth 是认证能力的总装配点,值得逐项拆解:
插件栈
plugins: [ bearer(), jwt(), banGuard(), oidcJwtBearer(env), steam(), magicLink({ ... }), oauthProvider({ ... }), ]bearer()+jwt():Better Auth 内置的 Bearer token 与会话 JWT 支持;banGuard()(plugins/ban-guard.ts):在 Better Auth 创建会话的session.create.before钩子中读取user.banned/banExpires,通过共享的isUserBannedNow判断,被封禁用户抛FORBIDDEN BANNED_USER。它只施加持久化封禁状态,不清除过期封禁(并发管理请求可能在会话钩子读取后续封);封禁字段banned、banReason、banExpires由私有管理后端拥有,客户端不可写入(input: false);oidcJwtBearer(env):弥合架构错配的关键桥接——Better Auth 官方的 OIDC 故事假设 IdP 与资源服务器是不同进程/信任域,而本项目把两者放在一个进程。该插件识别 JWT 形态的 Bearer token(三段 base64url),用本地 JWKS 做 RS256 校验,然后铸造一个 5 分钟 TTL 的"桥接会话"行,并注入better-auth.session_tokencookie,让sessionMiddleware及所有下游/api/auth/*端点都能接受 OIDC 签发的 JWT access token(详见 oidc-jwt-bearer.ts);steam():Steam 网页登录是 OpenID 2.0 而非 OAuth2/OIDC,无法作为socialProviders条目,因此实现为独立插件,新增POST /sign-in/steam、POST /link/steam、GET /steam/callback端点。Steam 不提供邮箱,新注册用户使用占位邮箱<steamid64>@steam.placeholder.local且emailVerified: true(占位邮箱收不到信,验证无意义且会永久阻塞登录);回调通过 OpenID "dumb mode"(openid.mode=check_authentication回传 Steam 验证)而非自验 RSA 签名,省去关联/会话状态管理,代价是每次登录多一次 HTTP 往返(详见 plugins/steam.ts);magicLink():sendMagicLink回调委托EmailService;邮件服务未配置时通过requireEmailService抛出 503,让配置错误"响亮而非沉默";oauthProvider():OIDC 授权服务器核心。配置loginPage: '/auth/sign-in'、consentPage: '/oauth/authorize'、作用域openid profile email offline_access、validAudiences: [env.PUBLIC_URL]、accessTokenExpiresIn: 3600。代码注释特别强调不要开启cachedTrustedClients——因为运行时会对受信客户端动态修改redirectUris,缓存会导致进程内残留过期白名单并引发invalid_redirect直到重启。
安全策略细节
- 禁用内置 token 路由:
disabledPaths: ['/token'],公开 OIDC token 端点由 oauthProvider 在/oauth2/token独占; - 真实 IP 提取:
advanced.ipAddress.ipAddressHeaders: ['x-real-ip']——Caddy 会从 Cloudflare 客户端地址重建该头;默认的X-Forwarded-For含代理链,会把无关客户端折叠进同一限流桶; - Capacitor 移动端适配:
account.skipStateCookieCheck: true。Capacitor 的 OAuth 用系统浏览器(与 WebView 分离的 cookie jar),签名 state cookie 必然缺失,否则会报state_security_mismatch;同时accountLinking.allowDifferentEmails: true满足"登录用户可绑定与 AIRI 账号邮箱不同的 OAuth 身份"的产品需求; - 会话落库:
session.storeSessionInDatabase: true——oauthProvider 的oauth_access_token表对 session 表有外键,不落库则签发 token 时外键 INSERT 失败; - 刻意关闭
cookieCache:若开启,/oauth2/end-session删除 DB 会话行但不清 cookie,签名sessionDatacookie 会持续呈现"有效会话",下一次/oauth2/authorize把授权码绑定到已删除的 session.id,/oauth2/token便报invalid_request: session no longer exists,导致登出后整个 TTL 窗口内无法登录。作为补偿,after钩子对/oauth2/end-session镜像执行deleteSessionCookie,让 RP-Initiated Logout 彻底失效客户端可见会话。
邮件驱动的生命周期
emailAndPassword启用并要求邮箱验证(requireEmailVerification: true;Google/GitHub 社交登录因 OAuth 天然发放已验证账号而绕过)。邮箱验证在注册时自动发送(sendOnSignUp: true),并开启autoSignInAfterVerification——用户点击验证链接即建立会话 cookie,原标签页通过轮询检测新会话并恢复 OIDC 交接。
user.changeEmail的确认邮件发送到当前邮箱而非新邮箱(发到newEmail会让只控制新邮箱的攻击者确认一次接管)。密码重置成功后钩子会强制emailVerified: true(点击重置链接本身就是拥有邮箱的证明)。
两步式账号删除
删除账号是典型的跨服务编排:POST /api/auth/delete-user触发sendDeleteAccountVerification,点击邮件链接命中GET /api/auth/delete-user/callback,Better Auth 在校验 token 后于硬删除 user 行之前调用beforeDelete:
async beforeDelete(user) { await socialAuthorization.revokeForUser(user.id) await requireResourceApi(resourceApi).softDeleteUserData({ userId: user.id, reason: 'user-requested', }) }即先吊销外部授权(OAuth 凭据),再经私网调用资源 API 软删除业务数据。两步操作均幂等,局部失败可重试;若在beforeDelete抛错,用户行与验证 token 保持完好,同一回调可恢复尝试。
遥测与数据库钩子
hooks.before/after通过 OpenTelemetry 计数认证尝试与失败(带auth.method属性),并在 OAuth 回调出错时重定向回 referer 而非返回 API JSON。databaseHooks:
user.create.after:计数注册并尽力上报user_signed_up事件到资源 API;user.update.after:当用户被置为banned: true时删除其全部oauth_refresh_token/oauth_access_token——oauthProvider 的刷新授权不检查banned,被封禁用户理论上可凭有效 refresh token 铸造新 access token(虽然资源路径会被isUserBannedNow拒绝),这里从源头切断凭据;session.create.after:更新lastSeenAt与登录计数(尽力而为,失败仅告警不阻断)。
Auth 到资源 API 的私网边界
resource-api.ts 定义了唯一的跨服务 HTTP 边界,两个方法:
softDeleteUserData({ userId, reason }):POST /internal/auth/user-deletion,非 2xx 抛 502BAD_GATEWAY;trackAuthEvent(...):POST /internal/auth/events,失败仅告警("analytics must never make signup or login unavailable")。
调用目标基于RESOURCE_SERVER_URL(私网域名)构造,公开边缘不得暴露/internal/*。beforeDelete缺少resourceApi时抛 503,杜绝静默空操作。
HTTP 面与跨路由运营策略
routes.ts 挂载在根级(因为路由横跨/auth/*、/api/auth/*、/.well-known/*多个前缀):
/auth/*重定向到独立认证 UI(AUTH_UI_URL,生产为accounts.airi.build/ui),保留路径与查询参数,并附带api_server_url参数供 UI 定位 API 源;/api/auth/*全量限流:max: 20 / windowSec: 60,使用 hono-rate-limiter,默认内存存储(单实例)。key 优先级:已认证 userId → 受信代理客户端地址(仅RATE_LIMIT_TRUSTED_PROXY=railway时读x-real-ip且必须是合法 IP)→ 连接信息 →anonymous;标准头采用draft-6格式以保证RateLimit-*头兼容性;被拦截请求计数airi.rate_limit.blocked指标(带route、key_type、limit标签);- 管理路由一律 404:
/api/auth/admin及其子路径全部返回 404,测试 app.test.ts 明确断言管理端点不可达、认证处理不被触发; - 动态受信回调 URI:
/api/auth/oauth2/authorize前先执行ensureDynamicFirstPartyRedirectUri,把基于PUBLIC_URL推导出的合法/auth/callback(web)或同源/api/auth/oidc/electron-callback(Electron)动态写入oauth_client.redirect_uris,从而支持分支预览部署; - userinfo 的封禁复核:
/api/auth/oauth2/userinfo绕过 sessionMiddleware 且只验签名,被封禁用户的仍有效 access token(≤1h TTL)可能读到自身 claim,故该端点单独解析 subject(忽略封禁)再判断,封禁则 403,无效/过期 token 仍回落到 Better Auth 自身的 401; - Electron 回调中继:
/api/auth/oidc/electron-callback返回一个 HTML 页,通过 JSfetch()把授权码转发给 Electron 环回服务器,避免浏览器直接导航到http://127.0.0.1:{port}; - 发现端点:
/.well-known/oauth-authorization-server/api/auth(OAuth 2.1 授权服务器元数据,issuer 带路径时须放根级 well-known)与/api/auth/.well-known/openid-configuration,均带Cache-Control: public, max-age=15, stale-while-revalidate=15, stale-if-error=86400; - 邮箱标识检查:
POST /api/auth/check-email返回{ exists, hasPassword },支撑统一登录/注册 UI 决策(有凭据账号则渲染密码框,纯社交账号则引导社交登录)。这是账号枚举披露,作者注释明确这是对标 Google/Linear/Notion 的标准权衡,并依赖限流抑制枚举; - 健康检查:
/livez恒返回{ status: 'live' };/readyz并行SELECT 1 FROM "user" LIMIT 1与redis.ping,全过返回 200,否则 503; - 会话解析:
resolveAuthRequest优先走 Better Auth 会话,回落 Bearer JWT 的 JWKS 校验(issuer 为PUBLIC_URL/api/auth、audience 为PUBLIC_URL),最后统一经isUserBannedNow过滤;/get-session还会为无头像用户生成 Gravatar identicon URL。
受信源与 OIDC 客户端托管
origin.ts 维护三层受信策略:
- 精确源白名单
TRUSTED_EXACT_ORIGINS:生产 Webhttps://airi.moeru.ai、Capacitor iOScapacitor://localhost、Android 深链ai.moeru.airi-pocket://links、认证 UIhttps://accounts.airi.build、管理 UIhttps://admin.airi.build等; - 正则模式:
http://localhost(:port)?、http://127.0.0.1(:port)?(含 https)、Cloudflare Workers 子域*.moeru-ai.workers.dev——注意私有 LAN/CGNAT 主机(如 cap-vite 的https://10.x:5273)不匹配任何正则,必须显式通过ADDITIONAL_TRUSTED_ORIGINS加入; - Better Auth
trustedOrigins通配:http://localhost:*与http://127.0.0.1:*永远可信(环回地址公网不可达,能解析到 localhost 的源必然与用户同机),供回调 URL 校验使用;原生深链 scheme 不能照抄进回调校验(Better Auth 按前缀匹配非 http(s) 源)。
受信 OIDC 客户端在 auth.ts 中以种子(seed)形式内建,共三个第一方客户端,全部为public client + PKCE(WebView/二进制无法安全保存 secret,Electron 客户端 secret 只是混淆而非机密边界):
| clientId | 名称 | 类型 | redirect URIs |
|---|---|---|---|
airi-stage-web | AIRI Stage Web | web | 默认三连(生产 + localhost:5173/4173)动态并入PUBLIC_URL派生源 |
airi-stage-electron | AIRI Stage Desktop | native | {PUBLIC_URL}/api/auth/oidc/electron-callback |
airi-stage-pocket | AIRI Stage Mobile | native | capacitor://localhost/auth/callback、ai.moeru.airi-pocket://links/auth/callback |
种子客户端在启动时由 seedTrustedClients 写入oauth_client表:已存在则按当前配置更新(如 public ↔ confidential 变更),否则插入。机密客户端 secret 存储前会用SHA-256 → base64url(去填充)哈希,以匹配 oauthProvider 内部默认的storeClientSecret: "hashed"模式——若用明文 raw INSERT,token 交换校验时会失败。服务启动日志会逐个打印就绪客户端的clientId、名称与 redirectUris。
测试保障
src/tests下的 Vitest 测试覆盖了上述关键行为,可作为行为契约阅读:
- app.test.ts:管理端点 404、根路径 issuer 标识、业务路由不泄漏、
/readyz只探测认证所需基础设施; - auth.test.ts、ban-guard.test.ts:封禁会话拦截;
- resource-api.test.ts:私网边界调用;
- rate-limit.test.ts:限流 key 与拦截;
- routes-ui.test.ts、routes-userinfo.test.ts:UI 重定向与 userinfo 封禁复核;
- social-authorization.test.ts、steam.test.ts:社交授权吊销与 Steam OpenID;
- env.test.ts、origin.test.ts:环境校验与受信源策略。
使用边界小结
最后,回到 server/apps/auth/README.md 划定的"不要用它做什么"红线,这也是整个架构最重要的约定:
- 不要承载产品 API、计费、模型路由、聊天或 WebSocket 业务状态;
- 不要从 Auth 导入
server/apps/api的模块; - 不要在正常进程启动时运行共享数据库迁移历史——Auth 表与主体契约在
@proj-airi/auth-shared,Drizzle 在 API 启动时读取共享迁移文件,API 始终是迁移所有者。
理解这三条边界,就把握住了 AIRI 后端"认证独立、业务独立、迁移唯一所有者"的总体设计意图。若需要完整的服务间契约,可继续阅读 server/README.md 的 Railway deployment 章节;若想深入迁移生成链路,可查看src/tooling/auth-config.ts(对应脚本pnpm run auth:generate,输出直写server/packages/auth-shared/src/schema.ts)。
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考