Establish secure account and managed AI backend

Provide the production foundation for Apple identity, immutable credits, referrals, integrity checks, managed providers, and hardened Docker deployment.
This commit is contained in:
Rocky
2026-08-16 14:46:23 +08:00
commit 0af35d44f4
124 changed files with 21052 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
# OSGAccountServer architecture
OSGAccountServer is a modular Ktor application. It owns OSG accounts, credits,
referrals, integrity checks, and metered access to OSG-hosted providers.
It does not replace the app's local ASR, BYOK provider access, or iCloud sync.
## Trust boundaries
1. Sign in with Apple identifies a user. The service stores only Apple's stable
subject, encrypted Apple refresh credentials, and internal identifiers.
2. The host app owns account-management credentials in its private Keychain.
3. The keyboard extension receives a separately revocable gateway grant. That
grant cannot delete an account, redeem referrals, or mutate credits directly.
4. Provider credentials exist only in the server process.
5. Audio and text are transient request data. They must not be persisted or
included in logs, traces, metrics labels, or error messages.
## Modules
- `auth` verifies Apple credentials and manages rotating application sessions.
- `account` reads and deletes accounts.
- `appleevents` handles consent and account lifecycle notifications.
- `credits` owns the immutable ledger and its balance projection.
- `referrals` binds one inviter and awards both users after qualified usage.
- `integrity` evaluates DeviceCheck and App Attest evidence.
- `gateway` proxies Volcengine ASR and DeepSeek text requests.
- `inviteweb` serves the first-party invitation landing page.
Modules communicate through narrow ports. Provider clients, Apple clients,
integrity clients, clocks, token generators, and repositories are replaceable in
tests.
## Monetary invariants
- Credit amounts are signed 64-bit integers; public inputs never accept decimal
credit values.
- Ledger rows are immutable.
- Every mutation has a unique idempotency key.
- Available and reserved balances never become negative.
- Reservation, settlement, release, refund, and referral rewards run in database
transactions with the account rows locked.
- A referral can transition to `rewarded` exactly once.
- Usage records reference the exact rate-card version used for settlement.
## Hosted request lifecycle
1. Authenticate a gateway-scoped principal and verify request integrity.
2. Validate payload size and feature-specific policy.
3. Reserve the maximum expected credits.
4. Call the allowlisted upstream provider.
5. Settle from provider usage or server-observed ASR duration.
6. Release on transport/provider failure or empty output.
7. Qualify a pending referral only after a successful non-zero settlement.
## Data retention
- No audio, transcript, prompt, context, or generated response body is stored.
- Authentication and integrity payloads are retained only as hashes or validated
claims required for replay protection.
- Usage metadata contains feature, model, metering units, latency, status, and
rate-card version.
- Operational logs use request IDs and internal opaque IDs, never Apple subjects
or bearer credentials.
- Account deletion revokes credentials and removes user-linked records. Only
non-identifying aggregate service metrics may remain.
+49
View File
@@ -0,0 +1,49 @@
# MySQL 8.4 备份与恢复
## 策略
- 由 1Panel 每日执行一次完整备份,保留 30 天;备份写入与应用主机隔离的对象存储。
- MySQL 启用 binary log,至少保留 7 天,用于完整备份后的时间点恢复。
- 数据库备份和对象存储均启用加密;备份账号只授予备份所需权限。
- 每月至少在隔离环境恢复一次,并记录恢复点、耗时和校验结果。未做恢复演练的备份不能视为可用。
- 发布含 Flyway 迁移的镜像前,先完成一次可验证的备份。Flyway 迁移只向前执行。
## 命令行完整备份
先用 `mysql_config_editor` 为受限备份账号建立本机登录路径,避免把密码写入命令行或脚本:
```bash
mysql_config_editor set --login-path=osg-backup \
--host=127.0.0.1 --user=osg_backup --password
```
在只允许管理员读取的目录执行一致性备份:
```bash
umask 077
mysqldump --login-path=osg-backup \
--single-transaction --quick --routines --triggers --events \
--set-gtid-purged=OFF --default-character-set=utf8mb4 \
osg_account | gzip -9 > "osg_account-$(date -u +%Y%m%dT%H%M%SZ).sql.gz"
```
备份任务应在上传后生成 SHA-256 校验值,并按保留策略删除过期副本。不要将备份、登录路径文件或校验清单提交到 Git。
## 隔离恢复演练
恢复目标必须是新建的隔离 MySQL 8.4 实例,不能覆盖生产库:
```bash
mysql --login-path=osg-restore -e \
"CREATE DATABASE osg_account_restore CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"
gzip -dc osg_account-YYYYMMDDTHHMMSSZ.sql.gz | \
mysql --login-path=osg-restore osg_account_restore
mysql --login-path=osg-restore osg_account_restore -e \
"SELECT version, success FROM flyway_schema_history ORDER BY installed_rank"
```
恢复后执行应用只读验收、表行数抽样、外键检查和邀请查询测试。时间点恢复应先还原完整备份,再仅回放目标时间之前的 binary log。确认结果后销毁隔离实例及明文恢复文件。
## 1Panel
1Panel 的数据库备份任务使用相同的每日频率、异地存储、加密和保留期。启用任务成功/失败通知,并每月下载一份备份到隔离 MySQL 8.4 完成恢复演练。1Panel 面板、工单和截图中不得出现数据库密码、Apple 私钥或供应商 API Key。
+198
View File
@@ -0,0 +1,198 @@
# OSG Account Server 部署指南
本方案使用一个非 root、只读文件系统兼容的 JDK 21 容器。OpenResty 终止 TLS 并反向代理,
MySQL 复用已有实例。`compose.yaml` 不会创建数据库容器。
## 1. 前置条件
- `account.osglab.com``osglab.com` 已解析到 1Panel 主机。
- Docker Engine、Docker Compose v2 和 1Panel OpenResty 已安装。
- 已有 MySQL 8 实例可从应用容器通过 Docker 外部网络或私网访问。
- 服务器时钟同步,Apple、DeepSeek、Volcengine 的出站 HTTPS/WSS 可用。
- App 的 Associated Domains 包含 `applinks:osglab.com`
邀请使用一方 Universal Link,不依赖 Firebase Dynamic Links 或 Branch。
## 2. 注册邀请网页路由
网页实现在 `features/inviteweb`,资源在 `src/main/resources/invite/index.html`。组合根应只注册
一个 `/i/{code}` 路由;不要与旧 `features/invite` 路由同时挂载。`ReferralLookupPort` 是只读、
大小写敏感的验证边界,适配器应在 referrals 模块中用有界查询检查邀请码及活动状态:
先在 Koin 注册一个 `ReferralLookupPort` 实现;公共接线会从现有 `AppConfig` 读取 App Store URL、
邀请域名和 Apple Team ID/bundle ID
```kotlin
routing {
configureInviteWebRoutes()
}
```
测试或不使用 Koin 的宿主可调用显式重载
`configureInviteWebRoutes(referralLookup, InviteWebConfig(...))`
邀请码规则与当前生成器一致:16 字节随机值编码为无填充 Base64URL,即固定 22 位、区分大小写,
字符白名单为 `A-Z``a-z``0-9``_``-`。结构非法或查无记录均返回相同 404;查询超时或
失败返回不泄露内部信息的 503。页面和两个 AASA endpoint 均发送 `no-store`
## 3. 准备现有 MySQL
先创建数据库,字符集使用 `utf8mb4`。使用 `docs/mysql-minimum-privileges.sql` 创建相互独立的
迁移账号和运行账号,并将示例来源网段替换为实际应用容器网段。不要使用公网来源或 `%`
```sql
CREATE DATABASE osg_account
CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci;
```
应用启动时使用迁移账号执行 Flyway,随后 Hikari 连接池只使用运行账号。运行账号不能更新或删除
不可变 ledger/usage 历史,也不能访问 Flyway 元数据或未来未审查的表。禁止授予全局权限、
`FILE``PROCESS``SUPER``CREATE USER``GRANT OPTION`。不要复用 root 或其他应用用户。
若 MySQL 是容器,把它和应用接入同一个已存在的外部网络:
```bash
docker network create account-backend
docker network connect account-backend <existing-mysql-container>
```
已存在网络时不要重复创建。`DATABASE_URL` 中使用该 MySQL 容器在网络内可解析的名称。若 MySQL
位于私网主机,使用私有 DNS/IP,并在数据库防火墙中只允许应用主机或容器网段。
## 4. 配置环境与秘密
复制部署所需变量到项目根目录的未跟踪 `.env`,或使用 1Panel 的环境变量/秘密管理。不要把真实
值写入 YAML、镜像层或 Git。
必须设置:
- `DATABASE_URL=jdbc:mysql://<existing-mysql>:3306/osg_account?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true`
- `DATABASE_USER``DATABASE_PASSWORD`
- `DATABASE_MIGRATION_USER``DATABASE_MIGRATION_PASSWORD`:必须与运行账号及其密码不同
- `JWT_SECRET`:至少 32 个随机字节
- `FIELD_ENCRYPTION_KEY`:恰好 32 个随机字节的 Base64
- `IDENTITY_HMAC_KEY`:至少 32 个随机字节的独立 Base64,不得复用字段加密密钥
- `APPLE_TEAM_ID``APPLE_KEY_ID``APPLE_PRIVATE_KEY_PEM`
- `VOLCENGINE_API_KEY``DEEPSEEK_API_KEY`
- `APP_STORE_URL`:正式 App Store HTTPS 地址;生产 Compose 不接受缺失值
生成新秘密的示例:
```bash
openssl rand -base64 48
openssl rand -base64 32
openssl rand -base64 32
```
三个结果分别用于 JWT、字段加密和身份 HMAC,不能相互复用。`APPLE_PRIVATE_KEY_PEM` 可使用包含
字面量 `\n` 的单行值;应用配置会在内存中还原换行。限制 `.env` 权限:
```bash
chmod 600 .env
```
生产 Compose 已固定 `APP_ENV=production`、Apple production 环境及两项完整性强制开关。
Apple 配置使用所属开发者账号的 Team ID、Key ID、bundle ID 和 `.p8` 私钥;火山引擎使用 SAUC
v3 WSS endpoint、资源 ID 和 API KeyDeepSeek 使用 HTTPS endpoint、已开通模型名和 API Key。
三方凭据分别创建、分别轮换,不得复用。
## 5. 构建与启动
先检查变量插值。`docker compose config` 会展开秘密,不要把输出上传或粘贴到工单:
```bash
./gradlew test
docker compose config --quiet
docker compose build --pull
docker compose up -d
docker compose ps
curl --fail http://127.0.0.1:18080/health/ready
```
容器以 UID/GID 10001 运行,根文件系统只读,仅 `/tmp` 是带大小限制的 tmpfs。应用端口只映射到
宿主机回环地址,不能改成 `0.0.0.0`
查看日志时不得记录或复制 token、Apple subject、音频、prompt、转录或模型响应:
```bash
docker compose logs --since=10m account-server
```
## 6. 配置 1Panel / OpenResty
在 1Panel 创建 `account.osglab.com``osglab.com` 两个 HTTPS 网站并签发证书。使用
`deploy/openresty-account.conf`
1.`map``limit_req_zone``limit_req_status``upstream` 放入 OpenResty `http` 上下文。
2. 将三个 `server` 块作为站点配置;按 1Panel 实际证书路径调整 `ssl_certificate`
3. 示例 upstream 指向宿主机 `127.0.0.1:18080`。若 OpenResty 自身在容器中,则将其加入
`account-backend`,并改为 `account-server:8080`
4. 配置明确对 `/admin``/internal``/v1/admin` 返回 404;不要新增绕过该规则的泛域名代理。
5. API 示例按 IP 限制 20 请求/秒,邀请页限制 5 请求/秒,可基于真实流量谨慎调整。
6. 代理统一支持 HTTP/1.1 Upgrade/Connection,因此当前 HTTP API 与后续 WebSocket 入口都可用。
两个 AASA 地址由 Ktor 根据 `appleAppId` 模板输出,不需要复制静态文件。配置检查成功后再通过
1Panel 重载 OpenResty
```bash
openresty -t
```
## 7. 验证
检查健康状态、反向代理、隐藏路径、限流和 AASA:
```bash
curl --fail https://account.osglab.com/health/ready
curl -i https://account.osglab.com/internal/
curl -i https://osglab.com/.well-known/apple-app-site-association
curl -i https://osglab.com/i/AbCdEf0123456789_-AbCd
```
预期结果:
- 健康检查返回 200。
- 内部路径返回 404。
- AASA 返回 200、`Content-Type: application/json`,且不发生重定向。
- 合法 22 位邀请码返回双语 HTML;非法、未知或失效邀请码统一返回 404。
- 邀请页包含 nonce CSP、`Cache-Control: no-store``X-Robots-Tag`,无第三方请求。
使用真机验证 Universal Link。安装带 Associated Domains entitlement 的 App 后,从信息或邮件点击
`https://osglab.com/i/<code>` 应直接进入 App;未安装时应显示网页。AASA 更新受系统缓存影响,
首次验证应预留传播时间。
## 8. 防火墙
- 公网入站仅允许 TCP 80/443。
- SSH 仅允许管理员固定 IP 或 VPN;不公开 18080、8080、3306。
- MySQL 3306 仅允许 Docker 私网或指定应用主机。
- 出站允许 DNS、NTP、Apple HTTPS、DeepSeek HTTPS、Volcengine HTTPS/WSS。
- 云安全组、主机防火墙和 1Panel 防火墙应采用相同边界,避免其中一层意外放行。
## 9. 更新与回滚
更新前备份 MySQL 并记录当前镜像标签。使用不可变标签构建:
```bash
IMAGE_TAG=<release-tag> docker compose build
IMAGE_TAG=<release-tag> docker compose up -d
```
Flyway 迁移只向前执行。若新版本包含数据库迁移,应用镜像回滚不等于数据库回滚;应先按迁移影响
制定恢复方案。无数据库变更时,可把 `IMAGE_TAG` 切回上一版本并重新执行 `docker compose up -d`
备份、时间点恢复和每月恢复演练按 `docs/BACKUP.md` 执行。
## 10. 分阶段验收
1. **本地阶段**`./gradlew test` 通过;合法、未知、格式错误和 lookup 异常路径符合预期;HTML
不加载第三方资源。
2. **容器阶段**:镜像以 UID/GID 10001 运行;根文件系统只读;`/health/live`
`/health/ready` 通过;容器无法取得额外 Linux capability。
3. **预发布阶段**:Flyway 使用迁移账号成功,应用使用运行账号成功;撤销运行账号 DDL 权限后服务
仍正常;OpenResty HTTP、HTTPS 和 WSS 代理验证通过。
4. **Apple 阶段**:两个 AASA 地址返回无重定向 JSON;真机从信息或邮件点击
`https://osglab.com/i/<code>` 可打开 App;未安装 App 时显示双语落地页。
5. **生产阶段**:公网仅开放 80/4433306/8080/18080 不可达;邀请 URL 不出现在访问日志;
供应商凭据可用;完成加密备份并记录一次隔离恢复结果。
+59
View File
@@ -0,0 +1,59 @@
-- Run as a MySQL administrator after replacing the host pattern and generated
-- passwords. Keep both users restricted to the private application subnet.
CREATE USER 'osg_account_runtime'@'10.20.%'
IDENTIFIED BY 'REPLACE_WITH_RUNTIME_PASSWORD';
CREATE USER 'osg_account_migrator'@'10.20.%'
IDENTIFIED BY 'REPLACE_WITH_MIGRATION_PASSWORD';
-- Flyway owns schema evolution. This account is not used by the Hikari runtime pool.
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, REFERENCES, TRIGGER
ON osg_account.* TO 'osg_account_migrator'@'10.20.%';
-- Runtime reads are explicit so the account cannot read Flyway metadata or future
-- tables until an administrator reviews and grants access.
GRANT SELECT ON osg_account.accounts TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.apple_credentials TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.sessions TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.apple_event_receipts TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.credit_accounts TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.credit_rate_versions TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.credit_reservations TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.referral_campaigns TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.referral_campaign_budgets TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.referral_codes TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.referral_bindings TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.credit_usage_records TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.credit_ledger TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.provider_requests TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.usage_records TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.gateway_grants TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE, DELETE ON osg_account.accounts TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.apple_credentials TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.sessions TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.apple_event_receipts TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.credit_accounts TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.credit_reservations TO 'osg_account_runtime'@'10.20.%';
GRANT UPDATE ON osg_account.referral_campaign_budgets TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.referral_codes TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.referral_bindings TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.credit_usage_records TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.credit_ledger TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.provider_requests TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.usage_records TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.gateway_grants TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%';
-- Deliberately absent: global privileges, GRANT OPTION, FILE, PROCESS, SUPER,
-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables.
SHOW GRANTS FOR 'osg_account_runtime'@'10.20.%';
SHOW GRANTS FOR 'osg_account_migrator'@'10.20.%';
+678
View File
@@ -0,0 +1,678 @@
openapi: 3.1.0
info:
title: OSG Account Server API
version: 0.1.0
description: |
Account, credit, referral, integrity, and managed AI APIs for OSGKeyboard.
Local features and user-owned API keys remain independent of this service.
servers:
- url: https://account.osglab.com
security:
- bearerAuth: []
paths:
/health:
get:
security: []
summary: Compatibility health check
responses:
"200": { $ref: "#/components/responses/HealthUp" }
/health/live:
get:
security: []
summary: Process liveness
responses:
"200": { $ref: "#/components/responses/HealthUp" }
/health/ready:
get:
security: []
summary: Database and migration readiness
responses:
"200": { $ref: "#/components/responses/HealthUp" }
"503":
description: Database is unavailable or migrations failed.
/v1/auth/apple:
post:
security: []
summary: Exchange Sign in with Apple credentials for an OSG session
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AppleSignInRequest" }
responses:
"200":
description: Authenticated session
content:
application/json:
schema: { $ref: "#/components/schemas/SessionTokenEnvelope" }
default: { $ref: "#/components/responses/Error" }
/v1/auth/refresh:
post:
security: []
summary: Rotate a refresh token
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [refreshToken]
properties:
refreshToken: { type: string, minLength: 32 }
responses:
"200":
description: Rotated session
content:
application/json:
schema: { $ref: "#/components/schemas/SessionTokenEnvelope" }
default: { $ref: "#/components/responses/Error" }
/v1/auth/logout:
post:
summary: Revoke the current session family
responses:
"204": { description: Session revoked }
default: { $ref: "#/components/responses/Error" }
/v1/account:
get:
summary: Return the minimal account profile
responses:
"200":
description: Account profile
content:
application/json:
schema: { $ref: "#/components/schemas/AccountEnvelope" }
default: { $ref: "#/components/responses/Error" }
delete:
summary: Reauthenticate with Apple, delete the account, and revoke authorization
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/DeleteAccountRequest" }
responses:
"204": { description: Account deleted }
default: { $ref: "#/components/responses/Error" }
/v1/apple/events:
post:
security: []
summary: Receive a signed Apple server-to-server account event
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [payload]
properties:
payload: { type: string }
responses:
"204": { description: Event accepted }
default: { $ref: "#/components/responses/Error" }
/v1/credits/balance:
get:
summary: Return available integer credits
responses:
"200":
description: Credit account
content:
application/json:
schema: { $ref: "#/components/schemas/CreditAccount" }
default: { $ref: "#/components/responses/Error" }
/v1/credits/ledger:
get:
summary: Return immutable credit history
parameters:
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Ledger entries
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/LedgerEntry" }
default: { $ref: "#/components/responses/Error" }
/v1/credits/rates:
get:
summary: Return effective managed-provider rate cards
responses:
"200":
description: Effective rate cards
content:
application/json:
schema:
type: array
items: { type: object, additionalProperties: true }
default: { $ref: "#/components/responses/Error" }
/v1/referrals:
get:
summary: List invitees without exposing their Apple identity
parameters:
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Referral bindings
content:
application/json:
schema:
type: array
items: { type: object, additionalProperties: true }
default: { $ref: "#/components/responses/Error" }
/v1/referrals/me:
get:
summary: Return the current referral code and binding
responses:
"200":
description: Referral profile
content:
application/json:
schema: { type: object, additionalProperties: true }
default: { $ref: "#/components/responses/Error" }
/v1/referrals/code:
post:
summary: Idempotently create the current campaign invite code
responses:
"200":
description: Invite code
content:
application/json:
schema: { $ref: "#/components/schemas/ReferralCode" }
default: { $ref: "#/components/responses/Error" }
/v1/referrals/redeem:
post:
summary: Bind the account to an inviter during the eligibility window
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/RedeemReferralRequest" }
responses:
"200": { description: Referral binding }
default: { $ref: "#/components/responses/Error" }
/v1/referrals/bind:
post:
deprecated: true
summary: Compatibility alias for referral redemption
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/RedeemReferralRequest" }
responses:
"200": { description: Referral binding }
default: { $ref: "#/components/responses/Error" }
/v1/referrals/campaigns:
get:
summary: Return active referral campaigns
responses:
"200": { description: Active campaigns }
default: { $ref: "#/components/responses/Error" }
/v1/integrity/challenges:
post:
security: []
summary: Issue a single-use App Attest challenge
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AppAttestChallengeRequest" }
responses:
"201":
description: Challenge issued
content:
application/json:
schema: { $ref: "#/components/schemas/AppAttestChallengeResponse" }
default: { $ref: "#/components/responses/Error" }
/v1/integrity/attest:
post:
security: []
summary: Register a validated App Attest key
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AppAttestationRequest" }
responses:
"204": { description: Key registered }
default: { $ref: "#/components/responses/Error" }
/v1/integrity/assert:
post:
security: []
summary: Validate a challenge-only App Attest assertion
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AppAssertionRequest" }
responses:
"200": { description: Assertion counter advanced }
default: { $ref: "#/components/responses/Error" }
/v1/gateway/catalog:
get:
summary: Return configured managed-provider capabilities
responses:
"200": { description: Provider catalog }
default: { $ref: "#/components/responses/Error" }
/v1/gateway/grants:
post:
summary: Create an idempotent managed-provider grant
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CreateGatewayGrantRequest" }
responses:
"201":
description: Gateway grant and rotating credentials
content:
application/json:
schema: { $ref: "#/components/schemas/GatewayGrantTokens" }
default: { $ref: "#/components/responses/GatewayError" }
/v1/gateway/grants/refresh:
post:
security: []
summary: Rotate a gateway refresh token
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/RefreshGatewayGrantRequest" }
responses:
"200":
description: Rotated gateway credentials
content:
application/json:
schema: { $ref: "#/components/schemas/GatewayGrantTokens" }
default: { $ref: "#/components/responses/GatewayError" }
/v1/gateway/grants/{grantId}:
delete:
summary: Revoke a gateway grant
parameters:
- name: grantId
in: path
required: true
schema: { type: string, format: uuid }
responses:
"204": { description: Gateway grant revoked }
default: { $ref: "#/components/responses/GatewayError" }
/v1/gateway/llm/{capability}:
post:
summary: Run a metered polish, AI, or agent request
parameters:
- $ref: "#/components/parameters/RequestId"
- name: capability
in: path
required: true
schema: { type: string, enum: [polish, ai, agent] }
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/TextGatewayRequest" }
responses:
"200":
description: JSON response, or SSE when stream is true
default: { $ref: "#/components/responses/GatewayError" }
/v1/gateway/asr:
post:
summary: Run buffered metered ASR
parameters:
- $ref: "#/components/parameters/RequestId"
- { name: X-Audio-Duration-Ms, in: header, required: true, schema: { type: integer, minimum: 1, maximum: 600000 } }
- { name: X-Audio-Format, in: header, schema: { type: string, enum: [pcm, wav, ogg, mp3], default: pcm } }
- { name: X-Audio-Codec, in: header, schema: { type: string, enum: [raw, opus], default: raw } }
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, contentEncoding: binary }
responses:
"200": { description: Newline-delimited Volcengine ASR result frames }
default: { $ref: "#/components/responses/GatewayError" }
/v1/gateway/asr/sessions:
post:
summary: Reserve credits and create a one-shot streaming ASR session
parameters:
- $ref: "#/components/parameters/RequestId"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CreateAsrSessionRequest" }
responses:
"201":
description: Session created
content:
application/json:
schema: { $ref: "#/components/schemas/CreateAsrSessionResponse" }
default: { $ref: "#/components/responses/GatewayError" }
/v1/gateway/asr/sessions/{sessionId}/stream:
get:
summary: Upgrade to WebSocket and stream binary audio frames
description: |
Send authenticated binary audio frames, then the exact text frame
`{"type":"end"}`. The server forwards binary provider-result frames and
closes the one-shot session after settlement.
parameters:
- name: sessionId
in: path
required: true
schema: { type: string, format: uuid }
responses:
"101": { description: WebSocket upgrade }
default: { $ref: "#/components/responses/GatewayError" }
/.well-known/apple-app-site-association:
get:
servers:
- url: https://osglab.com
security: []
summary: Return the Apple Universal Links association document
responses:
"200":
description: AASA JSON without a redirect
headers:
Cache-Control:
schema: { type: string, const: "no-store, max-age=0" }
content:
application/json:
schema: { $ref: "#/components/schemas/AppleAppSiteAssociation" }
/apple-app-site-association:
get:
servers:
- url: https://osglab.com
security: []
summary: Return the root compatibility AASA document
responses:
"200":
description: AASA JSON without a redirect
headers:
Cache-Control:
schema: { type: string, const: "no-store, max-age=0" }
content:
application/json:
schema: { $ref: "#/components/schemas/AppleAppSiteAssociation" }
/i/{code}:
get:
servers:
- url: https://osglab.com
security: []
summary: Privacy-preserving invitation landing page
parameters:
- name: code
in: path
required: true
schema: { type: string, pattern: "^[A-Za-z0-9_-]{22}$" }
responses:
"200": { description: Bilingual HTML landing page }
"404": { description: Invalid, unknown, or expired invitation }
"503": { description: Invitation lookup is temporarily unavailable }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
Limit:
name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
RequestId:
name: X-Request-ID
in: header
required: true
schema: { type: string, pattern: "^[A-Za-z0-9_-]{8,64}$" }
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: { type: string, minLength: 1, maxLength: 255 }
responses:
HealthUp:
description: Service is healthy
content:
application/json:
schema:
type: object
required: [status]
properties:
status: { type: string, const: UP }
Error:
description: API error
content:
application/json:
schema: { $ref: "#/components/schemas/ApiErrorEnvelope" }
GatewayError:
description: Managed-provider error
content:
application/json:
schema: { $ref: "#/components/schemas/GatewayError" }
schemas:
AppleSignInRequest:
type: object
additionalProperties: false
required: [identityToken, authorizationCode, nonce]
properties:
identityToken: { type: string }
authorizationCode: { type: string }
nonce: { type: string }
deviceCheckToken:
type: ["string", "null"]
description: Ephemeral DeviceCheck token; never persisted in plaintext.
appAttest:
oneOf:
- $ref: "#/components/schemas/AppAttestAssertion"
- type: "null"
AppAttestAssertion:
type: object
additionalProperties: false
required: [keyId, challengeId, challenge, assertion]
properties:
keyId: { type: string, maxLength: 128 }
challengeId: { type: string, format: uuid }
challenge: { type: string, description: Base64URL challenge returned by the server }
assertion: { type: string, contentEncoding: base64 }
SessionTokenResponse:
type: object
required: [accountId, tokenType, accessToken, accessTokenExpiresAtEpochSeconds, refreshToken, refreshTokenExpiresAtEpochSeconds]
properties:
accountId: { type: string, format: uuid }
tokenType: { type: string, const: Bearer }
accessToken: { type: string }
accessTokenExpiresAtEpochSeconds: { type: integer, format: int64 }
refreshToken: { type: string }
refreshTokenExpiresAtEpochSeconds: { type: integer, format: int64 }
SessionTokenEnvelope:
type: object
additionalProperties: false
required: [data]
properties:
data: { $ref: "#/components/schemas/SessionTokenResponse" }
Account:
type: object
required: [id, createdAtEpochSeconds]
properties:
id: { type: string, format: uuid }
createdAtEpochSeconds: { type: integer, format: int64 }
AccountEnvelope:
type: object
additionalProperties: false
required: [data]
properties:
data: { $ref: "#/components/schemas/Account" }
DeleteAccountRequest:
type: object
additionalProperties: false
required: [identityToken, authorizationCode, nonce]
properties:
identityToken: { type: string, maxLength: 16384 }
authorizationCode: { type: string, maxLength: 4096 }
nonce: { type: string, maxLength: 512 }
CreditAccount:
type: object
additionalProperties: true
required: [userId, balance]
properties:
userId: { type: string, format: uuid }
balance: { type: integer, format: int64, minimum: 0 }
LedgerEntry:
type: object
additionalProperties: true
required: [id, amountDelta, balanceAfter]
properties:
id: { type: string, format: uuid }
amountDelta: { type: integer, format: int64 }
balanceAfter: { type: integer, format: int64, minimum: 0 }
AppleAppSiteAssociation:
type: object
additionalProperties: false
required: [applinks]
properties:
applinks:
type: object
additionalProperties: false
required: [details]
properties:
details:
type: array
items:
type: object
required: [appIDs, components]
properties:
appIDs:
type: array
items: { type: string }
components:
type: array
items: { type: object, additionalProperties: true }
ReferralCode:
type: object
additionalProperties: true
properties:
code: { type: string, pattern: "^[A-Za-z0-9_-]{22}$" }
RedeemReferralRequest:
type: object
additionalProperties: false
required: [code]
properties:
code: { type: string, pattern: "^[A-Za-z0-9_-]{22}$" }
AppAttestChallengeRequest:
type: object
additionalProperties: false
required: [purpose, keyId]
properties:
purpose: { type: string, enum: [attestation, assertion] }
keyId: { type: string, maxLength: 128 }
AppAttestChallengeResponse:
type: object
required: [challengeId, challenge, expiresAtEpochSeconds]
properties:
challengeId: { type: string, format: uuid }
challenge: { type: string }
expiresAtEpochSeconds: { type: integer, format: int64 }
AppAttestationRequest:
type: object
additionalProperties: false
required: [challengeId, challenge, keyId, attestationObject]
properties:
challengeId: { type: string, format: uuid }
challenge: { type: string }
keyId: { type: string }
attestationObject: { type: string, contentEncoding: base64 }
AppAssertionRequest:
type: object
additionalProperties: false
required: [challengeId, challenge, keyId, assertion, clientDataHash]
properties:
challengeId: { type: string, format: uuid }
challenge: { type: string }
keyId: { type: string }
assertion: { type: string, contentEncoding: base64 }
clientDataHash: { type: string, description: Base64URL SHA-256 of the echoed challenge }
TextGatewayRequest:
type: object
additionalProperties: false
required: [input]
properties:
input: { type: string, minLength: 1, maxLength: 32000 }
context: { type: ["string", "null"], maxLength: 32000 }
maxOutputTokens: { type: integer, minimum: 1, maximum: 4096, default: 512 }
temperature: { type: number, minimum: 0, maximum: 1, default: 0.2 }
stream: { type: boolean, default: false }
CreateGatewayGrantRequest:
type: object
additionalProperties: false
required: [scopes]
properties:
scopes:
type: array
uniqueItems: true
minItems: 1
items: { type: string, enum: [polish, ai, agent, asr] }
lifetimeSeconds: { type: ["integer", "null"], format: int64, minimum: 1 }
RefreshGatewayGrantRequest:
type: object
additionalProperties: false
required: [refreshToken]
properties:
refreshToken: { type: string, minLength: 32 }
GatewayGrantTokens:
type: object
additionalProperties: false
required: [grantId, scopes, accessToken, accessExpiresAt, refreshToken, refreshExpiresAt]
properties:
grantId: { type: string, format: uuid }
scopes:
type: array
uniqueItems: true
items: { type: string, enum: [polish, ai, agent, asr] }
accessToken: { type: string }
accessExpiresAt: { type: string, format: date-time }
refreshToken: { type: string }
refreshExpiresAt: { type: string, format: date-time }
CreateAsrSessionRequest:
type: object
additionalProperties: false
required: [estimatedDurationMillis]
properties:
format: { type: string, enum: [pcm, wav, ogg, mp3], default: pcm }
codec: { type: string, enum: [raw, opus], default: raw }
sampleRate: { type: integer, const: 16000 }
bits: { type: integer, const: 16 }
channels: { type: integer, minimum: 1, maximum: 2, default: 1 }
language: { type: ["string", "null"], maxLength: 32 }
estimatedDurationMillis: { type: integer, minimum: 1, maximum: 600000 }
CreateAsrSessionResponse:
type: object
required: [sessionId, websocketPath, maxFrameBytes, idleTimeoutMillis]
properties:
sessionId: { type: string, format: uuid }
websocketPath: { type: string }
maxFrameBytes: { type: integer }
idleTimeoutMillis: { type: integer, format: int64 }
ApiError:
type: object
additionalProperties: false
required: [code, message]
properties:
code: { type: string }
message: { type: string }
ApiErrorEnvelope:
type: object
additionalProperties: false
required: [error]
properties:
error: { $ref: "#/components/schemas/ApiError" }
GatewayError:
type: object
required: [code, message, requestId]
properties:
code: { type: string }
message: { type: string }
requestId: { type: string }