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
+72
View File
@@ -0,0 +1,72 @@
OSGAccountServer is a private backend for OSGKeyboard.
- Use Kotlin 2.1+ with Ktor 3 and JDK 21.
- Organize code by business feature.
- Route handlers only validate and map HTTP.
- Services own business rules; repositories own persistence.
- Use constructor injection and explicit interfaces at external boundaries.
- Never log credentials, Apple subjects, audio, prompts, transcripts, or model output.
- Store monetary-like credit values as integers and use immutable ledger entries.
- Require idempotency for credit grants, reservations, settlement, refunds, and StoreKit events.
- Keep BYOK and local OSGKeyboard features independent from this service.
- Apply SOLID, DRY, KISS, YAGNI, and OWASP guidance.
- Add tests for success, failure, replay, concurrency, and boundary conditions.
- Do not commit secrets or production identifiers beyond public bundle/domain names.
# OSGAccountServer engineering rules
- Use Kotlin 2.1.20+, Ktor 3, JDK 21 and Gradle Kotlin DSL.
- Organize code by business feature under `features/`.
- Keep route handlers limited to request/response mapping.
- Put business rules in services and persistence in repositories.
- Use constructor injection and small, testable interfaces.
- Follow SOLID, DRY, KISS, YAGNI and OWASP guidance.
- Never log credentials, Apple identifiers, audio, prompts, transcripts or model output.
- Never store provider API keys in source control or container images.
- Use integer credits. Never use floating point for balances or billing.
- Credit mutations must be transactional, append-only and idempotent.
- Provider failures must release reserved credits; balances must never become negative.
- Sign in with Apple tokens must be verified server-side, including signature, issuer,
audience, expiry and nonce.
- Existing referral rewards require a qualified usage event; registration alone is not
sufficient for the main reward.
- Public APIs require validation, rate limiting and stable error codes.
- Database migrations are immutable after release.
- Tests must cover success, failure, replay, concurrency and boundary conditions.
- Do not expose MySQL or internal admin routes to the public network.
# OSGAccountServer
- Use Kotlin 2.1+ with JDK 21, Ktor, kotlinx.serialization, Exposed, HikariCP, Koin, and Kotest.
- Organize code by business feature under `features/`; keep routes thin, business logic in services, and persistence behind repositories.
- Apply SOLID, DRY, KISS, YAGNI, and OWASP practices.
- Store money-like credits as integer units and update balances only through immutable, idempotent ledger transactions.
- Never log tokens, Apple subjects, provider credentials, audio, prompts, transcripts, or generated responses.
- Keep all external providers behind interfaces and use deterministic fakes in tests.
- Require tests for success, validation, replay, concurrency, timeout, and failure/refund paths.
- Do not add a dependency unless the standard library or existing stack cannot solve the problem clearly.
# OSGAccountServer
- Use Kotlin 2.1+ with Ktor 3, JDK 21, and Gradle Kotlin DSL.
- Organize code by business feature: auth, account, credits, referrals, integrity, gateway.
- Keep routes limited to transport concerns; put business rules in services and persistence in repositories.
- Use constructor injection and interfaces at every external boundary.
- Follow SOLID, DRY, KISS, YAGNI, and OWASP guidance.
- Store no audio, prompts, transcripts, or generated response bodies.
- Never log secrets, Apple subjects, credentials, tokens, or user content.
- Keep all credit amounts as integers. Ledger rows are immutable and all writes are idempotent.
- Use database transactions and row locks for balance, reservation, settlement, and referral rewards.
- Require tests for success, failure, replay, concurrency, boundary, and provider-timeout paths.
- Keep public APIs versioned and document them in OpenAPI before mobile integration.
- Do not commit environment files, private keys, provider credentials, or production identifiers.
# OSGAccountServer
- Use Kotlin 2.1.20 or newer, Ktor 3, JDK 21, and Gradle Kotlin DSL.
- Organize code by business feature under `features/`.
- Route handlers only validate transport input and produce responses.
- Services own business rules; repositories own database access.
- Use constructor injection and explicit interfaces at external boundaries.
- Store money-like credits as integers and preserve an immutable ledger.
- Make every externally retried mutation idempotent.
- Never log tokens, Apple subjects, audio, prompts, transcripts, or model responses.
- Keep provider credentials and signing keys in environment-backed secret storage.
- Follow SOLID, DRY, KISS, YAGNI, and OWASP guidance.
- Add tests for success, validation, replay, concurrency, timeout, and rollback paths.
+27
View File
@@ -0,0 +1,27 @@
.git
.github
.gradle
.idea
.kotlin
.cursor
build
out
secrets
.env
.env.*
!.env.example
*.iml
*.log
*.p8
*.pem
!src/main/resources/apple/Apple_App_Attestation_Root_CA.pem
*.key
*.crt
*.sqlite
*.db
.classpath
.project
.settings
compose.override.yml
docker-compose.override.yml
.DS_Store
+17
View File
@@ -0,0 +1,17 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{kt,kts}]
indent_style = space
indent_size = 4
continuation_indent_size = 4
max_line_length = 120
[*.{yaml,yml,json}]
indent_style = space
indent_size = 2
+62
View File
@@ -0,0 +1,62 @@
# Copy to an untracked .env. Every secret below is a placeholder.
APP_ENV=development
PORT=8080
PUBLIC_BASE_URL=https://account.osglab.com
INVITE_BASE_URL=https://osglab.com/i
APP_STORE_URL=https://apps.apple.com/app/id0000000000
IMAGE_TAG=local
ACCOUNT_BIND_PORT=18080
ACCOUNT_DOCKER_NETWORK=account-backend
# Existing isolated MySQL 8.4 database.
DATABASE_URL=jdbc:mysql://mysql:3306/osg_account?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true
DATABASE_USER=osg_account
DATABASE_PASSWORD=replace-with-a-random-password
DATABASE_POOL_SIZE=10
DATABASE_MIGRATION_USER=osg_account_migrator
DATABASE_MIGRATION_PASSWORD=replace-with-a-separate-migration-password
# Generate three independent secrets. Never reuse any of them.
JWT_ISSUER=https://account.osglab.com
JWT_AUDIENCE=osgkeyboard-ios
JWT_SECRET=replace-with-at-least-32-random-bytes
ACCESS_TOKEN_MINUTES=15
REFRESH_TOKEN_DAYS=30
GATEWAY_GRANT_DAYS=30
FIELD_ENCRYPTION_KEY=replace-with-exactly-32-random-bytes-as-base64
IDENTITY_HMAC_KEY=replace-with-a-distinct-32-random-bytes-as-base64
IDENTITY_TOMBSTONE_RETENTION_DAYS=365
# Apple identifiers are not secrets, but use the values from your own developer account.
APPLE_TEAM_ID=replace-with-apple-team-id
APPLE_KEY_ID=replace-with-apple-key-id
APPLE_CLIENT_ID=replace.with.your.bundle.id
# Encode PEM newlines as literal \n when supplied through Compose/1Panel.
APPLE_PRIVATE_KEY_PEM=replace-with-p8-content-using-literal-backslash-n
APPLE_JWKS_URL=https://appleid.apple.com/auth/keys
APPLE_TOKEN_URL=https://appleid.apple.com/auth/token
APPLE_REVOKE_URL=https://appleid.apple.com/auth/revoke
APPLE_INTEGRITY_ENVIRONMENT=development
APP_ATTEST_CHALLENGE_TTL_SECONDS=300
# DeviceCheck reuses the configured Apple Team ID, Key ID and ES256 private key.
# Prefer the newer Volcengine API key. The legacy app ID/access token pair is optional.
VOLCENGINE_API_KEY=replace-with-volcengine-api-key
VOLCENGINE_APP_ID=
VOLCENGINE_ACCESS_TOKEN=
VOLCENGINE_RESOURCE_ID=volc.seedasr.sauc.duration
VOLCENGINE_ASR_ENDPOINT=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async
DEEPSEEK_API_KEY=replace-with-deepseek-api-key
DEEPSEEK_MODEL=deepseek-v4-flash
DEEPSEEK_ENDPOINT=https://api.deepseek.com/v1
SIGNUP_TRIAL_CREDITS=1000
REFERRAL_INVITER_CREDITS=3000
REFERRAL_INVITEE_CREDITS=3000
REFERRAL_BINDING_DAYS=7
# Production startup requires both flags and the production Apple environment.
ENFORCE_DEVICE_CHECK=false
ENFORCE_APP_ATTEST=false
LOG_LEVEL=INFO
+12
View File
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: gradle
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 3
+47
View File
@@ -0,0 +1,47 @@
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- uses: gradle/actions/setup-gradle@v4
- name: Verify Docker for MySQL integration tests
run: docker info
- name: Validate production Compose
env:
APP_STORE_URL: https://apps.apple.com/app/id1234567890
DATABASE_URL: jdbc:mysql://mysql:3306/osg_account
DATABASE_USER: runtime
DATABASE_PASSWORD: runtime-password
DATABASE_MIGRATION_USER: migrator
DATABASE_MIGRATION_PASSWORD: migration-password
JWT_SECRET: 01234567890123456789012345678901
FIELD_ENCRYPTION_KEY: BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=
IDENTITY_HMAC_KEY: CAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg=
APPLE_TEAM_ID: X329MZU23S
APPLE_KEY_ID: CI_KEY
APPLE_PRIVATE_KEY_PEM: ci-private-key-placeholder
VOLCENGINE_API_KEY: ci-volcengine-key
DEEPSEEK_API_KEY: ci-deepseek-key
run: docker compose -f compose.yaml config --quiet
- name: Test
run: ./gradlew --no-daemon clean test
- name: Build deployable JAR
run: ./gradlew --no-daemon buildFatJar
- name: Build container
run: docker build -t osg-account-server:ci .
+15
View File
@@ -0,0 +1,15 @@
.gradle/
.idea/
.kotlin/
build/
out/
.DS_Store
.env
*.local
*.iml
*.log
*.p8
*.jks
*.keystore
docker/mysql/
secrets/
+25
View File
@@ -0,0 +1,25 @@
FROM gradle:9.6.1-jdk21-alpine AS build
WORKDIR /workspace
COPY --chown=gradle:gradle . .
USER gradle
RUN ./gradlew --no-daemon --stacktrace installDist
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S -g 10001 app \
&& adduser -S -D -H -u 10001 -G app -h /app app
WORKDIR /app
COPY --from=build --chown=app:app /workspace/build/install/OSGAccountServer/ /app/
ENV HOME=/tmp \
JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=/tmp -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
USER 10001:10001
EXPOSE 8080
STOPSIGNAL SIGTERM
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/health/live || exit 1
ENTRYPOINT ["/app/bin/OSGAccountServer"]
+302
View File
@@ -0,0 +1,302 @@
# OSG Account Server
Kotlin 2.4.10 / Ktor 3.5.2 managed AI gateway and invitation page.
## Included
- Controlled `polish`, `ai`, and `agent` requests through DeepSeek's OpenAI-compatible API.
- Volcengine SAUC v3 ASR over WebSocket with a strict binary frame codec.
- Credit reservation, settlement, release, identity, grant, and metadata persistence ports.
- Apple DeviceCheck trial enforcement and App Attest attestation/assertion validation.
- Bilingual invitation page at `GET /i/{code}` with no analytics, tracking, or fingerprinting.
- Flyway migrations for provider metadata, metered usage, gateway grants, and integrity state.
Prompts, audio, transcripts, and provider response bodies are never sent to the usage persistence port.
Application logging must also keep request/response body logging disabled.
## HTTP API
- `GET /v1/gateway/catalog`
- `POST /v1/gateway/grants`, `POST /v1/gateway/grants/refresh`, `DELETE /v1/gateway/grants/{grantId}`
- `POST /v1/gateway/llm/{polish|ai|agent}`
- `POST /v1/gateway/asr`
- `POST /v1/gateway/asr/sessions`, `GET (WebSocket) /v1/gateway/asr/sessions/{sessionId}/stream`
- `POST /v1/integrity/challenges`
- `POST /v1/integrity/attest`
- `POST /v1/integrity/assert`
- `POST /v1/auth/apple`
- `POST /v1/auth/refresh`, `POST /v1/auth/logout`
- `GET/DELETE /v1/account`
- `POST /v1/apple/events`
- `GET /v1/credits/{balance|ledger|rates}`
- `GET/POST /v1/referrals...`
- `GET /health`, `GET /health/live`, `GET /health/ready`
- `GET /i/{code}`
- `GET /.well-known/apple-app-site-association`
- `GET /apple-app-site-association`
See `docs/openapi.yaml` for request limits and response formats.
## Local development
JDK 21 is required. Start a local MySQL 8.4 database, copy `.env.example` to an untracked `.env`,
replace every required placeholder, then export it into the shell before starting Ktor:
```bash
set -a
source .env
set +a
./gradlew test
./gradlew run
curl --fail http://127.0.0.1:8080/health/live
```
Development may set `APP_ENV=development`, `APPLE_INTEGRITY_ENVIRONMENT=development`, and both
integrity enforcement flags to `false`. Production refuses to start with those values. Never commit
`.env`, `.p8` files, API keys, tokens, database dumps, or generated response data.
## Application wiring
`Application.kt` is the composition root. It installs Ktor security, serialization, authentication,
rate limiting, WebSockets, error handling and Koin; initializes Flyway/Exposed; and mounts auth,
account, Apple event, credit, referral, gateway, invitation and health routes.
The first-party invitation page uses the registered read-only `ReferralLookupPort` adapter. It
preserves case and checks code existence plus campaign validity; the composition root mounts exactly
one `/i/{code}` route.
Gateway identity uses the signed session JWT and verifies the account plus session family in MySQL on
every authenticated request. Logout, refresh-token replay response, Apple events, and account deletion
therefore invalidate access tokens immediately. Gateway grants, provider metadata and metered usage are
persisted in MySQL. Managed calls reserve, settle or release credits through the same immutable credit
ledger exposed read-only by the public credit API. Clients create scope-limited, time-limited gateway
grants explicitly. Signup trial credits are granted only after DeviceCheck bit0 is conservatively marked.
## Apple integrity
DeviceCheck uses Apple's official `query_two_bits` and `update_two_bits` endpoints. ES256 bearer JWTs
are created from `APPLE_TEAM_ID`, `APPLE_KEY_ID`, and `APPLE_PRIVATE_KEY_PEM`; select the matching
development or production endpoint with `APPLE_INTEGRITY_ENVIRONMENT`. Bit0 means the device has
consumed its signup trial, while bit1 is preserved. The plaintext device token is never persisted or
logged. Because DeviceCheck tokens are ephemeral, a MySQL named lock serializes the complete
query-update-confirm window across all server instances. Apple is marked and confirmed before the
idempotent credit entry; an interruption may conservatively forfeit a trial but cannot duplicate one.
App Attest is bound to the configured Apple team, bundle, and environment. Cryptographic attestation,
receipt, assertion, and certificate-chain validation use the
official Apple App Attestation Root CA bundled from Apple Certificate Authority. The server stores the
validated public key, receipt, and strictly increasing assertion counter.
Request an `attestation` challenge after `generateKey`, then call `/attest` with the resulting CBOR
object. For login assertions, request an `assertion` challenge and generate the assertion over SHA-256
of the canonical UTF-8 payload documented in `docs/openapi.yaml`. Challenges are single-use and expire
after `APP_ATTEST_CHALLENGE_TTL_SECONDS`.
`ENFORCE_DEVICE_CHECK` and `ENFORCE_APP_ATTEST` fail closed for missing or unavailable evidence.
`MONITOR` permits login during rollout or Apple outages, but unavailable DeviceCheck evidence never
qualifies for trial credits. Cryptographically rejected evidence is never allowed by either mode.
Production refuses to start unless both policies are `ENFORCE` and the production Apple environment is
selected.
## Account deletion and anti-abuse retention
Account deletion requires a fresh matching Sign in with Apple identity token, authorization code, and
nonce. After reauthentication succeeds, local deletion is committed before the Apple revocation call.
Sessions, referral state, credit balances and reservations, gateway grants, and provider metadata are
removed through database cascades. The newly issued Apple refresh token moves encrypted into a durable
outbox; a successful revoke clears it immediately, while failures retry with bounded exponential backoff.
The immutable credit ledger is retained with only the now-unmapped random account UUID for financial
auditability. To prevent deletion/recreation trial and self-referral abuse, the server retains only an
HMAC-SHA-256 identity tombstone—never the Apple subject—for
`IDENTITY_TOMBSTONE_RETENTION_DAYS` (365 days by default). `IDENTITY_HMAC_KEY` is a separate secret and
must not reuse JWT or field-encryption keys. Tombstoned identities may sign in but cannot receive another
signup trial or participate in referrals during the retention window.
Apple server events accept both the documented `account-delete` name and the previously observed
`account-deleted` variant.
Gateway execution and settlement rules:
- `X-Request-ID` is atomically claimed within the authenticated account and can never invoke a
provider twice. Replays return `409`, including claimed, started, pending, settled, and released calls.
- LLM requests reserve a UTF-8 byte upper bound plus maximum output tokens, then require internally
consistent provider input, output, and total token usage.
- Raw PCM/WAV reservations are derived from audio bytes. Compressed audio reserves the full accepted
ten-minute boundary because its duration cannot be proven from byte length alone.
- Only failures before provider completion release a reservation.
- Provider success is durably moved to `SETTLEMENT_PENDING` before settlement. Settlement failures keep
credits frozen and the background reconciliation job retries idempotently.
- A successful settlement is returned to the client even if usage metadata needs a later retry.
- Settlement retries must repeat the same actual usage; parameter drift returns a conflict.
- Public idempotency keys are SHA-256 namespaced before storage and cannot collide with internal
referral, signup-trial, or gateway ledger keys.
## Provider configuration
Existing `application.yaml` values can be mapped into:
- `DeepSeekConfig(endpoint, apiKey, model)`
- `VolcengineAsrConfig(endpoint, resourceId, appId, accessToken)`
- `InviteWebConfig(appStoreUrl, appleAppId, universalLinkBaseUrl)`
`VolcengineAsrConfig` also supports the newer single `apiKey` credential when the application
configuration exposes it. Missing credentials, non-TLS provider URLs, missing resource IDs, malformed
frames, provider error frames, missing final frames, and missing metered duration all fail closed.
Production additionally requires the exact provider hosts `api.deepseek.com` and
`openspeech.bytedance.com` on the default TLS port.
Configuration ownership:
- Apple: `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID`, `APPLE_PRIVATE_KEY_PEM`,
`APPLE_INTEGRITY_ENVIRONMENT`, plus the two integrity enforcement flags.
- Volcengine: prefer `VOLCENGINE_API_KEY`; set the SAUC v3 `VOLCENGINE_RESOURCE_ID` and WSS
`VOLCENGINE_ASR_ENDPOINT`. The legacy app ID/access token pair remains optional.
- DeepSeek: set `DEEPSEEK_API_KEY`, the provisioned `DEEPSEEK_MODEL`, and HTTPS
`DEEPSEEK_ENDPOINT`.
Store production values in 1Panel's secret/environment facility. The Compose environment receives
them at runtime because this application does not read Docker `/run/secrets/*` files directly.
## SAUC v3 protocol boundary
The endpoint uses the SAUC v3 API, while the documented binary header version nibble remains `1`.
The codec implements:
- 4-byte base header and big-endian integer fields.
- Full-client JSON request (`0x1`) with gzip.
- Audio-only request (`0x2`) with gzip and final-packet flag `0x2`.
- Full-server response (`0x9`) with a required strictly increasing sequence and negative final sequence.
- Error response (`0xF`) with error-code field.
- Strict declared-size, payload-limit, gzip, message-type, and final-frame validation.
The client supports `pcm`, `wav`, `ogg`, and `mp3`; 16 kHz, 16-bit, one or two channels; and a
maximum duration of ten minutes. Only the final frame can supply billable `audio_info.duration`.
Clients may use the buffered HTTP endpoint or create a one-shot downstream WebSocket session. Both
paths durably claim the request ID before contacting Volcengine and share the same reservation and
settlement state machine.
## Database
The application runs Flyway against the existing MySQL 8.4 instance before opening the runtime pool.
V1-V7 remain append-only migration history; the invitation web feature requires no new table and does
not modify V4. `V5__account_security_hardening.sql` adds account
foreign keys, identity tombstones, and the encrypted Apple revocation outbox.
`V6__gateway_execution_state.sql` scopes request IDs by account, binds provider requests to credit
reservations, and stores only metering metadata needed for settlement reconciliation. No table has a
request-body, prompt, audio, transcript, or response-body column.
## Container deployment
The production Compose file starts only `account-server`; it does not create a second MySQL.
The application port is bound to `127.0.0.1:18080`, and OpenResty is the only public entry point.
Local build:
```bash
./gradlew clean build
docker build -t osg-account-server:local .
```
Deployment:
```bash
# Create this only when the existing MySQL is containerized and the network is absent.
docker network create --internal account-backend
# Put deployment values in an uncommitted .env or 1Panel secret/environment store.
docker compose config
docker compose up -d --build
docker compose ps
curl --fail http://127.0.0.1:18080/health/ready
```
If MySQL runs directly on the host or another private server, keep the external network declaration
but set `DATABASE_URL` to a private hostname reachable from that network. The application also joins
the separate egress network for Apple and provider HTTPS calls. Never publish MySQL port 3306 to the
Internet.
### Required environment
- Public URLs: `PUBLIC_BASE_URL`, `INVITE_BASE_URL`, `APP_STORE_URL`.
- Existing MySQL: `DATABASE_URL`, runtime `DATABASE_USER` / `DATABASE_PASSWORD`,
migration-only `DATABASE_MIGRATION_USER` / `DATABASE_MIGRATION_PASSWORD`, and
`DATABASE_POOL_SIZE`.
Keep `connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true` in the JDBC URL.
- Session and encryption secrets: `JWT_SECRET`, `FIELD_ENCRYPTION_KEY`, `IDENTITY_HMAC_KEY`.
The two Base64 keys must be distinct; the field key decodes to exactly 32 bytes.
- Apple: `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID`, `APPLE_PRIVATE_KEY_PEM`,
`APPLE_INTEGRITY_ENVIRONMENT`.
- Providers: `VOLCENGINE_API_KEY`, `VOLCENGINE_RESOURCE_ID`, `DEEPSEEK_API_KEY`,
`DEEPSEEK_MODEL`.
- Production controls: `APP_ENV=production`, `ENFORCE_DEVICE_CHECK=true`,
`ENFORCE_APP_ATTEST=true`.
- Optional tuning: token lifetimes, gateway grant days, credit values, binding window and pool size;
defaults are visible in `compose.yaml`.
All values in the Compose file are placeholders or environment substitutions. Keep real credentials
out of Git, Compose YAML, 1Panel screenshots and shell history.
The Apple ES256 `.p8` material is supplied as `APPLE_PRIVATE_KEY_PEM` with literal `\n` line breaks;
the DeviceCheck JWS adapter reuses that configured Team ID, Key ID and private key. Keep Volcengine and
DeepSeek API keys in separate 1Panel secret fields and rotate each provider independently.
### Existing MySQL minimum privileges
The server uses a Flyway-only account for migrations and a separately pooled runtime account. Apply
`docs/mysql-minimum-privileges.sql` as an administrator after replacing its private host pattern and
generated passwords.
The runtime account can append to ledger and usage history but cannot update/delete that history or
alter rate versions or schemas. The migration account is scoped to `osg_account.*` and has no global
privileges.
Replace the example `10.20.%` host pattern with the actual container subnet. Do not grant either account `FILE`, `PROCESS`,
`SUPER`, `CREATE USER`, `GRANT OPTION`, or access from `%`.
### 1Panel and firewall
1. Create HTTPS sites for `account.osglab.com` and `osglab.com` in 1Panel.
2. Apply `deploy/openresty-account.conf`, adjusting certificate/root paths when needed.
3. Validate with `openresty -t`; AASA is proxied to Ktor at both extensionless paths.
4. Point both DNS records to the server and reload OpenResty after certificate issuance.
5. Allow inbound TCP 80/443 only; allow SSH only from an administrator IP. Do not expose
18080, 8080 or 3306. Permit required outbound HTTPS for Apple, DeepSeek and Volcengine.
See `docs/DEPLOYMENT.md` for rollback, verification and Universal Link checks.
### Invitation link architecture
Invitation links use the first-party Universal Link `https://osglab.com/i/{code}`. The page contains
no third-party JavaScript, analytics, tracking pixels or external fonts. Firebase Dynamic Links and
Branch are deliberately not used.
## Tests
Run:
```bash
./gradlew test
```
Gateway tests cover account-scoped replay exclusion, cross-account isolation, provider failure release,
settlement pending behavior, reconciliation, whole-call timeout, forged audio duration, malformed SAUC
sequences, and final-frame-only duration.
The MySQL Testcontainers suite validates all migrations, account-deletion cascades, stale access-token
rejection, real InnoDB concurrent balance locking, and exactly-once referral rewards. It is automatically
skipped when Docker is unavailable; CI requires Docker and runs it on every build.
## Staged acceptance
1. Local: tests pass, malformed/unknown/referral-lookup-failure paths fail closed, and the page makes no
third-party requests.
2. Container: UID/GID 10001, read-only root filesystem, dropped capabilities, and both health endpoints
pass.
3. Pre-production: migrations use only the migrator; runtime uses only its table grants; HTTP and WSS
reverse proxy tests pass.
4. Apple: AASA returns JSON without redirects; a physical device opens the Universal Link; an
uninstalled device receives the responsive bilingual page.
5. Production: only 80/443 are public, invitation paths are excluded from access logs, provider
credentials work, and an encrypted backup has been restored successfully in isolation.
See `docs/DEPLOYMENT.md` for exact rollout commands and `docs/BACKUP.md` for backup and recovery.
+107
View File
@@ -0,0 +1,107 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
kotlin("jvm") version "2.4.10"
kotlin("plugin.serialization") version "2.4.10"
id("io.ktor.plugin") version "3.5.2"
application
jacoco
}
group = "com.osglab.account"
version = "0.1.0"
repositories {
mavenCentral()
}
application {
mainClass.set("io.ktor.server.netty.EngineMain")
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.add("-Xjsr305=strict")
}
}
dependencies {
val ktorVersion = "3.5.2"
val exposedVersion = "1.4.0"
val flywayVersion = "13.3.0"
implementation("io.ktor:ktor-server-core-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-netty-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-auth-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-auth-jwt-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-status-pages-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-call-logging-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-rate-limit-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-websockets-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-forwarded-header-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-config-yaml-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-call-id-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-default-headers-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-compression-jvm:$ktorVersion")
implementation("io.ktor:ktor-client-core-jvm:$ktorVersion")
implementation("io.ktor:ktor-client-cio-jvm:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation-jvm:$ktorVersion")
implementation("io.ktor:ktor-client-websockets-jvm:$ktorVersion")
implementation("io.insert-koin:koin-ktor:4.2.2")
implementation("io.insert-koin:koin-logger-slf4j:4.2.2")
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion")
implementation("com.zaxxer:HikariCP:7.1.0")
implementation("com.mysql:mysql-connector-j:26.7.0")
implementation("org.flywaydb:flyway-core:$flywayVersion")
implementation("org.flywaydb:flyway-mysql:$flywayVersion")
implementation("com.auth0:java-jwt:4.6.0")
implementation("com.nimbusds:nimbus-jose-jwt:10.9.1")
implementation("ch.veehait.devicecheck:devicecheck-appattest:0.9.6")
implementation("org.bouncycastle:bcprov-jdk18on:1.85.2")
implementation("com.upokecenter:cbor:4.5.6")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0")
implementation("ch.qos.logback:logback-classic:1.6.3")
testImplementation("io.ktor:ktor-server-test-host-jvm:$ktorVersion")
testImplementation("io.ktor:ktor-client-mock-jvm:$ktorVersion")
testImplementation(kotlin("test"))
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.2.4")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.2.4")
testImplementation("io.mockk:mockk-jvm:1.14.11")
testImplementation("org.testcontainers:mysql:1.21.4")
testImplementation("org.testcontainers:junit-jupiter:1.21.4")
}
tasks.test {
useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
}
jacoco {
toolVersion = "0.8.15"
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required.set(true)
html.required.set(true)
}
}
ktor {
fatJar {
archiveFileName.set("osg-account-server.jar")
}
}
+91
View File
@@ -0,0 +1,91 @@
services:
account-server:
build:
context: .
dockerfile: Dockerfile
image: osg-account-server:${IMAGE_TAG:-local}
restart: unless-stopped
init: true
user: "10001:10001"
stop_grace_period: 30s
environment:
APP_ENV: production
PORT: "8080"
PUBLIC_BASE_URL: https://account.osglab.com
INVITE_BASE_URL: https://osglab.com/i
APP_STORE_URL: ${APP_STORE_URL:?set the production App Store HTTPS URL}
# Reuse the existing MySQL; this stack intentionally creates no database.
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL for the existing MySQL}
DATABASE_USER: ${DATABASE_USER:?set DATABASE_USER}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:?set DATABASE_PASSWORD}
DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-10}
DATABASE_MIGRATION_USER: ${DATABASE_MIGRATION_USER:?set DATABASE_MIGRATION_USER}
DATABASE_MIGRATION_PASSWORD: ${DATABASE_MIGRATION_PASSWORD:?set DATABASE_MIGRATION_PASSWORD}
JWT_ISSUER: https://account.osglab.com
JWT_AUDIENCE: ${JWT_AUDIENCE:-osgkeyboard-ios}
JWT_SECRET: ${JWT_SECRET:?set a random JWT secret}
ACCESS_TOKEN_MINUTES: ${ACCESS_TOKEN_MINUTES:-15}
REFRESH_TOKEN_DAYS: ${REFRESH_TOKEN_DAYS:-30}
GATEWAY_GRANT_DAYS: ${GATEWAY_GRANT_DAYS:-30}
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY:?set a 32-byte Base64 key}
IDENTITY_HMAC_KEY: ${IDENTITY_HMAC_KEY:?set a distinct Base64 key}
IDENTITY_TOMBSTONE_RETENTION_DAYS: ${IDENTITY_TOMBSTONE_RETENTION_DAYS:-365}
APPLE_TEAM_ID: ${APPLE_TEAM_ID:?set Apple team ID}
APPLE_KEY_ID: ${APPLE_KEY_ID:?set Apple key ID}
APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-com.osgkeyboard.ios}
APPLE_PRIVATE_KEY_PEM: ${APPLE_PRIVATE_KEY_PEM:?set Apple private key PEM}
APPLE_INTEGRITY_ENVIRONMENT: production
APP_ATTEST_CHALLENGE_TTL_SECONDS: ${APP_ATTEST_CHALLENGE_TTL_SECONDS:-300}
ENFORCE_DEVICE_CHECK: "true"
ENFORCE_APP_ATTEST: "true"
VOLCENGINE_API_KEY: ${VOLCENGINE_API_KEY:?set Volcengine API key}
VOLCENGINE_RESOURCE_ID: ${VOLCENGINE_RESOURCE_ID:-volc.seedasr.sauc.duration}
VOLCENGINE_ASR_ENDPOINT: ${VOLCENGINE_ASR_ENDPOINT:-wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async}
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:?set DeepSeek API key}
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-flash}
DEEPSEEK_ENDPOINT: ${DEEPSEEK_ENDPOINT:-https://api.deepseek.com/v1}
SIGNUP_TRIAL_CREDITS: ${SIGNUP_TRIAL_CREDITS:-1000}
REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-3000}
REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-3000}
REFERRAL_BINDING_DAYS: ${REFERRAL_BINDING_DAYS:-7}
ports:
- "127.0.0.1:${ACCOUNT_BIND_PORT:-18080}:8080"
read_only: true
pids_limit: 256
tmpfs:
- /tmp:size=64m,mode=1777,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health/ready"]
interval: 30s
timeout: 3s
start_period: 30s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- account-backend
# Separate egress keeps Apple/provider traffic available when the shared
# database network was created with Docker's --internal flag.
- account-egress
networks:
# Pre-create this shared network with:
# docker network create --internal account-backend
# Attach the existing MySQL container without publishing port 3306.
account-backend:
external: true
name: ${ACCOUNT_DOCKER_NETWORK:-account-backend}
account-egress:
driver: bridge
+17
View File
@@ -0,0 +1,17 @@
# 1Panel / OpenResty 示例
1. 在 1Panel 创建 `account.osglab.com``osglab.com` 两个 HTTPS 网站并申请证书。
2.`deploy/openresty-account.conf``map``limit_req_zone``upstream` 放入
OpenResty `http` 上下文,其余 `server` 块作为站点配置。这是仓库内唯一的反向代理配置源。
3. 确认证书目录与示例一致;若 1Panel 实际路径不同,以面板生成的路径为准。
4. AASA 的两个无扩展名路径都直接代理到 Ktor,由生产配置生成 JSON;不要复制或维护静态
AASA 文件,也不要给 AASA 添加跳转、认证或缓存覆盖。
5. 先执行 `openresty -t`,配置检查通过后再在 1Panel 重载 OpenResty。
示例假定 `compose.yaml` 把应用映射到宿主机 `127.0.0.1:18080`。若 OpenResty
本身运行在容器中,应让它加入 `account-backend` 外部网络,并把 upstream 改为
`account-server:8080`;不要把应用端口绑定到公网地址。
使用 `docker network create --internal account-backend` 创建数据库网络,并将现有 MySQL
容器加入该网络;MySQL 不配置 `ports`。应用同时加入独立 egress 网络访问 Apple、火山
和 DeepSeek。
+123
View File
@@ -0,0 +1,123 @@
# Include this file from OpenResty's http block. The Compose service listens only
# on 127.0.0.1:18080, so clients cannot bypass these controls.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
limit_req_zone $binary_remote_addr zone=account_api:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=invite_page:10m rate=5r/s;
limit_req_status 429;
upstream osg_account_server {
server 127.0.0.1:18080;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name account.osglab.com;
return 308 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name account.osglab.com;
ssl_certificate /www/server/panel/vhost/cert/account.osglab.com/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/account.osglab.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_timeout 1d;
ssl_session_cache shared:account_tls:10m;
client_max_body_size 21m;
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
# Never publish operational or administrative paths through this vhost.
location ~ ^/(?:admin|internal|v1/admin)(?:/|$) {
return 404;
}
location / {
limit_req zone=account_api burst=40 nodelay;
proxy_pass http://osg_account_server;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Request-ID $request_id;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_hide_header Server;
proxy_request_buffering off;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 360s;
proxy_send_timeout 360s;
}
}
server {
listen 80;
listen [::]:80;
server_name osglab.com www.osglab.com;
return 308 https://osglab.com$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name osglab.com www.osglab.com;
ssl_certificate /www/server/panel/vhost/cert/osglab.com/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/osglab.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
location = /.well-known/apple-app-site-association {
access_log off;
proxy_pass http://osg_account_server;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
location = /apple-app-site-association {
access_log off;
proxy_pass http://osg_account_server;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
# Invitation codes are bearer-like values and must not appear in access logs.
location ^~ /i/ {
access_log off;
limit_req zone=invite_page burst=10 nodelay;
proxy_pass http://osg_account_server;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Request-ID $request_id;
proxy_hide_header Server;
proxy_buffering off;
proxy_cache off;
}
# Keep the existing 1Panel site root for all non-invitation pages.
root /www/wwwroot/osglab.com;
location / {
try_files $uri $uri/ =404;
}
}
+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 }
+6
View File
@@ -0,0 +1,6 @@
org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.parallel=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
kotlin.daemon.jvmargs=-Xmx1g
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.6.1-bin.zip
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
networkTimeout=30000
retries=2
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+10
View File
@@ -0,0 +1,10 @@
rootProject.name = "OSGAccountServer"
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
@@ -0,0 +1,511 @@
package com.osglab.account
import com.osglab.account.common.api.installApiStatusPages
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.common.security.SessionJwt
import com.osglab.account.common.security.installSessionAuthentication
import com.osglab.account.config.AppConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.account.AccountRepository
import com.osglab.account.features.account.AccountReauthenticator
import com.osglab.account.features.account.AccountService
import com.osglab.account.features.account.AppleAccountReauthenticator
import com.osglab.account.features.account.AppleRevocationOutboxProcessor
import com.osglab.account.features.account.ExposedAccountRepository
import com.osglab.account.features.account.accountRoutes
import com.osglab.account.features.appleevents.AppleEventService
import com.osglab.account.features.appleevents.AppleEventRepository
import com.osglab.account.features.appleevents.AppleEventVerifier
import com.osglab.account.features.appleevents.ExposedAppleEventRepository
import com.osglab.account.features.appleevents.appleEventRoutes
import com.osglab.account.features.auth.AccountProvisioner
import com.osglab.account.features.auth.AppleIdentityTokenVerifier
import com.osglab.account.features.auth.AppleJwksProvider
import com.osglab.account.features.auth.AppleTokenClient
import com.osglab.account.features.auth.AuthRepository
import com.osglab.account.features.auth.ExposedAuthRepository
import com.osglab.account.features.auth.RemoteAppleJwksProvider
import com.osglab.account.features.auth.SessionService
import com.osglab.account.features.auth.SessionAccessAuthenticator
import com.osglab.account.features.auth.authRoutes
import com.osglab.account.features.auth.createAppleTokenClient
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.credits.repositories.ExposedBillingTransactionRunner
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.routes.creditRoutes
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.gateway.adapters.CreditReservationAdapter
import com.osglab.account.features.gateway.adapters.SessionIdentityAdapter
import com.osglab.account.features.gateway.GatewaySettings
import com.osglab.account.features.gateway.asr.AsrStreamingService
import com.osglab.account.features.gateway.ports.CreditReservationPort
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
import com.osglab.account.features.gateway.ports.GatewayGrantPort
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
import com.osglab.account.features.gateway.ports.GatewayIdentityPort
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import com.osglab.account.features.gateway.providers.deepseek.DeepSeekConfig
import com.osglab.account.features.gateway.providers.deepseek.DeepSeekProvider
import com.osglab.account.features.gateway.providers.volcengine.KtorVolcengineAsrTransport
import com.osglab.account.features.gateway.providers.volcengine.VolcengineAsrConfig
import com.osglab.account.features.gateway.providers.volcengine.VolcengineAsrProvider
import com.osglab.account.features.gateway.repositories.ExposedGatewayRepository
import com.osglab.account.features.gateway.routes.configureGatewayRoutes
import com.osglab.account.features.gateway.services.GatewayBearerIdentity
import com.osglab.account.features.gateway.services.GatewayGrantService
import com.osglab.account.features.gateway.services.GatewayReconciliationService
import com.osglab.account.features.gateway.services.GatewayService
import com.osglab.account.features.integrity.AppAttestCrypto
import com.osglab.account.features.integrity.AppAttestRepository
import com.osglab.account.features.integrity.AppAttestService
import com.osglab.account.features.integrity.AppAttestVerifier
import com.osglab.account.features.integrity.AppleDeviceCheckClient
import com.osglab.account.features.integrity.BundledAppleAppAttestTrust
import com.osglab.account.features.integrity.DeviceCheckTrialClaimRepository
import com.osglab.account.features.integrity.DeviceCheckTrialService
import com.osglab.account.features.integrity.DeviceCheckVerifier
import com.osglab.account.features.integrity.ExposedAppAttestRepository
import com.osglab.account.features.integrity.ExposedDeviceCheckTrialClaimRepository
import com.osglab.account.features.integrity.IntegrityService
import com.osglab.account.features.integrity.LibraryAppAttestCrypto
import com.osglab.account.features.integrity.MysqlDeviceCheckTrialMutex
import com.osglab.account.features.integrity.RemoteDeviceCheckVerifier
import com.osglab.account.features.integrity.TrialCreditGranter
import com.osglab.account.features.integrity.UnavailableAppleDeviceCheckClient
import com.osglab.account.features.integrity.createDeviceCheckClient
import com.osglab.account.features.integrity.integrityRoutes
import com.osglab.account.features.inviteweb.InviteWebConfig
import com.osglab.account.features.inviteweb.ReferralLookupPort
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
import com.osglab.account.features.referrals.routes.referralRoutes
import com.osglab.account.features.referrals.services.ReferralService
import com.osglab.account.features.referrals.services.ReferralRiskIdentity
import com.osglab.account.features.referrals.services.ReferralRiskProvider
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.websocket.WebSockets as ClientWebSockets
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationStopped
import io.ktor.server.application.install
import io.ktor.server.plugins.callid.CallId
import io.ktor.server.plugins.callid.callIdMdc
import io.ktor.server.plugins.calllogging.CallLogging
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.defaultheaders.DefaultHeaders
import io.ktor.server.plugins.forwardedheaders.XForwardedHeaders
import io.ktor.server.plugins.ratelimit.RateLimit
import io.ktor.server.plugins.ratelimit.RateLimitName
import io.ktor.server.plugins.ratelimit.rateLimit
import io.ktor.server.request.httpMethod
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.Route
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import kotlinx.serialization.json.Json
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.koin.core.module.Module
import org.koin.dsl.module
import org.koin.ktor.ext.getKoin
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
import java.time.Duration
import java.time.Instant
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.time.Duration.Companion.minutes
private val JSON = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
fun Application.module() {
val appConfig = AppConfig.from(environment.config)
install(ContentNegotiation) {
json(JSON)
}
install(DefaultHeaders) {
header("X-Content-Type-Options", "nosniff")
header("X-Frame-Options", "DENY")
header("Referrer-Policy", "no-referrer")
if (appConfig.isProduction) {
header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
}
install(CallId) {
retrieveFromHeader("X-Request-ID")
verify { it.matches(REQUEST_ID) }
generate { UUID.randomUUID().toString() }
}
install(CallLogging) {
callIdMdc("requestId")
// Never include paths, headers or bodies: they may contain invitation or session tokens.
format { call -> "${call.request.httpMethod.value} status=${call.response.status()}" }
}
install(WebSockets) {
maxFrameSize = 4L * 1024 * 1024
masking = false
}
install(XForwardedHeaders)
install(RateLimit) {
register(AUTH_RATE_LIMIT) {
rateLimiter(limit = 10, refillPeriod = 1.minutes)
}
register(ACCOUNT_RATE_LIMIT) {
rateLimiter(limit = 60, refillPeriod = 1.minutes)
}
register(GATEWAY_RATE_LIMIT) {
rateLimiter(limit = 120, refillPeriod = 1.minutes)
}
register(PUBLIC_RATE_LIMIT) {
rateLimiter(limit = 120, refillPeriod = 1.minutes)
}
}
installApiStatusPages()
install(Koin) {
slf4jLogger()
modules(accountServerModule(appConfig))
}
val koin = getKoin()
// Fail startup before accepting traffic if migrations or database connectivity fail.
koin.get<DatabaseFactory>().database
val sessionAuthenticator = koin.get<SessionAccessAuthenticator>()
installSessionAuthentication(sessionAuthenticator::authenticate)
val asrStreaming = if (appConfig.providers.volcengine.credentialsAvailable) {
val providerConfig = appConfig.providers.volcengine.toProviderConfig()
AsrStreamingService(
gateway = koin.get(),
upstream = KtorVolcengineAsrTransport(koin.get(), providerConfig),
scope = this,
)
} else {
null
}
launch {
while (isActive) {
try {
koin.get<AppleRevocationOutboxProcessor>().processPending()
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
// Durable outbox state is retried; never log sensitive token material.
}
try {
koin.get<GatewayReconciliationService>().reconcile()
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
// Durable settlement state is retried without logging provider data.
}
delay(60_000)
}
}
monitor.subscribe(ApplicationStopped) {
koin.get<HttpClient>().close()
koin.get<DatabaseFactory>().close()
}
routing {
healthRoutes(koin.get())
rateLimit(AUTH_RATE_LIMIT) {
authRoutes(koin.get())
}
rateLimit(ACCOUNT_RATE_LIMIT) {
accountRoutes(koin.get())
creditRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), koin.get())
}
rateLimit(GATEWAY_RATE_LIMIT) {
configureGatewayRoutes(
service = koin.get(),
appIdentity = koin.get<GatewayIdentityPort>(),
gatewayIdentity = koin.get<GatewayAccessTokenPort>(),
grantService = koin.get(),
asrStreaming = asrStreaming,
)
}
rateLimit(PUBLIC_RATE_LIMIT) {
appleEventRoutes(koin.get())
configureInviteWebRoutes(koin.get(), koin.get())
integrityRoutes(koin.get())
}
}
}
fun Route.healthRoutes(databaseFactory: DatabaseFactory? = null) {
get("/health") { call.respondText("""{"status":"UP"}""", ContentType.Application.Json) }
get("/health/live") { call.respondText("""{"status":"UP"}""", ContentType.Application.Json) }
get("/health/ready") {
if (databaseFactory?.isReady() == true) {
call.respondText("""{"status":"UP"}""", ContentType.Application.Json)
} else {
call.respondText(
"""{"status":"DOWN"}""",
ContentType.Application.Json,
HttpStatusCode.ServiceUnavailable,
)
}
}
}
fun accountServerModule(config: AppConfig): Module = module {
single { config }
single { DatabaseFactory(config.database) }
single { get<DatabaseFactory>().database }
single {
HttpClient(CIO) {
followRedirects = false
install(HttpTimeout) {
connectTimeoutMillis = 10_000
socketTimeoutMillis = 60_000
requestTimeoutMillis = 360_000
}
install(ClientContentNegotiation) {
json(JSON)
}
install(ClientWebSockets) {
maxFrameSize = 4L * 1024 * 1024
}
}
}
single { SessionJwt(config.session) }
single { FieldEncryptor(config.encryption.key) }
single { IdentityFingerprint(config.antiAbuse.identityHmacKey) }
single<AppleJwksProvider> {
RemoteAppleJwksProvider(get(), config.apple.jwksUrl)
}
single { AppleIdentityTokenVerifier(config.apple, get()) }
single<AppleTokenClient> { createAppleTokenClient(get(), config.apple) }
single<AppleDeviceCheckClient> {
createDeviceCheckClient(get(), config.apple, config.integrity.appleEnvironment)
?: UnavailableAppleDeviceCheckClient()
}
single<DeviceCheckVerifier> { RemoteDeviceCheckVerifier(get()) }
single<AppAttestRepository> { ExposedAppAttestRepository(get()) }
single<AppAttestCrypto> {
LibraryAppAttestCrypto(
config = config.integrity,
certificateValidator = BundledAppleAppAttestTrust.validator(),
)
}
single { AppAttestService(get(), get(), config.integrity) }
single<AppAttestVerifier> { get<AppAttestService>() }
single { IntegrityService(config.integrity, get(), get()) }
single<DeviceCheckTrialClaimRepository> { ExposedDeviceCheckTrialClaimRepository(get()) }
single { MysqlDeviceCheckTrialMutex(get()) }
single<AuthRepository> { ExposedAuthRepository(get()) }
single { SessionAccessAuthenticator(get(), get()) }
single<AccountRepository> { ExposedAccountRepository(get(), get()) }
single<AppleEventRepository> { ExposedAppleEventRepository(get(), get(), config.antiAbuse) }
single { AppleEventVerifier(config.apple, get()) }
single { AppleEventService(get(), get()) }
single<BillingTransactionRunner> {
ExposedBillingTransactionRunner(get())
}
single {
CreditService(
transactions = get(),
referralRewards = ReferralRewardConfig(
inviterCredits = config.credits.referralInviter,
inviteeCredits = config.credits.referralInvitee,
),
)
}
single<TrialCreditGranter> {
TrialCreditGranter { accountId ->
get<CreditService>().grantSignupTrial(
userId = accountId,
credits = config.credits.signupTrial,
idempotencyKey = "internal:signup-trial:$accountId",
)
}
}
single {
DeviceCheckTrialService(
repository = get(),
client = get(),
creditGranter = get(),
policy = config.integrity.deviceCheckPolicy,
mutex = get<MysqlDeviceCheckTrialMutex>(),
)
}
single { ExposedGatewayRepository(get()) }
single<GatewayGrantRepository> { get<ExposedGatewayRepository>() }
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
single<AccountProvisioner> {
AccountProvisioner { accountId, deviceCheckToken ->
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
if (deviceCheckToken != null && !granted) {
get<AuthRepository>().restrictAccountForAntiAbuse(
accountId,
java.time.Instant.now(),
)
}
}
}
single {
SessionService(
repository = get(),
appleIdentityVerifier = get(),
appleTokenClient = get(),
integrityService = get(),
sessionJwt = get(),
fieldEncryptor = get(),
identityFingerprint = get(),
sessionConfig = config.session,
accountProvisioner = get(),
)
}
single { AppleRevocationOutboxProcessor(get(), get(), get()) }
single<AccountReauthenticator> {
AppleAccountReauthenticator(
identityVerifier = get(),
appleTokenClient = get(),
identityFingerprint = get(),
)
}
single {
AccountService(
repository = get(),
fieldEncryptor = get(),
antiAbuseConfig = config.antiAbuse,
revocationProcessor = get(),
reauthenticator = get(),
)
}
single<UserRegistrationTimeProvider> {
UserRegistrationTimeProvider { accountId ->
get<AccountRepository>().findById(accountId)?.createdAt
}
}
single<ReferralRiskProvider> {
ReferralRiskProvider { accountId ->
get<AccountRepository>().findById(accountId)?.let {
ReferralRiskIdentity(it.identityFingerprint, it.antiAbuseRestricted)
}
}
}
single {
ReferralService(
transactions = get(),
registrationTimeProvider = get(),
riskProvider = get(),
bindingWindow = Duration.ofDays(config.credits.referralBindingDays),
)
}
single<ReferralLookupPort> {
val transactions = get<BillingTransactionRunner>()
ReferralLookupPort { code ->
transactions.inTransaction { unit ->
val referralCode = unit.referrals.findCode(code)
referralCode?.campaignId
?.let(unit.referrals::findCampaign)
?.isActive(Instant.now()) == true
}
}
}
single { SessionIdentityAdapter(get()) }
single<AuthenticatedUserExtractor> { get<SessionIdentityAdapter>() }
single<GatewayIdentityPort> { get<SessionIdentityAdapter>() }
single {
GatewaySettings(
issuer = config.session.issuer,
audience = "${config.session.audience}-gateway",
accessTokenHmacSecret = deriveGatewaySecret(config.session.hmacSecret, "gateway-access"),
refreshTokenHmacSecret = deriveGatewaySecret(config.session.hmacSecret, "gateway-refresh"),
accessTokenLifetime = Duration.ofMinutes(5),
refreshTokenLifetime = Duration.ofDays(config.session.refreshDays),
maximumGrantLifetime = Duration.ofDays(config.session.gatewayGrantDays),
)
}
single { GatewayGrantService(get(), get()) }
single<GatewayAccessTokenPort> { GatewayBearerIdentity(get()) }
single<CreditReservationPort> {
CreditReservationAdapter(
creditService = get(),
llmModel = config.providers.deepSeek.model,
asrModel = config.providers.volcengine.resourceId,
)
}
single {
ProviderCatalog(configuredProviders(config, get()))
}
single { GatewayService(get(), get(), get(), get()) }
single { GatewayReconciliationService(get(), get()) }
single {
InviteWebConfig(
appStoreUrl = config.appStoreUrl,
appleAppId = "${config.integrity.appAttestTeamId}.${config.integrity.appAttestBundleId}",
universalLinkBaseUrl = config.inviteBaseUrl,
)
}
}
private fun configuredProviders(config: AppConfig, client: HttpClient): List<GatewayProvider> =
buildList {
config.providers.deepSeek.apiKey?.let { apiKey ->
add(
DeepSeekProvider(
client,
DeepSeekConfig(
endpoint = config.providers.deepSeek.endpoint,
apiKey = apiKey,
model = config.providers.deepSeek.model,
),
),
)
}
if (config.providers.volcengine.credentialsAvailable) {
val providerConfig = config.providers.volcengine.toProviderConfig()
add(VolcengineAsrProvider(KtorVolcengineAsrTransport(client, providerConfig)))
}
}
private fun com.osglab.account.config.VolcengineConfig.toProviderConfig() =
VolcengineAsrConfig(
endpoint = endpoint,
resourceId = resourceId,
appId = appId,
accessToken = accessToken,
apiKey = apiKey,
)
private fun deriveGatewaySecret(master: ByteArray, context: String): ByteArray =
Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(master, "HmacSHA256"))
doFinal("osg-account-server:$context".toByteArray(Charsets.UTF_8))
}
private val REQUEST_ID = Regex("[A-Za-z0-9_-]{8,64}")
private val AUTH_RATE_LIMIT = RateLimitName("auth")
private val ACCOUNT_RATE_LIMIT = RateLimitName("account")
private val GATEWAY_RATE_LIMIT = RateLimitName("gateway")
private val PUBLIC_RATE_LIMIT = RateLimitName("public")
@@ -0,0 +1,59 @@
package com.osglab.account.common.api
import com.osglab.account.common.errors.ApiException
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.plugins.statuspages.exception
import io.ktor.server.response.respond
import io.ktor.util.AttributeKey
import kotlinx.serialization.Serializable
@Serializable
data class ApiResponse<T>(val data: T)
@Serializable
data class ApiErrorResponse(val error: ApiError)
@Serializable
data class ApiError(
val code: String,
val message: String,
)
fun Application.installApiStatusPages() {
install(StatusPages) {
status(HttpStatusCode.Unauthorized) { call, status ->
if (!call.attributes.contains(API_ERROR_HANDLED)) {
call.respond(
status,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
}
}
exception<ApiException> { call, cause ->
call.attributes.put(API_ERROR_HANDLED, true)
call.respond(
cause.status,
ApiErrorResponse(ApiError(cause.code, cause.message)),
)
}
exception<BadRequestException> { call, _ ->
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "Request body is invalid")),
)
}
exception<Throwable> { call, _ ->
call.respond(
HttpStatusCode.InternalServerError,
ApiErrorResponse(ApiError("internal_error", "An internal error occurred")),
)
}
}
}
private val API_ERROR_HANDLED = AttributeKey<Boolean>("api-error-handled")
@@ -0,0 +1,65 @@
package com.osglab.account.common.errors
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.plugins.statuspages.exception
import io.ktor.server.response.respond
import kotlinx.serialization.Serializable
@Serializable
data class ApiErrorResponse(val error: ApiError)
@Serializable
data class ApiError(
val code: String,
val message: String,
)
open class ApiException(
val status: HttpStatusCode,
val code: String,
override val message: String,
) : RuntimeException(message)
class InvalidRequestException(message: String) :
ApiException(HttpStatusCode.BadRequest, "invalid_request", message)
class UnauthorizedException(message: String = "Authentication required") :
ApiException(HttpStatusCode.Unauthorized, "unauthorized", message)
class TokenReuseException :
ApiException(
HttpStatusCode.Unauthorized,
"refresh_token_reuse",
"Refresh token reuse detected; the session family has been revoked",
)
class ExternalServiceUnavailableException(service: String) :
ApiException(
HttpStatusCode.ServiceUnavailable,
"external_service_unavailable",
"$service is temporarily unavailable",
)
class ConflictException(message: String) :
ApiException(HttpStatusCode.Conflict, "conflict", message)
fun Application.installApiErrors() {
install(StatusPages) {
exception<ApiException> { call, cause ->
call.respond(
cause.status,
ApiErrorResponse(ApiError(cause.code, cause.message)),
)
}
exception<Throwable> { call, _ ->
call.respond(
HttpStatusCode.InternalServerError,
ApiErrorResponse(ApiError("internal_error", "An internal error occurred")),
)
}
}
}
@@ -0,0 +1,61 @@
package com.osglab.account.common.security
import java.security.GeneralSecurityException
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class FieldEncryptor(
key: ByteArray,
private val secureRandom: SecureRandom = SecureRandom(),
) {
private val keySpec: SecretKeySpec
init {
require(key.size == AES_KEY_BYTES) { "AES-GCM requires a 32-byte key" }
keySpec = SecretKeySpec(key.copyOf(), "AES")
}
fun encrypt(plaintext: String, context: String): String {
require(context.isNotBlank()) { "Encryption context must not be blank" }
val iv = ByteArray(GCM_IV_BYTES).also(secureRandom::nextBytes)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(GCM_TAG_BITS, iv))
cipher.updateAAD(context.toByteArray(Charsets.UTF_8))
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val encoder = Base64.getUrlEncoder().withoutPadding()
return listOf(VERSION, encoder.encodeToString(iv), encoder.encodeToString(ciphertext))
.joinToString(".")
}
fun decrypt(value: String, context: String): String {
require(context.isNotBlank()) { "Encryption context must not be blank" }
val parts = value.split('.')
require(parts.size == 3 && parts[0] == VERSION) { "Unsupported encrypted field format" }
return try {
val decoder = Base64.getUrlDecoder()
val iv = decoder.decode(parts[1])
require(iv.size == GCM_IV_BYTES) { "Invalid AES-GCM IV" }
val ciphertext = decoder.decode(parts[2])
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, keySpec, GCMParameterSpec(GCM_TAG_BITS, iv))
cipher.updateAAD(context.toByteArray(Charsets.UTF_8))
cipher.doFinal(ciphertext).toString(Charsets.UTF_8)
} catch (exception: GeneralSecurityException) {
throw FieldDecryptionException(exception)
} catch (exception: IllegalArgumentException) {
throw FieldDecryptionException(exception)
}
}
}
class FieldDecryptionException(cause: Throwable) :
IllegalStateException("Encrypted field authentication failed", cause)
private const val VERSION = "v1"
private const val AES_KEY_BYTES = 32
private const val GCM_IV_BYTES = 12
private const val GCM_TAG_BITS = 128
private const val TRANSFORMATION = "AES/GCM/NoPadding"
@@ -0,0 +1,34 @@
package com.osglab.account.common.security
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Produces a non-reversible, deployment-specific identifier for anti-abuse history.
* The HMAC key must be independent from the field-encryption and session keys.
*/
class IdentityFingerprint(
key: ByteArray,
) {
private val keySpec: SecretKeySpec
init {
require(key.size >= MIN_KEY_BYTES) {
"Identity fingerprint HMAC key must contain at least $MIN_KEY_BYTES bytes"
}
keySpec = SecretKeySpec(key.copyOf(), HMAC_ALGORITHM)
}
fun ofAppleSubject(subject: String): String {
require(subject.isNotBlank()) { "Apple subject must not be blank" }
val mac = Mac.getInstance(HMAC_ALGORITHM)
mac.init(keySpec)
return mac.doFinal(subject.toByteArray(Charsets.UTF_8))
.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
}
private companion object {
const val HMAC_ALGORITHM = "HmacSHA256"
const val MIN_KEY_BYTES = 32
}
}
@@ -0,0 +1,116 @@
package com.osglab.account.common.security
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.JOSEObjectType
import com.nimbusds.jose.crypto.MACSigner
import com.nimbusds.jose.crypto.MACVerifier
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.SessionConfig
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.bearer
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Date
import java.util.UUID
data class IssuedAccessToken(
val value: String,
val expiresAt: Instant,
) {
override fun toString(): String = "IssuedAccessToken(value=[REDACTED], expiresAt=$expiresAt)"
}
data class AccountPrincipal(
val userId: UUID,
val sessionId: UUID,
) {
// Compatibility name used by the existing credits and gateway adapters.
val accountId: UUID
get() = userId
}
@Deprecated("Use AccountPrincipal", ReplaceWith("AccountPrincipal"))
typealias SessionPrincipal = AccountPrincipal
class SessionJwt(
private val config: SessionConfig,
private val clock: Clock = Clock.systemUTC(),
) {
init {
require(config.hmacSecret.size >= MIN_HMAC_BYTES) {
"Session HMAC secret must contain at least $MIN_HMAC_BYTES bytes"
}
}
fun issue(userId: UUID, sessionId: UUID): IssuedAccessToken {
val now = clock.instant()
val expiresAt = now.plus(Duration.ofMinutes(config.accessMinutes))
val tokenId = UUID.randomUUID()
val claims = JWTClaimsSet.Builder()
.issuer(config.issuer)
.audience(config.audience)
.subject(userId.toString())
.jwtID(tokenId.toString())
.issueTime(Date.from(now))
.notBeforeTime(Date.from(now.minusSeconds(CLOCK_SKEW_SECONDS)))
.expirationTime(Date.from(expiresAt))
.claim(CLAIM_TYPE, ACCESS_TOKEN_TYPE)
.claim(CLAIM_SESSION, sessionId.toString())
.build()
val jwt = SignedJWT(
JWSHeader.Builder(JWSAlgorithm.HS256).type(JOSEObjectType.JWT).build(),
claims,
)
jwt.sign(MACSigner(config.hmacSecret))
return IssuedAccessToken(jwt.serialize(), expiresAt)
}
fun verify(serialized: String): AccountPrincipal? = runCatching {
require(serialized.isNotBlank() && serialized.length <= MAX_ACCESS_TOKEN_LENGTH)
val jwt = SignedJWT.parse(serialized)
require(jwt.header.algorithm == JWSAlgorithm.HS256)
require(jwt.header.type == JOSEObjectType.JWT)
require(jwt.header.criticalParams.isNullOrEmpty())
require(jwt.verify(MACVerifier(config.hmacSecret)))
val claims = jwt.jwtClaimsSet
val now = clock.instant()
require(claims.issuer == config.issuer)
require(claims.audience == listOf(config.audience))
require(claims.getStringClaim(CLAIM_TYPE) == ACCESS_TOKEN_TYPE)
val expiresAt = requireNotNull(claims.expirationTime).toInstant()
val notBefore = requireNotNull(claims.notBeforeTime).toInstant()
val issuedAt = requireNotNull(claims.issueTime).toInstant()
require(expiresAt.isAfter(now.minusSeconds(CLOCK_SKEW_SECONDS)))
require(notBefore.isBefore(now.plusSeconds(CLOCK_SKEW_SECONDS)))
require(!issuedAt.isAfter(now.plusSeconds(CLOCK_SKEW_SECONDS)))
require(expiresAt.isAfter(issuedAt))
UUID.fromString(requireNotNull(claims.jwtid))
AccountPrincipal(
userId = UUID.fromString(claims.subject),
sessionId = UUID.fromString(claims.getStringClaim(CLAIM_SESSION)),
)
}.getOrNull()
}
fun Application.installSessionAuthentication(
authenticateToken: suspend (String) -> AccountPrincipal?,
) {
install(Authentication) {
bearer(SESSION_AUTH_NAME) {
authenticate { credential -> authenticateToken(credential.token) }
}
}
}
const val SESSION_AUTH_NAME = "session"
private const val CLAIM_TYPE = "typ"
private const val CLAIM_SESSION = "sid"
private const val ACCESS_TOKEN_TYPE = "access"
private const val MIN_HMAC_BYTES = 32
private const val MAX_ACCESS_TOKEN_LENGTH = 4_096
private const val CLOCK_SKEW_SECONDS = 30L
@@ -0,0 +1,40 @@
package com.osglab.account.common.security
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
typealias RefreshTokenGenerator = Sha256SecureTokenGenerator
fun interface SecureTokenGenerator {
fun newRefreshToken(): String
}
/**
* Generates a 256-bit opaque token. Callers persist only its SHA-256 digest.
*/
class Sha256SecureTokenGenerator(
private val secureRandom: SecureRandom = SecureRandom(),
) : SecureTokenGenerator {
override fun newRefreshToken(): String =
ByteArray(REFRESH_TOKEN_BYTES)
.also(secureRandom::nextBytes)
.let(Base64.getUrlEncoder().withoutPadding()::encodeToString)
private companion object {
const val REFRESH_TOKEN_BYTES = 32
}
}
object TokenHash {
fun sha256(token: String): String =
MessageDigest.getInstance("SHA-256")
.digest(token.toByteArray(Charsets.UTF_8))
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
fun matches(token: String, expectedHex: String): Boolean {
val actual = sha256(token).toByteArray(Charsets.US_ASCII)
val expected = expectedHex.lowercase().toByteArray(Charsets.US_ASCII)
return MessageDigest.isEqual(actual, expected)
}
}
@@ -0,0 +1,519 @@
package com.osglab.account.config
import io.ktor.server.config.ApplicationConfig
import java.net.URI
import java.util.Base64
data class AppConfig(
val environment: Environment,
val publicBaseUrl: String,
val inviteBaseUrl: String,
val appStoreUrl: String,
val database: DatabaseConfig,
val session: SessionConfig,
val encryption: EncryptionConfig,
val antiAbuse: AntiAbuseConfig,
val apple: AppleConfig,
val credits: CreditsConfig,
val providers: ProvidersConfig,
val integrity: IntegrityConfig,
) {
val isProduction: Boolean = environment == Environment.PRODUCTION
companion object {
fun from(config: ApplicationConfig): AppConfig {
val environment = Environment.parse(config.required("app.environment"))
val production = environment == Environment.PRODUCTION
val databaseUsername = config.required("app.database.username")
val databasePassword = config.secret("app.database.password", production)
val migrationUsername = if (production) {
config.required("app.database.migrationUsername")
} else {
config.optionalValue("app.database.migrationUsername") ?: databaseUsername
}
val migrationPassword = config.optionalSecret(
"app.database.migrationPassword",
production = production,
) ?: databasePassword
val database = DatabaseConfig(
jdbcUrl = config.required("app.database.jdbcUrl"),
username = databaseUsername,
password = databasePassword,
migrationUsername = migrationUsername,
migrationPassword = migrationPassword,
maximumPoolSize = config.positiveInt("app.database.maximumPoolSize"),
)
val session = SessionConfig(
issuer = config.required("app.session.issuer"),
audience = config.required("app.session.audience"),
hmacSecret = config.secret("app.session.secret", production).toByteArray(),
accessMinutes = config.positiveLong("app.session.accessMinutes"),
refreshDays = config.positiveLong("app.session.refreshDays"),
gatewayGrantDays = config.positiveLong("app.session.gatewayGrantDays", 30),
)
val encryption = EncryptionConfig(
key = config.base64Key("app.encryption.keyBase64", production),
)
val antiAbuse = AntiAbuseConfig(
identityHmacKey = config.base64Key("app.antiAbuse.identityHmacKeyBase64", production),
tombstoneRetentionDays = config.positiveLong(
"app.antiAbuse.tombstoneRetentionDays",
365,
),
)
val apple = AppleConfig(
teamId = config.optionalSecret("app.apple.teamId", production),
keyId = config.optionalSecret("app.apple.keyId", production),
clientId = config.required("app.apple.clientId"),
privateKeyPem = config.optionalSecret("app.apple.privateKeyPem", production)
?.replace("\\n", "\n"),
jwksUrl = config.httpsUrl("app.apple.jwksUrl", production),
tokenUrl = config.httpsUrl("app.apple.tokenUrl", production),
revokeUrl = config.httpsUrl("app.apple.revokeUrl", production),
)
val credits = CreditsConfig(
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
referralInviter = config.positiveLong("app.credits.referralInviter", 3_000),
referralInvitee = config.positiveLong("app.credits.referralInvitee", 3_000),
referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7),
)
val providers = ProvidersConfig(
volcengine = VolcengineConfig(
endpoint = config.valueOrDefault(
"app.providers.volcengine.endpoint",
"wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async",
),
appId = config.optionalValue("app.providers.volcengine.appId"),
accessToken = config.optionalValue("app.providers.volcengine.accessToken"),
apiKey = config.optionalValue("app.providers.volcengine.apiKey"),
resourceId = config.valueOrDefault(
"app.providers.volcengine.resourceId",
"volc.seedasr.sauc.duration",
),
),
deepSeek = DeepSeekConfig(
endpoint = config.valueOrDefault(
"app.providers.deepseek.endpoint",
"https://api.deepseek.com/v1",
),
apiKey = config.optionalValue("app.providers.deepseek.apiKey"),
model = config.valueOrDefault("app.providers.deepseek.model", "deepseek-v4-flash"),
),
)
val integrity = IntegrityConfig(
deviceCheckPolicy = IntegrityPolicy.fromEnforced(
config.boolean("app.integrity.enforceDeviceCheck"),
),
appAttestPolicy = IntegrityPolicy.fromEnforced(
config.boolean("app.integrity.enforceAppAttest"),
),
appleEnvironment = AppleServiceEnvironment.parse(
config.valueOrDefault(
"app.integrity.appleEnvironment",
if (production) "production" else "development",
),
),
challengeLifetimeSeconds = config.positiveLong(
"app.integrity.challengeLifetimeSeconds",
300,
),
)
require(session.hmacSecret.size >= MIN_HMAC_SECRET_BYTES) {
"app.session.secret must contain at least $MIN_HMAC_SECRET_BYTES bytes"
}
require(encryption.key.size == AES_256_KEY_BYTES) {
"app.encryption.keyBase64 must decode to exactly $AES_256_KEY_BYTES bytes"
}
require(antiAbuse.identityHmacKey.size >= MIN_HMAC_SECRET_BYTES) {
"app.antiAbuse.identityHmacKeyBase64 must decode to at least $MIN_HMAC_SECRET_BYTES bytes"
}
require(
!session.hmacSecret.contentEquals(encryption.key) &&
!session.hmacSecret.contentEquals(antiAbuse.identityHmacKey) &&
!encryption.key.contentEquals(antiAbuse.identityHmacKey)
) {
"Session, field-encryption, and identity-HMAC secrets must be distinct"
}
require(database.jdbcUrl.startsWith("jdbc:mysql:")) {
"app.database.jdbcUrl must use MySQL"
}
require(
!production ||
(!database.migrationUsername.isPlaceholder() &&
database.migrationUsername != database.username)
) {
"Production migration and runtime database users must be distinct"
}
require(!production || database.migrationPassword != database.password) {
"Production migration and runtime database passwords must be distinct"
}
require(database.maximumPoolSize in 1..100) {
"app.database.maximumPoolSize must be between 1 and 100"
}
require(session.accessMinutes in 1..60) {
"app.session.accessMinutes must be between 1 and 60"
}
require(session.refreshDays in 1..365) {
"app.session.refreshDays must be between 1 and 365"
}
require(!production || providers.volcengine.credentialsAvailable) {
"Production Volcengine credentials are missing"
}
require(!production || providers.deepSeek.credentialsAvailable) {
"Production DeepSeek credentials are missing"
}
if (production) {
requireExactProviderEndpoint(
providers.deepSeek.endpoint,
"https",
"api.deepseek.com",
"DeepSeek",
)
requireExactProviderEndpoint(
providers.volcengine.endpoint,
"wss",
"openspeech.bytedance.com",
"Volcengine",
)
}
require(integrity.challengeLifetimeSeconds in 30..600) {
"app.integrity.challengeLifetimeSeconds must be between 30 and 600 seconds"
}
require(!production || integrity.appleEnvironment == AppleServiceEnvironment.PRODUCTION) {
"Production must use the Apple production integrity environment"
}
require(
!production ||
(
integrity.deviceCheckPolicy == IntegrityPolicy.ENFORCE &&
integrity.appAttestPolicy == IntegrityPolicy.ENFORCE
)
) {
"Production must enforce both DeviceCheck and App Attest"
}
require(!production || apple.teamId == APP_ATTEST_TEAM_ID) {
"Production Apple team ID must be $APP_ATTEST_TEAM_ID"
}
require(!production || apple.clientId == APP_ATTEST_BUNDLE_ID) {
"Production Apple client ID must be $APP_ATTEST_BUNDLE_ID"
}
if (production) {
requireExactAppleEndpoint(apple.jwksUrl, "/auth/keys", "JWKS")
requireExactAppleEndpoint(apple.tokenUrl, "/auth/token", "token")
requireExactAppleEndpoint(apple.revokeUrl, "/auth/revoke", "revoke")
}
val publicBaseUrl = config.productionValueOrDefault(
"app.publicBaseUrl",
"http://localhost:8080",
production,
).validatedExternalUrl("app.publicBaseUrl", production)
val inviteBaseUrl = config.productionValueOrDefault(
"app.inviteBaseUrl",
"https://osglab.com/i",
production,
).validatedExternalUrl("app.inviteBaseUrl", production)
val appStoreUrl = config.productionValueOrDefault(
"app.appStoreUrl",
"https://apps.apple.com",
production,
).validatedExternalUrl("app.appStoreUrl", production)
if (production) {
requireExactExternalUrl(publicBaseUrl, "account.osglab.com", "", "PUBLIC_BASE_URL")
requireExactExternalUrl(inviteBaseUrl, "osglab.com", "/i", "INVITE_BASE_URL")
requireOfficialAppStoreUrl(appStoreUrl)
}
return AppConfig(
environment = environment,
publicBaseUrl = publicBaseUrl,
inviteBaseUrl = inviteBaseUrl,
appStoreUrl = appStoreUrl,
database = database,
session = session,
encryption = encryption,
antiAbuse = antiAbuse,
apple = apple,
credits = credits,
providers = providers,
integrity = integrity,
)
}
}
}
enum class Environment {
DEVELOPMENT,
TEST,
PRODUCTION;
companion object {
fun parse(value: String): Environment = entries.firstOrNull {
it.name.equals(value, ignoreCase = true)
} ?: throw ConfigValidationException("Unsupported app.environment: $value")
}
}
data class DatabaseConfig(
val jdbcUrl: String,
val username: String,
val password: String,
val maximumPoolSize: Int,
val migrationUsername: String = username,
val migrationPassword: String = password,
)
data class SessionConfig(
val issuer: String,
val audience: String,
val hmacSecret: ByteArray,
val accessMinutes: Long,
val refreshDays: Long,
val gatewayGrantDays: Long = 30,
)
data class EncryptionConfig(val key: ByteArray)
data class AntiAbuseConfig(
val identityHmacKey: ByteArray,
val tombstoneRetentionDays: Long,
)
data class AppleConfig(
val teamId: String?,
val keyId: String?,
val clientId: String,
val privateKeyPem: String?,
val jwksUrl: String,
val tokenUrl: String,
val revokeUrl: String,
) {
val clientCredentialsAvailable: Boolean
get() = teamId != null && keyId != null && privateKeyPem != null
}
data class CreditsConfig(
val signupTrial: Long,
val referralInviter: Long,
val referralInvitee: Long,
val referralBindingDays: Long,
)
data class ProvidersConfig(
val volcengine: VolcengineConfig,
val deepSeek: DeepSeekConfig,
)
data class VolcengineConfig(
val endpoint: String,
val appId: String?,
val accessToken: String?,
val apiKey: String?,
val resourceId: String,
) {
val credentialsAvailable: Boolean
get() = !apiKey.isNullOrBlank() || (!appId.isNullOrBlank() && !accessToken.isNullOrBlank())
}
data class DeepSeekConfig(
val endpoint: String,
val apiKey: String?,
val model: String,
) {
val credentialsAvailable: Boolean
get() = !apiKey.isNullOrBlank()
}
data class IntegrityConfig(
val deviceCheckPolicy: IntegrityPolicy,
val appAttestPolicy: IntegrityPolicy,
val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT,
val challengeLifetimeSeconds: Long = 300,
val appAttestTeamId: String = APP_ATTEST_TEAM_ID,
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
)
enum class IntegrityPolicy {
MONITOR,
ENFORCE;
companion object {
fun fromEnforced(enforced: Boolean): IntegrityPolicy = if (enforced) ENFORCE else MONITOR
}
}
enum class AppleServiceEnvironment {
DEVELOPMENT,
PRODUCTION;
companion object {
fun parse(value: String): AppleServiceEnvironment = entries.firstOrNull {
it.name.equals(value, ignoreCase = true)
} ?: throw ConfigValidationException("Unsupported Apple integrity environment: $value")
}
}
class ConfigValidationException(message: String, cause: Throwable? = null) :
IllegalStateException(message, cause)
private const val MIN_HMAC_SECRET_BYTES = 32
private const val AES_256_KEY_BYTES = 32
const val APP_ATTEST_TEAM_ID = "X329MZU23S"
const val APP_ATTEST_BUNDLE_ID = "com.osgkeyboard.ios"
private val PLACEHOLDER_MARKERS = listOf("replace-with", "change-me", "\${", "$")
private fun ApplicationConfig.required(path: String): String =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
?: throw ConfigValidationException("Missing required configuration: $path")
private fun ApplicationConfig.valueOrDefault(path: String, default: String): String =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty) ?: default
private fun ApplicationConfig.productionValueOrDefault(
path: String,
default: String,
production: Boolean,
): String = if (production) required(path) else valueOrDefault(path, default)
private fun ApplicationConfig.optionalValue(path: String): String? =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
?.takeUnless(String::isPlaceholder)
private fun ApplicationConfig.secret(path: String, production: Boolean): String =
optionalSecret(path, production)
?: throw ConfigValidationException("Missing required secret: $path")
private fun ApplicationConfig.optionalSecret(path: String, production: Boolean): String? {
val value = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
if (production && (value == null || value.isPlaceholder())) {
throw ConfigValidationException("Production secret is missing or uses a placeholder: $path")
}
return value?.takeUnless(String::isPlaceholder)
}
private fun String.isPlaceholder(): Boolean =
PLACEHOLDER_MARKERS.any { marker -> contains(marker, ignoreCase = true) }
private fun ApplicationConfig.positiveInt(path: String): Int =
required(path).toIntOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("$path must be a positive integer")
private fun ApplicationConfig.positiveLong(path: String): Long =
required(path).toLongOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("$path must be a positive integer")
private fun ApplicationConfig.positiveLong(path: String, default: Long): Long =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)?.let {
it.toLongOrNull()?.takeIf { value -> value > 0 }
?: throw ConfigValidationException("$path must be a positive integer")
} ?: default
private fun ApplicationConfig.boolean(path: String): Boolean =
required(path).let {
when (it.lowercase()) {
"true" -> true
"false" -> false
else -> throw ConfigValidationException("$path must be true or false")
}
}
private fun ApplicationConfig.base64Key(path: String, production: Boolean): ByteArray {
val encoded = secret(path, production)
return try {
Base64.getDecoder().decode(encoded)
} catch (exception: IllegalArgumentException) {
throw ConfigValidationException("$path must be valid Base64", exception)
}
}
private fun ApplicationConfig.httpsUrl(path: String, production: Boolean): String {
val value = required(path)
val uri = runCatching { URI(value) }
.getOrElse { throw ConfigValidationException("$path must be a valid URL", it) }
require(uri.isAbsolute && !uri.host.isNullOrBlank() && uri.userInfo == null) {
"$path must be an absolute URL without user information"
}
require(uri.scheme.equals("https", true) || (!production && uri.scheme.equals("http", true))) {
"$path must use HTTP or HTTPS"
}
require(!production || uri.scheme.equals("https", ignoreCase = true)) {
"$path must use HTTPS in production"
}
return value
}
private fun String.validatedExternalUrl(path: String, production: Boolean): String {
val uri = runCatching { URI(this) }
.getOrElse { throw ConfigValidationException("$path must be a valid URL", it) }
require(uri.isAbsolute && !uri.host.isNullOrBlank() && uri.userInfo == null) {
"$path must be an absolute URL without user information"
}
require(uri.scheme.equals("https", true) || (!production && uri.scheme.equals("http", true))) {
"$path must use HTTPS${if (production) "" else " or HTTP"}"
}
return this
}
private fun requireExactAppleEndpoint(value: String, path: String, name: String) {
val uri = URI(value)
require(
uri.scheme.equals("https", true) &&
uri.host.equals("appleid.apple.com", true) &&
uri.port == -1 &&
uri.path == path &&
uri.rawQuery == null &&
uri.rawFragment == null
) {
"Apple $name endpoint must be https://appleid.apple.com$path"
}
}
private fun requireExactProviderEndpoint(
value: String,
scheme: String,
host: String,
provider: String,
) {
val uri = runCatching { URI(value) }
.getOrElse { throw ConfigValidationException("$provider endpoint is invalid", it) }
require(
uri.scheme.equals(scheme, ignoreCase = true) &&
uri.host.equals(host, ignoreCase = true) &&
uri.userInfo == null &&
(uri.port == -1 || uri.port == 443)
) {
"$provider endpoint must use $scheme://$host on the default TLS port"
}
}
private fun requireExactExternalUrl(value: String, host: String, path: String, name: String) {
val uri = URI(value)
require(
uri.scheme.equals("https", ignoreCase = true) &&
uri.host.equals(host, ignoreCase = true) &&
uri.port == -1 &&
uri.path.trimEnd('/') == path &&
uri.rawQuery == null &&
uri.rawFragment == null
) {
"$name must be https://$host$path"
}
}
private fun requireOfficialAppStoreUrl(value: String) {
val uri = URI(value)
require(
uri.scheme.equals("https", ignoreCase = true) &&
uri.host.equals("apps.apple.com", ignoreCase = true) &&
uri.port == -1 &&
uri.userInfo == null &&
uri.rawFragment == null &&
APP_STORE_APP_PATH.matches(uri.path)
) {
"APP_STORE_URL must be an official apps.apple.com URL ending in a non-zero numeric App ID"
}
}
private val APP_STORE_APP_PATH = Regex("/.+/id[1-9][0-9]*")
@@ -0,0 +1,106 @@
package com.osglab.account.config
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import kotlinx.coroutines.Dispatchers
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction
import java.sql.DriverManager
class DatabaseFactory(
private val config: DatabaseConfig,
) : AutoCloseable {
private val dataSourceDelegate = lazy(::createDataSource)
private val dataSource: HikariDataSource by dataSourceDelegate
val database: Database by lazy {
Flyway.configure()
.dataSource(
config.jdbcUrl,
config.migrationUsername,
config.migrationPassword,
)
.validateMigrationNaming(true)
.load()
.migrate()
Database.connect(dataSource)
}
suspend fun <T> query(block: suspend () -> T): T =
kotlinx.coroutines.withContext(Dispatchers.IO) {
suspendTransaction(database) { block() }
}
suspend fun isReady(): Boolean = kotlinx.coroutines.withContext(Dispatchers.IO) {
runCatching {
// Initializing `database` also validates and applies Flyway migrations.
database
dataSource.connection.use { connection ->
connection.prepareStatement("SELECT 1").use { statement ->
statement.executeQuery().use { result ->
check(result.next() && result.getInt(1) == 1)
}
}
}
}.isSuccess
}
/**
* Uses a dedicated physical connection because MySQL named locks are
* connection-scoped. The protected block may use normal repository
* transactions without exhausting the Hikari pool.
*/
suspend fun <T> withMysqlNamedLock(
name: String,
timeoutSeconds: Int,
block: suspend () -> T,
): T = kotlinx.coroutines.withContext(Dispatchers.IO) {
require(name.isNotBlank() && name.length <= 64)
require(timeoutSeconds in 1..60)
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
val acquired = connection.prepareStatement("SELECT GET_LOCK(?, ?)").use { statement ->
statement.setString(1, name)
statement.setInt(2, timeoutSeconds)
statement.executeQuery().use { result ->
result.next() && result.getInt(1) == 1
}
}
if (!acquired) throw IllegalStateException("Timed out acquiring database named lock")
try {
block()
} finally {
runCatching {
connection.prepareStatement("SELECT RELEASE_LOCK(?)").use { statement ->
statement.setString(1, name)
statement.executeQuery().close()
}
}
}
}
}
override fun close() {
if (dataSourceDelegate.isInitialized()) {
dataSourceDelegate.value.close()
}
}
private fun createDataSource(): HikariDataSource = HikariDataSource(
HikariConfig().apply {
jdbcUrl = config.jdbcUrl
username = config.username
password = config.password
maximumPoolSize = config.maximumPoolSize
minimumIdle = 1
connectionTimeout = 10_000
validationTimeout = 5_000
idleTimeout = 600_000
maxLifetime = 1_800_000
isAutoCommit = false
transactionIsolation = "TRANSACTION_READ_COMMITTED"
connectionInitSql = "SET time_zone = '+00:00'"
poolName = "osg-account-db"
},
)
}
@@ -0,0 +1,57 @@
package com.osglab.account.config
import com.osglab.account.common.api.installApiStatusPages
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.calllogging.CallLogging
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.defaultheaders.DefaultHeaders
import io.ktor.server.plugins.forwardedheaders.ForwardedHeaders
import io.ktor.server.plugins.forwardedheaders.XForwardedHeaders
import io.ktor.server.request.header
import io.ktor.server.request.httpMethod
import io.ktor.server.request.path
import io.ktor.server.websocket.WebSockets
import kotlinx.serialization.json.Json
import org.slf4j.event.Level
fun Application.configureHttpPlugins() {
install(ForwardedHeaders)
install(XForwardedHeaders)
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = false
explicitNulls = false
encodeDefaults = true
},
)
}
install(DefaultHeaders) {
header("X-Content-Type-Options", "nosniff")
header("X-Frame-Options", "DENY")
header("Referrer-Policy", "no-referrer")
}
install(WebSockets) {
maxFrameSize = 1L * 1024 * 1024
masking = false
}
install(CallLogging) {
level = Level.INFO
filter { call -> !call.request.path().startsWith("/health/") }
mdc("requestId") { call -> call.request.header(REQUEST_ID_HEADER) ?: "generated" }
format { call ->
// Deliberately excludes query strings, headers and bodies.
"${call.request.httpMethod.value} ${call.request.path()} ${call.response.status()?.value ?: 0}"
}
}
installApiStatusPages()
}
private const val REQUEST_ID_HEADER = "X-Request-ID"
@@ -0,0 +1,212 @@
package com.osglab.account.features.account
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.features.auth.AccountIdentityTombstonesTable
import com.osglab.account.features.auth.AccountsTable
import com.osglab.account.features.auth.AppleCredentialsTable
import com.osglab.account.features.auth.withAppleIdentityLock
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.lessEq
import org.jetbrains.exposed.v1.core.plus
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
import java.util.UUID
data class AccountRecord(
val id: UUID,
val identityFingerprint: String,
val antiAbuseRestricted: Boolean,
val encryptedAppleRefreshToken: String?,
val createdAt: Instant,
) {
override fun toString(): String =
"AccountRecord(id=$id, identityFingerprint=[REDACTED], " +
"antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " +
"createdAt=$createdAt)"
}
data class AppleRevocationOutboxRecord(
val id: UUID,
val encryptedRefreshToken: String,
val attemptCount: Int,
) {
override fun toString(): String =
"AppleRevocationOutboxRecord(id=$id, encryptedRefreshToken=[REDACTED], attemptCount=$attemptCount)"
}
data class NewAppleRevocation(
val id: UUID,
val encryptedRefreshToken: String,
) {
override fun toString(): String =
"NewAppleRevocation(id=$id, encryptedRefreshToken=[REDACTED])"
}
internal object AppleRevocationOutboxTable : Table("apple_revocation_outbox") {
val id = varchar("id", 36)
val encryptedRefreshToken = text("encrypted_refresh_token").nullable()
val createdAt = timestamp("created_at")
val nextAttemptAt = timestamp("next_attempt_at")
val attemptCount = integer("attempt_count")
val completedAt = timestamp("completed_at").nullable()
override val primaryKey = PrimaryKey(id)
}
interface AccountRepository {
suspend fun findById(accountId: UUID): AccountRecord?
suspend fun deleteById(
accountId: UUID,
deletedAt: Instant,
tombstoneExpiresAt: Instant,
createRevocation: (encryptedRefreshToken: String?) -> NewAppleRevocation?,
): Boolean
suspend fun pendingAppleRevocations(now: Instant, limit: Int): List<AppleRevocationOutboxRecord>
suspend fun rescheduleAppleRevocation(id: UUID, nextAttemptAt: Instant)
suspend fun completeAppleRevocation(id: UUID, completedAt: Instant)
}
class ExposedAccountRepository(
private val databaseFactory: DatabaseFactory,
@Suppress("UNUSED_PARAMETER") identityFingerprint: IdentityFingerprint,
) : AccountRepository {
override suspend fun findById(accountId: UUID): AccountRecord? = databaseFactory.query {
val row = AccountsTable.selectAll()
.where { AccountsTable.id eq accountId.toString() }
.singleOrNull()
?: return@query null
val fingerprint = row[AccountsTable.identityFingerprint] ?: return@query null
val encryptedRefreshToken = AppleCredentialsTable.selectAll()
.where { AppleCredentialsTable.accountId eq accountId.toString() }
.singleOrNull()
?.get(AppleCredentialsTable.encryptedRefreshToken)
row.let {
AccountRecord(
id = UUID.fromString(it[AccountsTable.id]),
identityFingerprint = fingerprint,
antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted],
encryptedAppleRefreshToken = encryptedRefreshToken,
createdAt = it[AccountsTable.createdAt],
)
}
}
override suspend fun deleteById(
accountId: UUID,
deletedAt: Instant,
tombstoneExpiresAt: Instant,
createRevocation: (encryptedRefreshToken: String?) -> NewAppleRevocation?,
): Boolean {
val fingerprint = databaseFactory.query {
AccountsTable.selectAll()
.where { AccountsTable.id eq accountId.toString() }
.singleOrNull()
?.get(AccountsTable.identityFingerprint)
} ?: return false
return databaseFactory.withAppleIdentityLock(fingerprint) {
databaseFactory.query {
val account = AccountsTable.selectAll()
.where { AccountsTable.id eq accountId.toString() }
.forUpdate()
.singleOrNull()
?: return@query false
val currentFingerprint = requireNotNull(account[AccountsTable.identityFingerprint])
check(currentFingerprint == fingerprint) { "Account identity changed unexpectedly" }
val encryptedRefreshToken = AppleCredentialsTable.selectAll()
.where { AppleCredentialsTable.accountId eq accountId.toString() }
.forUpdate()
.singleOrNull()
?.get(AppleCredentialsTable.encryptedRefreshToken)
recordIdentityTombstone(
currentFingerprint,
deletedAt,
tombstoneExpiresAt,
)
createRevocation(encryptedRefreshToken)?.let { pending ->
AppleRevocationOutboxTable.insert {
it[id] = pending.id.toString()
it[AppleRevocationOutboxTable.encryptedRefreshToken] = pending.encryptedRefreshToken
it[createdAt] = deletedAt
it[nextAttemptAt] = deletedAt
it[attemptCount] = 0
it[completedAt] = null
}
}
AccountsTable.deleteWhere { AccountsTable.id eq accountId.toString() } > 0
}
}
}
override suspend fun pendingAppleRevocations(
now: Instant,
limit: Int,
): List<AppleRevocationOutboxRecord> = databaseFactory.query {
AppleRevocationOutboxTable.selectAll()
.where {
AppleRevocationOutboxTable.completedAt.isNull() and
(AppleRevocationOutboxTable.nextAttemptAt lessEq now)
}
.orderBy(AppleRevocationOutboxTable.createdAt)
.limit(limit)
.map {
AppleRevocationOutboxRecord(
id = UUID.fromString(it[AppleRevocationOutboxTable.id]),
encryptedRefreshToken = requireNotNull(
it[AppleRevocationOutboxTable.encryptedRefreshToken],
),
attemptCount = it[AppleRevocationOutboxTable.attemptCount],
)
}
}
override suspend fun rescheduleAppleRevocation(id: UUID, nextAttemptAt: Instant) {
databaseFactory.query {
AppleRevocationOutboxTable.update({
(AppleRevocationOutboxTable.id eq id.toString()) and
AppleRevocationOutboxTable.completedAt.isNull()
}) {
it[attemptCount] = AppleRevocationOutboxTable.attemptCount + 1
it[AppleRevocationOutboxTable.nextAttemptAt] = nextAttemptAt
}
}
}
override suspend fun completeAppleRevocation(id: UUID, completedAt: Instant) {
databaseFactory.query {
AppleRevocationOutboxTable.update({
(AppleRevocationOutboxTable.id eq id.toString()) and
AppleRevocationOutboxTable.completedAt.isNull()
}) {
it[encryptedRefreshToken] = null
it[AppleRevocationOutboxTable.completedAt] = completedAt
}
}
}
}
internal fun recordIdentityTombstone(
identityFingerprint: String,
deletedAt: Instant,
expiresAt: Instant,
) {
AccountIdentityTombstonesTable.insertIgnore {
it[AccountIdentityTombstonesTable.identityFingerprint] = identityFingerprint
it[AccountIdentityTombstonesTable.deletedAt] = deletedAt
it[AccountIdentityTombstonesTable.expiresAt] = expiresAt
}
AccountIdentityTombstonesTable.update({
AccountIdentityTombstonesTable.identityFingerprint eq identityFingerprint
}) {
it[AccountIdentityTombstonesTable.deletedAt] = deletedAt
it[AccountIdentityTombstonesTable.expiresAt] = expiresAt
}
}
@@ -0,0 +1,77 @@
package com.osglab.account.features.account
import com.osglab.account.common.api.ApiResponse
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.SESSION_AUTH_NAME
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
@Serializable
data class AccountResponse(
val id: String,
val createdAtEpochSeconds: Long,
)
@Serializable
data class DeleteAccountRequest(
val identityToken: String,
val authorizationCode: String,
val nonce: String,
) {
fun toProof() = AppleReauthenticationProof(
identityToken = identityToken,
authorizationCode = authorizationCode,
nonce = nonce,
)
override fun toString(): String =
"DeleteAccountRequest(identityToken=[REDACTED], " +
"authorizationCode=[REDACTED], nonce=[REDACTED])"
}
class AccountRoutes(
private val accountService: AccountService,
) {
fun register(parent: Route) {
with(parent) {
authenticate(SESSION_AUTH_NAME) {
route("/v1/account") {
get {
val principal = call.principal<AccountPrincipal>()
?: throw UnauthorizedException()
val account = accountService.get(principal.userId)
call.respond(
ApiResponse(
data = AccountResponse(
id = account.id.toString(),
createdAtEpochSeconds = account.createdAt.epochSecond,
),
),
)
}
delete {
val principal = call.principal<AccountPrincipal>()
?: throw UnauthorizedException()
accountService.delete(
principal.userId,
call.receive<DeleteAccountRequest>().toProof(),
)
call.respond(HttpStatusCode.NoContent)
}
}
}
}
}
}
fun Route.accountRoutes(accountService: AccountService) =
AccountRoutes(accountService).register(this)
@@ -0,0 +1,177 @@
package com.osglab.account.features.account
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.FieldDecryptionException
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.config.AntiAbuseConfig
import com.osglab.account.features.auth.AppleClientUnavailableException
import com.osglab.account.features.auth.AppleIdentityTokenVerifier
import com.osglab.account.features.auth.AppleTokenEndpointException
import com.osglab.account.features.auth.AppleTokenClient
import com.osglab.account.features.auth.AppleTokenInvalidException
import com.osglab.account.features.auth.AppleVerificationUnavailableException
import kotlinx.coroutines.CancellationException
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.UUID
data class AccountView(
val id: UUID,
val createdAt: Instant,
)
data class AppleReauthenticationProof(
val identityToken: String,
val authorizationCode: String,
val nonce: String,
) {
override fun toString(): String =
"AppleReauthenticationProof(identityToken=[REDACTED], " +
"authorizationCode=[REDACTED], nonce=[REDACTED])"
}
fun interface AccountReauthenticator {
/**
* Verifies recent Apple credentials and returns the newly issued Apple
* refresh token so account deletion can revoke the current authorization.
*/
suspend fun verify(account: AccountRecord, proof: AppleReauthenticationProof): String
}
class AppleAccountReauthenticator(
private val identityVerifier: AppleIdentityTokenVerifier,
private val appleTokenClient: AppleTokenClient,
private val identityFingerprint: IdentityFingerprint,
) : AccountReauthenticator {
override suspend fun verify(
account: AccountRecord,
proof: AppleReauthenticationProof,
): String {
requireProofValue(proof.identityToken, "identityToken", 16_384)
requireProofValue(proof.authorizationCode, "authorizationCode", 4_096)
requireProofValue(proof.nonce, "nonce", 512)
val supplied = verifyIdentity(proof.identityToken, proof.nonce)
val exchanged = try {
appleTokenClient.exchangeAuthorizationCode(proof.authorizationCode)
} catch (exception: AppleClientUnavailableException) {
throw ExternalServiceUnavailableException("Apple reauthentication")
} catch (exception: AppleTokenEndpointException) {
if (exception.retryable) throw ExternalServiceUnavailableException("Apple reauthentication")
throw UnauthorizedException("Apple reauthentication failed")
}
val confirmed = verifyIdentity(exchanged.identityToken, proof.nonce)
val fingerprint = identityFingerprint.ofAppleSubject(supplied.subject)
if (supplied.subject != confirmed.subject || fingerprint != account.identityFingerprint) {
throw UnauthorizedException("Apple reauthentication does not match this account")
}
return exchanged.refreshToken
}
private suspend fun verifyIdentity(token: String, nonce: String) =
try {
identityVerifier.verify(token, nonce)
} catch (exception: AppleVerificationUnavailableException) {
throw ExternalServiceUnavailableException("Apple reauthentication")
} catch (exception: AppleTokenInvalidException) {
throw UnauthorizedException("Apple reauthentication failed")
}
private fun requireProofValue(value: String, name: String, maximumLength: Int) {
if (value.isBlank() || value.length > maximumLength) {
throw UnauthorizedException("Apple $name is invalid")
}
}
}
class AccountService(
private val repository: AccountRepository,
private val fieldEncryptor: FieldEncryptor,
private val antiAbuseConfig: AntiAbuseConfig,
private val revocationProcessor: AppleRevocationOutboxProcessor,
private val reauthenticator: AccountReauthenticator,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun get(accountId: UUID): AccountView {
val account = repository.findById(accountId) ?: throw UnauthorizedException()
return AccountView(account.id, account.createdAt)
}
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
val account = repository.findById(accountId) ?: return
val now = clock.instant()
val currentRefreshToken = reauthenticator.verify(account, proof)
val revocationId = UUID.randomUUID()
val revocation = NewAppleRevocation(
id = revocationId,
encryptedRefreshToken = fieldEncryptor.encrypt(
currentRefreshToken,
appleRevocationContext(revocationId),
),
)
repository.deleteById(
accountId = accountId,
deletedAt = now,
tombstoneExpiresAt = now.plus(Duration.ofDays(antiAbuseConfig.tombstoneRetentionDays)),
createRevocation = { revocation },
)
try {
revocationProcessor.processPending(limit = 1)
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
// Local deletion is final. The durable outbox retry loop handles Apple outages.
}
}
}
class AppleRevocationOutboxProcessor(
private val repository: AccountRepository,
private val appleTokenClient: AppleTokenClient,
private val fieldEncryptor: FieldEncryptor,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun processPending(limit: Int = 20) {
require(limit > 0)
repository.pendingAppleRevocations(clock.instant(), limit).forEach { record ->
try {
val refreshToken = fieldEncryptor.decrypt(
record.encryptedRefreshToken,
appleRevocationContext(record.id),
)
appleTokenClient.revokeRefreshToken(refreshToken)
repository.completeAppleRevocation(record.id, clock.instant())
} catch (exception: CancellationException) {
throw exception
} catch (_: AppleClientUnavailableException) {
reschedule(record)
} catch (_: AppleTokenEndpointException) {
reschedule(record)
} catch (_: FieldDecryptionException) {
// Keep the ciphertext for recovery after a key/configuration correction,
// but do not let one poisoned record starve the rest of the batch.
reschedule(record)
}
}
}
private suspend fun reschedule(record: AppleRevocationOutboxRecord) {
val exponent = record.attemptCount.coerceIn(0, MAX_BACKOFF_EXPONENT)
val delaySeconds = BASE_BACKOFF_SECONDS * (1L shl exponent)
repository.rescheduleAppleRevocation(
record.id,
clock.instant().plusSeconds(delaySeconds.coerceAtMost(MAX_BACKOFF_SECONDS)),
)
}
private companion object {
const val BASE_BACKOFF_SECONDS = 30L
const val MAX_BACKOFF_SECONDS = 6 * 60 * 60L
const val MAX_BACKOFF_EXPONENT = 10
}
}
fun appleRevocationContext(id: UUID): String = "apple-revocation-outbox:$id"
@@ -0,0 +1,69 @@
package com.osglab.account.features.appleevents
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.config.AntiAbuseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.account.recordIdentityTombstone
import com.osglab.account.features.auth.AccountsTable
import com.osglab.account.features.auth.withAppleIdentityLock
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import java.time.Duration
import java.time.Instant
internal object AppleEventReceiptsTable : Table("apple_event_receipts") {
val eventId = varchar("event_id", 255)
val eventType = varchar("event_type", 64)
val receivedAt = timestamp("received_at")
override val primaryKey = PrimaryKey(eventId)
}
interface AppleEventRepository {
suspend fun apply(event: VerifiedAppleEvent, receivedAt: Instant): Boolean
}
class ExposedAppleEventRepository(
private val databaseFactory: DatabaseFactory,
private val identityFingerprint: IdentityFingerprint,
private val antiAbuseConfig: AntiAbuseConfig,
) : AppleEventRepository {
override suspend fun apply(event: VerifiedAppleEvent, receivedAt: Instant): Boolean {
if (!isAccountTerminatingAppleEvent(event.type)) {
return databaseFactory.query { insertReceipt(event, receivedAt) }
}
val fingerprint = identityFingerprint.ofAppleSubject(event.appleSubject)
return databaseFactory.withAppleIdentityLock(fingerprint) {
databaseFactory.query {
val inserted = insertReceipt(event, receivedAt)
if (inserted) {
recordIdentityTombstone(
fingerprint,
receivedAt,
receivedAt.plus(Duration.ofDays(antiAbuseConfig.tombstoneRetentionDays)),
)
AccountsTable.deleteWhere { AccountsTable.identityFingerprint eq fingerprint }
}
inserted
}
}
}
private fun insertReceipt(event: VerifiedAppleEvent, receivedAt: Instant): Boolean =
AppleEventReceiptsTable.insertIgnore {
it[AppleEventReceiptsTable.eventId] = event.eventId
it[AppleEventReceiptsTable.eventType] = event.type
it[AppleEventReceiptsTable.receivedAt] = receivedAt
}.insertedCount > 0
}
private val ACCOUNT_TERMINATING_EVENTS = setOf(
"consent-revoked",
"account-delete",
"account-deleted",
)
internal fun isAccountTerminatingAppleEvent(type: String): Boolean =
type in ACCOUNT_TERMINATING_EVENTS
@@ -0,0 +1,50 @@
package com.osglab.account.features.appleevents
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.features.auth.AppleVerificationUnavailableException
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import kotlinx.serialization.Serializable
import java.time.Clock
@Serializable
data class AppleEventRequest(val payload: String) {
override fun toString(): String = "AppleEventRequest(payload=[REDACTED])"
}
class AppleEventService(
private val verifier: AppleEventVerifier,
private val repository: AppleEventRepository,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun receive(signedPayload: String) {
val event = try {
verifier.verify(signedPayload)
} catch (exception: AppleVerificationUnavailableException) {
throw ExternalServiceUnavailableException("Apple event verification")
} catch (exception: InvalidAppleEventException) {
throw UnauthorizedException("Apple event signature is invalid")
}
repository.apply(event, clock.instant())
}
}
class AppleEventRoutes(
private val service: AppleEventService,
) {
fun register(parent: Route) {
with(parent) {
post("/v1/apple/events") {
service.receive(call.receive<AppleEventRequest>().payload)
call.respond(HttpStatusCode.NoContent)
}
}
}
}
fun Route.appleEventRoutes(service: AppleEventService) =
AppleEventRoutes(service).register(this)
@@ -0,0 +1,99 @@
package com.osglab.account.features.appleevents
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.crypto.RSASSAVerifier
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import com.osglab.account.features.auth.AppleJwksProvider
import com.osglab.account.features.auth.isSuitableAppleSigningKey
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.time.Clock
import java.time.Duration
data class VerifiedAppleEvent(
val eventId: String,
val type: String,
val appleSubject: String,
) {
override fun toString(): String =
"VerifiedAppleEvent(eventId=$eventId, type=$type, appleSubject=[REDACTED])"
}
class AppleEventVerifier(
private val config: AppleConfig,
private val jwksProvider: AppleJwksProvider,
private val clock: Clock = Clock.systemUTC(),
private val json: Json = Json,
) {
suspend fun verify(signedPayload: String): VerifiedAppleEvent {
if (signedPayload.isBlank() || signedPayload.length > MAX_SIGNED_PAYLOAD_LENGTH) {
throw InvalidAppleEventException("Apple event has an invalid size")
}
val jwt = runCatching { SignedJWT.parse(signedPayload) }
.getOrElse { throw InvalidAppleEventException("Malformed Apple event", it) }
if (jwt.header.algorithm != JWSAlgorithm.RS256) {
throw InvalidAppleEventException("Apple event must use RS256")
}
val keyId = jwt.header.keyID?.takeIf(String::isNotBlank)
?: throw InvalidAppleEventException("Apple event is missing kid")
val key = jwksProvider.rsaKey(keyId)
?: throw InvalidAppleEventException("Apple event used an unknown key")
if (!key.isSuitableAppleSigningKey(keyId)) {
throw InvalidAppleEventException("Apple event used an unsuitable key")
}
if (!runCatching { jwt.verify(RSASSAVerifier(key.toRSAPublicKey())) }.getOrDefault(false)) {
throw InvalidAppleEventException("Apple event signature is invalid")
}
val claims = runCatching { jwt.jwtClaimsSet }
.getOrElse { throw InvalidAppleEventException("Apple event claims are invalid", it) }
val now = clock.instant()
if (claims.issuer != APPLE_ISSUER || claims.audience != listOf(config.clientId)) {
throw InvalidAppleEventException("Apple event issuer or audience is invalid")
}
val expiresAt = claims.expirationTime?.toInstant()
if (expiresAt?.isAfter(now.minus(CLOCK_SKEW)) != true) {
throw InvalidAppleEventException("Apple event has expired")
}
val issuedAt = claims.issueTime?.toInstant()
?: throw InvalidAppleEventException("Apple event is missing iat")
if (issuedAt.isAfter(now.plus(CLOCK_SKEW)) ||
issuedAt.isBefore(now.minus(MAX_EVENT_AGE)) ||
!expiresAt.isAfter(issuedAt)
) {
throw InvalidAppleEventException("Apple event is outside the accepted time window")
}
val eventId = claims.jwtid?.takeIf { it.isNotBlank() && it.length <= MAX_EVENT_ID_LENGTH }
?: throw InvalidAppleEventException("Apple event is missing jti")
val rawEvents = runCatching { claims.getStringClaim(EVENTS_CLAIM) }
.getOrElse { throw InvalidAppleEventException("Apple events claim is invalid", it) }
?.takeIf { it.isNotBlank() && it.length <= MAX_EVENTS_CLAIM_LENGTH }
?: throw InvalidAppleEventException("Apple event is missing or oversized events")
val events = runCatching { json.parseToJsonElement(rawEvents).jsonObject }
.getOrElse { throw InvalidAppleEventException("Apple events claim is invalid", it) }
val type = runCatching { events["type"]?.jsonPrimitive?.content }
.getOrElse { throw InvalidAppleEventException("Apple event type is invalid", it) }
?.takeIf { it.isNotBlank() && it.length <= MAX_EVENT_TYPE_LENGTH }
?: throw InvalidAppleEventException("Apple event type is missing")
val subject = runCatching { events["sub"]?.jsonPrimitive?.content }
.getOrElse { throw InvalidAppleEventException("Apple event subject is invalid", it) }
?.takeIf { it.isNotBlank() && it.length <= MAX_SUBJECT_LENGTH }
?: throw InvalidAppleEventException("Apple event subject is missing")
return VerifiedAppleEvent(eventId, type, subject)
}
}
class InvalidAppleEventException(message: String, cause: Throwable? = null) :
SecurityException(message, cause)
private const val APPLE_ISSUER = "https://appleid.apple.com"
private const val EVENTS_CLAIM = "events"
private const val MAX_EVENT_ID_LENGTH = 255
private const val MAX_EVENT_TYPE_LENGTH = 64
private const val MAX_SUBJECT_LENGTH = 128
private const val MAX_SIGNED_PAYLOAD_LENGTH = 16_384
private const val MAX_EVENTS_CLAIM_LENGTH = 4_096
private val MAX_EVENT_AGE: Duration = Duration.ofHours(24)
private val CLOCK_SKEW: Duration = Duration.ofSeconds(30)
@@ -0,0 +1,191 @@
package com.osglab.account.features.auth
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.crypto.RSASSAVerifier
import com.nimbusds.jose.jwk.JWKSet
import com.nimbusds.jose.jwk.KeyOperation
import com.nimbusds.jose.jwk.KeyUse
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.math.BigInteger
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
data class AppleIdentity(
val subject: String,
) {
override fun toString(): String = "AppleIdentity(subject=[REDACTED])"
}
interface AppleJwksProvider {
suspend fun rsaKey(keyId: String): RSAKey?
}
class RemoteAppleJwksProvider(
private val httpClient: HttpClient,
private val jwksUrl: String,
private val clock: Clock = Clock.systemUTC(),
private val cacheTtl: Duration = Duration.ofHours(6),
) : AppleJwksProvider {
private val mutex = Mutex()
private var cached: CachedJwks? = null
init {
require(!cacheTtl.isZero && !cacheTtl.isNegative && cacheTtl <= MAX_JWKS_CACHE_TTL) {
"Apple JWKS cache TTL must be between zero and 24 hours"
}
}
override suspend fun rsaKey(keyId: String): RSAKey? = mutex.withLock {
require(keyId.isNotBlank()) { "Apple key ID must not be blank" }
val nowMillis = clock.millis()
val current = cached
if (current != null && current.expiresAtMillis > nowMillis) {
val cachedKey = current.keys.getKeyByKeyId(keyId) as? RSAKey
if (cachedKey != null) return@withLock cachedKey
if (nowMillis - current.fetchedAtMillis < KEY_MISS_REFRESH_INTERVAL.toMillis()) {
return@withLock null
}
}
val response = runCatching { httpClient.get(jwksUrl) }
.getOrElse { throw AppleVerificationUnavailableException("Apple JWKS request failed", it) }
if (!response.status.isSuccess()) {
throw AppleVerificationUnavailableException("Apple JWKS returned HTTP ${response.status.value}")
}
val keys = runCatching { JWKSet.parse(response.bodyAsText()) }
.getOrElse { throw AppleVerificationUnavailableException("Apple JWKS response was invalid", it) }
val keyIds = keys.keys.map { it.keyID }
if (keys.keys.isEmpty() ||
keys.keys.size > MAX_JWK_COUNT ||
keyIds.any { it.isNullOrBlank() } ||
keyIds.distinct().size != keyIds.size
) {
throw AppleVerificationUnavailableException("Apple JWKS response contained invalid keys")
}
cached = CachedJwks(
keys = keys,
fetchedAtMillis = nowMillis,
expiresAtMillis = nowMillis + cacheTtl.toMillis(),
)
keys.getKeyByKeyId(keyId) as? RSAKey
}
private data class CachedJwks(
val keys: JWKSet,
val fetchedAtMillis: Long,
val expiresAtMillis: Long,
)
}
class AppleIdentityTokenVerifier(
private val config: AppleConfig,
private val jwksProvider: AppleJwksProvider,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun verify(identityToken: String, expectedNonce: String): AppleIdentity {
if (identityToken.isBlank() || identityToken.length > MAX_IDENTITY_TOKEN_LENGTH) {
throw AppleTokenInvalidException("Apple identity token has an invalid size")
}
if (expectedNonce.isBlank() || expectedNonce.length > MAX_NONCE_LENGTH) {
throw AppleTokenInvalidException("Expected nonce has an invalid size")
}
val jwt = runCatching { SignedJWT.parse(identityToken) }
.getOrElse { throw AppleTokenInvalidException("Malformed Apple identity token", it) }
if (jwt.header.algorithm != JWSAlgorithm.RS256) {
throw AppleTokenInvalidException("Apple identity token must use RS256")
}
val keyId = jwt.header.keyID?.takeIf(String::isNotBlank)
?: throw AppleTokenInvalidException("Apple identity token is missing kid")
val key = jwksProvider.rsaKey(keyId)
?: throw AppleTokenInvalidException("Apple identity token used an unknown key")
if (!key.isSuitableAppleSigningKey(keyId)) {
throw AppleTokenInvalidException("Apple identity token used an unsuitable key")
}
if (!runCatching { jwt.verify(RSASSAVerifier(key.toRSAPublicKey())) }.getOrDefault(false)) {
throw AppleTokenInvalidException("Apple identity token signature is invalid")
}
val claims = runCatching { jwt.jwtClaimsSet }
.getOrElse { throw AppleTokenInvalidException("Apple identity claims are invalid", it) }
val now = clock.instant()
if (claims.issuer != APPLE_ISSUER || claims.audience != listOf(config.clientId)) {
throw AppleTokenInvalidException("Apple identity token issuer or audience is invalid")
}
val expiresAt = claims.expirationTime?.toInstant()
if (expiresAt?.isAfter(now.minusSeconds(CLOCK_SKEW_SECONDS)) != true) {
throw AppleTokenInvalidException("Apple identity token has expired")
}
val issuedAt = claims.issueTime?.toInstant()
?: throw AppleTokenInvalidException("Apple identity token is missing iat")
if (issuedAt.isAfter(now.plusSeconds(CLOCK_SKEW_SECONDS)) || !expiresAt.isAfter(issuedAt)) {
throw AppleTokenInvalidException("Apple identity token time claims are invalid")
}
val actualNonce = runCatching { claims.getStringClaim(NONCE_CLAIM) }
.getOrElse { throw AppleTokenInvalidException("Apple identity token nonce is invalid", it) }
?: throw AppleTokenInvalidException("Apple identity token is missing nonce")
if (!AppleNonceVerifier.matches(expectedNonce, actualNonce)) {
throw AppleTokenInvalidException("Apple identity token nonce is invalid")
}
val subject = claims.subject?.takeIf { it.isNotBlank() && it.length <= MAX_SUBJECT_LENGTH }
?: throw AppleTokenInvalidException("Apple identity token is missing sub")
return AppleIdentity(subject)
}
}
internal fun RSAKey.isSuitableAppleSigningKey(expectedKeyId: String): Boolean = runCatching {
val operations = keyOperations
keyID == expectedKeyId &&
(algorithm == null || algorithm == JWSAlgorithm.RS256) &&
(keyUse == null || keyUse == KeyUse.SIGNATURE) &&
(operations.isNullOrEmpty() || KeyOperation.VERIFY in operations) &&
toRSAPublicKey().let { publicKey ->
publicKey.modulus.bitLength() >= MIN_RSA_KEY_BITS &&
publicKey.publicExponent >= MIN_RSA_PUBLIC_EXPONENT &&
publicKey.publicExponent.testBit(0)
}
}.getOrDefault(false)
/**
* Apple receives SHA-256(raw nonce) from the client. The server receives the
* original nonce and compares only its digest with the signed claim.
*/
internal object AppleNonceVerifier {
fun matches(rawNonce: String, signedClaim: String): Boolean {
if (rawNonce.isBlank() || !signedClaim.matches(SHA256_HEX)) return false
val expected = MessageDigest.getInstance("SHA-256")
.digest(rawNonce.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
return MessageDigest.isEqual(
expected.toByteArray(Charsets.US_ASCII),
signedClaim.lowercase().toByteArray(Charsets.US_ASCII),
)
}
}
class AppleTokenInvalidException(message: String, cause: Throwable? = null) :
SecurityException(message, cause)
class AppleVerificationUnavailableException(message: String, cause: Throwable? = null) :
IllegalStateException(message, cause)
private const val APPLE_ISSUER = "https://appleid.apple.com"
private const val NONCE_CLAIM = "nonce"
private const val MAX_SUBJECT_LENGTH = 128
private const val MIN_RSA_KEY_BITS = 2048
private const val MAX_IDENTITY_TOKEN_LENGTH = 16_384
private const val MAX_NONCE_LENGTH = 256
private const val MAX_JWK_COUNT = 20
private const val CLOCK_SKEW_SECONDS = 30L
private val SHA256_HEX = Regex("[A-Fa-f0-9]{64}")
private val KEY_MISS_REFRESH_INTERVAL: Duration = Duration.ofMinutes(1)
private val MAX_JWKS_CACHE_TTL: Duration = Duration.ofHours(24)
private val MIN_RSA_PUBLIC_EXPONENT: BigInteger = BigInteger.valueOf(65_537)
@@ -0,0 +1,243 @@
package com.osglab.account.features.auth
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.ECDSASigner
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import io.ktor.client.HttpClient
import io.ktor.client.plugins.timeout
import io.ktor.client.request.forms.submitForm
import io.ktor.client.statement.bodyAsText
import io.ktor.http.Parameters
import io.ktor.http.isSuccess
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.interfaces.ECPrivateKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.ECParameterSpec
import java.security.spec.PKCS8EncodedKeySpec
import java.time.Clock
import java.time.Duration
import java.util.Base64
import java.util.Date
data class AppleTokenExchange(
val refreshToken: String,
val identityToken: String,
) {
override fun toString(): String =
"AppleTokenExchange(refreshToken=[REDACTED], identityToken=[REDACTED])"
}
interface AppleTokenClient {
suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange
suspend fun revokeRefreshToken(refreshToken: String)
}
fun interface AppleClientSecretSigner {
fun create(): String
}
fun createAppleTokenClient(httpClient: HttpClient, config: AppleConfig): AppleTokenClient =
if (config.clientCredentialsAvailable) {
HttpAppleTokenClient(httpClient, config, AppleClientSecretProvider(config))
} else {
UnavailableAppleTokenClient()
}
class HttpAppleTokenClient(
private val httpClient: HttpClient,
private val config: AppleConfig,
private val clientSecretProvider: AppleClientSecretSigner,
private val json: Json = Json { ignoreUnknownKeys = true },
private val requestTimeoutMillis: Long = APPLE_REQUEST_TIMEOUT_MILLIS,
) : AppleTokenClient {
init {
require(requestTimeoutMillis > 0) { "Apple request timeout must be positive" }
}
override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange {
requireSecretSize(code, "authorization code", MAX_AUTHORIZATION_CODE_LENGTH)
val response = request(
url = config.tokenUrl,
parameters = Parameters.build {
append("client_id", config.clientId)
append("client_secret", clientSecretProvider.create())
append("code", code)
append("grant_type", "authorization_code")
},
)
val payload = runCatching { json.decodeFromString<AppleTokenResponse>(response.body) }
.getOrNull()
if (!response.success || payload?.error != null) {
throw AppleTokenEndpointException(
"Apple rejected the authorization code",
retryable = response.status.isRetryableAppleStatus(),
)
}
if (payload == null) {
throw AppleTokenEndpointException("Apple token response was invalid", true)
}
val identityToken = payload.identityToken
?.takeIf { it.isNotBlank() && it.length <= MAX_IDENTITY_TOKEN_LENGTH }
?: throw AppleTokenEndpointException("Apple token response omitted a valid id_token", false)
val refreshToken = payload.refreshToken
?.takeIf { it.isNotBlank() && it.length <= MAX_APPLE_REFRESH_TOKEN_LENGTH }
?: throw AppleTokenEndpointException("Apple token response omitted a valid refresh_token", false)
return AppleTokenExchange(refreshToken, identityToken)
}
override suspend fun revokeRefreshToken(refreshToken: String) {
requireSecretSize(refreshToken, "Apple refresh token", MAX_APPLE_REFRESH_TOKEN_LENGTH)
val response = request(
url = config.revokeUrl,
parameters = Parameters.build {
append("client_id", config.clientId)
append("client_secret", clientSecretProvider.create())
append("token", refreshToken)
append("token_type_hint", "refresh_token")
},
)
if (!response.success) {
throw AppleTokenEndpointException(
"Apple token revocation failed",
retryable = response.status.isRetryableAppleStatus(),
)
}
}
private fun requireSecretSize(value: String, label: String, maximumLength: Int) {
if (value.isBlank() || value.length > maximumLength) {
throw AppleTokenEndpointException("$label has an invalid size", false)
}
}
private suspend fun request(url: String, parameters: Parameters): AppleHttpResponse {
val response = runCatching {
httpClient.submitForm(url = url, formParameters = parameters) {
timeout {
connectTimeoutMillis = requestTimeoutMillis
requestTimeoutMillis = requestTimeoutMillis
socketTimeoutMillis = requestTimeoutMillis
}
}
}
.getOrElse { throw AppleTokenEndpointException("Apple token endpoint is unavailable", true, it) }
return AppleHttpResponse(
success = response.status.isSuccess(),
status = response.status.value,
body = response.bodyAsText(),
)
}
private data class AppleHttpResponse(
val success: Boolean,
val status: Int,
val body: String,
)
}
class AppleClientSecretProvider(
private val config: AppleConfig,
private val clock: Clock = Clock.systemUTC(),
) : AppleClientSecretSigner {
private val privateKey: ECPrivateKey by lazy(::loadPrivateKey)
override fun create(): String {
val teamId = config.teamId?.takeIf(String::isNotBlank)
?: throw AppleClientUnavailableException()
val keyId = config.keyId?.takeIf(String::isNotBlank)
?: throw AppleClientUnavailableException()
if (config.clientId.isBlank()) throw AppleClientUnavailableException()
if (config.privateKeyPem == null) throw AppleClientUnavailableException()
val now = clock.instant()
val claims = JWTClaimsSet.Builder()
.issuer(teamId)
.subject(config.clientId)
.audience(APPLE_ISSUER)
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plus(CLIENT_SECRET_LIFETIME)))
.build()
val jwt = SignedJWT(
JWSHeader.Builder(JWSAlgorithm.ES256).keyID(keyId).build(),
claims,
)
jwt.sign(ECDSASigner(privateKey))
return jwt.serialize()
}
private fun loadPrivateKey(): ECPrivateKey {
val pem = config.privateKeyPem?.trim() ?: throw AppleClientUnavailableException()
if (!pem.startsWith(PKCS8_PEM_BEGIN) || !pem.endsWith(PKCS8_PEM_END)) {
throw AppleClientUnavailableException("Apple private key must be PKCS#8 PEM")
}
val encoded = pem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(Regex("\\s"), "")
return runCatching {
val key = KeyFactory.getInstance("EC")
.generatePrivate(PKCS8EncodedKeySpec(Base64.getDecoder().decode(encoded))) as ECPrivateKey
requireP256(key)
key
}.getOrElse {
throw AppleClientUnavailableException("Apple private key is invalid", it)
}
}
private fun requireP256(key: ECPrivateKey) {
val expected = AlgorithmParameters.getInstance("EC").run {
init(ECGenParameterSpec("secp256r1"))
getParameterSpec(ECParameterSpec::class.java)
}
require(key.params.curve == expected.curve &&
key.params.generator == expected.generator &&
key.params.order == expected.order &&
key.params.cofactor == expected.cofactor
) {
"Apple private key must use P-256"
}
}
}
class UnavailableAppleTokenClient(
private val reason: String = "Apple client credentials are not configured",
) : AppleTokenClient {
override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange =
throw AppleClientUnavailableException(reason)
override suspend fun revokeRefreshToken(refreshToken: String): Unit =
throw AppleClientUnavailableException(reason)
}
class AppleClientUnavailableException(message: String = "Apple token client is unavailable", cause: Throwable? = null) :
IllegalStateException(message, cause)
class AppleTokenEndpointException(
message: String,
val retryable: Boolean,
cause: Throwable? = null,
) : IllegalStateException(message, cause)
@Serializable
private data class AppleTokenResponse(
@SerialName("refresh_token") val refreshToken: String? = null,
@SerialName("id_token") val identityToken: String? = null,
val error: String? = null,
)
private fun Int.isRetryableAppleStatus(): Boolean = this == 408 || this == 429 || this >= 500
private const val APPLE_ISSUER = "https://appleid.apple.com"
private const val APPLE_REQUEST_TIMEOUT_MILLIS = 10_000L
private const val MAX_AUTHORIZATION_CODE_LENGTH = 2_048
private const val MAX_APPLE_REFRESH_TOKEN_LENGTH = 4_096
private const val MAX_IDENTITY_TOKEN_LENGTH = 16_384
private const val PKCS8_PEM_BEGIN = "-----BEGIN PRIVATE KEY-----"
private const val PKCS8_PEM_END = "-----END PRIVATE KEY-----"
private val CLIENT_SECRET_LIFETIME: Duration = Duration.ofMinutes(5)
@@ -0,0 +1,346 @@
package com.osglab.account.features.auth
import com.osglab.account.config.DatabaseFactory
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
import java.util.UUID
internal object AccountsTable : Table("accounts") {
val id = varchar("id", 36)
// The legacy column name is retained by V1, but its value is always AES-GCM ciphertext.
val encryptedAppleSubject = varchar("apple_sub", 255).uniqueIndex()
val identityFingerprint = char("identity_fingerprint", 64).nullable().uniqueIndex()
val antiAbuseRestricted = bool("anti_abuse_restricted")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
internal object AppleCredentialsTable : Table("apple_credentials") {
val accountId = varchar("account_id", 36)
val encryptedRefreshToken = text("encrypted_refresh_token")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(accountId)
}
internal object AccountIdentityTombstonesTable : Table("account_identity_tombstones") {
val identityFingerprint = char("identity_fingerprint", 64)
val deletedAt = timestamp("deleted_at")
val expiresAt = timestamp("expires_at")
override val primaryKey = PrimaryKey(identityFingerprint)
}
internal object SessionsTable : Table("sessions") {
val id = varchar("id", 36)
val accountId = varchar("account_id", 36).index()
val familyId = varchar("family_id", 36).index()
val refreshTokenHash = varchar("refresh_token_hash", 64).uniqueIndex()
val replacedById = varchar("replaced_by_id", 36).nullable()
val createdAt = timestamp("created_at")
val expiresAt = timestamp("expires_at")
val revokedAt = timestamp("revoked_at").nullable()
val reuseDetectedAt = timestamp("reuse_detected_at").nullable()
override val primaryKey = PrimaryKey(id)
}
data class AuthAccount(
val id: UUID,
val identityFingerprint: String,
val antiAbuseRestricted: Boolean,
)
data class CreatedSession(
val accountId: UUID,
val sessionId: UUID,
val familyId: UUID,
)
sealed interface RefreshRotationResult {
data class Rotated(
val accountId: UUID,
val sessionId: UUID,
val familyId: UUID,
) : RefreshRotationResult
data object Invalid : RefreshRotationResult
data object ReuseDetected : RefreshRotationResult
}
internal enum class RefreshRotationDecision {
ROTATE,
REVOKE_EXPIRED,
REVOKE_REUSED_FAMILY,
}
/**
* Keeps the security-sensitive refresh state transition independent from SQL,
* so every repository implementation applies the same replay policy.
*/
internal object RefreshRotationPolicy {
fun decide(
revoked: Boolean,
replaced: Boolean,
expiresAt: Instant,
now: Instant,
): RefreshRotationDecision = when {
revoked || replaced -> RefreshRotationDecision.REVOKE_REUSED_FAMILY
!expiresAt.isAfter(now) -> RefreshRotationDecision.REVOKE_EXPIRED
else -> RefreshRotationDecision.ROTATE
}
}
interface AuthRepository {
suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount
suspend fun updateAppleRefreshToken(accountId: UUID, encryptedToken: String, now: Instant)
suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession
suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult
suspend fun revokeSessionFamily(accountId: UUID, sessionId: UUID, now: Instant): Boolean
suspend fun isSessionActive(accountId: UUID, sessionId: UUID, now: Instant): Boolean
suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant)
}
class ExposedAuthRepository(
private val databaseFactory: DatabaseFactory,
) : AuthRepository {
override suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount =
databaseFactory.withAppleIdentityLock(identityFingerprint) {
databaseFactory.query {
val restricted = AccountIdentityTombstonesTable.selectAll()
.where {
(AccountIdentityTombstonesTable.identityFingerprint eq identityFingerprint) and
(AccountIdentityTombstonesTable.expiresAt greater now)
}
.singleOrNull() != null
val id = UUID.randomUUID()
AccountsTable.insertIgnore {
it[AccountsTable.id] = id.toString()
it[AccountsTable.encryptedAppleSubject] = encryptedAppleSubject
it[AccountsTable.identityFingerprint] = identityFingerprint
it[AccountsTable.antiAbuseRestricted] = restricted
it[AccountsTable.createdAt] = now
it[AccountsTable.updatedAt] = now
}
AccountsTable.selectAll()
.where { AccountsTable.identityFingerprint eq identityFingerprint }
.single()
.toAuthAccount()
}
}
override suspend fun updateAppleRefreshToken(
accountId: UUID,
encryptedToken: String,
now: Instant,
) {
val fingerprint = databaseFactory.query {
AccountsTable.selectAll()
.where { AccountsTable.id eq accountId.toString() }
.singleOrNull()
?.get(AccountsTable.identityFingerprint)
} ?: return
databaseFactory.withAppleIdentityLock(fingerprint) {
databaseFactory.query {
val accountStillExists = AccountsTable.selectAll()
.where {
(AccountsTable.id eq accountId.toString()) and
(AccountsTable.identityFingerprint eq fingerprint)
}
.limit(1)
.singleOrNull() != null
check(accountStillExists) { "Account was deleted during Apple sign-in" }
AppleCredentialsTable.insertIgnore {
it[AppleCredentialsTable.accountId] = accountId.toString()
it[AppleCredentialsTable.encryptedRefreshToken] = encryptedToken
it[AppleCredentialsTable.createdAt] = now
it[AppleCredentialsTable.updatedAt] = now
}
AppleCredentialsTable.update({
AppleCredentialsTable.accountId eq accountId.toString()
}) {
it[encryptedRefreshToken] = encryptedToken
it[updatedAt] = now
}
}
}
}
override suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession = databaseFactory.query {
val sessionId = UUID.randomUUID()
val familyId = sessionId
SessionsTable.insert {
it[SessionsTable.id] = sessionId.toString()
it[SessionsTable.accountId] = accountId.toString()
it[SessionsTable.familyId] = familyId.toString()
it[SessionsTable.refreshTokenHash] = refreshTokenHash
it[SessionsTable.createdAt] = now
it[SessionsTable.expiresAt] = expiresAt
}
CreatedSession(accountId, sessionId, familyId)
}
override suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult = databaseFactory.query {
val current = SessionsTable.selectAll()
.where { SessionsTable.refreshTokenHash eq currentTokenHash }
.forUpdate()
.singleOrNull()
?: return@query RefreshRotationResult.Invalid
val familyId = current[SessionsTable.familyId]
when (
RefreshRotationPolicy.decide(
revoked = current[SessionsTable.revokedAt] != null,
replaced = current[SessionsTable.replacedById] != null,
expiresAt = current[SessionsTable.expiresAt],
now = now,
)
) {
RefreshRotationDecision.REVOKE_REUSED_FAMILY -> {
SessionsTable.update({ SessionsTable.familyId eq familyId }) {
it[SessionsTable.revokedAt] = now
}
SessionsTable.update({ SessionsTable.id eq current[SessionsTable.id] }) {
it[SessionsTable.reuseDetectedAt] = now
}
return@query RefreshRotationResult.ReuseDetected
}
RefreshRotationDecision.REVOKE_EXPIRED -> {
SessionsTable.update({ SessionsTable.familyId eq familyId }) {
it[SessionsTable.revokedAt] = now
}
return@query RefreshRotationResult.Invalid
}
RefreshRotationDecision.ROTATE -> Unit
}
val newSessionId = UUID.randomUUID()
SessionsTable.insert {
it[SessionsTable.id] = newSessionId.toString()
it[SessionsTable.accountId] = current[SessionsTable.accountId]
it[SessionsTable.familyId] = familyId
it[SessionsTable.refreshTokenHash] = newTokenHash
it[SessionsTable.createdAt] = now
it[SessionsTable.expiresAt] = newExpiresAt
}
SessionsTable.update({ SessionsTable.id eq current[SessionsTable.id] }) {
it[SessionsTable.replacedById] = newSessionId.toString()
it[SessionsTable.revokedAt] = now
}
RefreshRotationResult.Rotated(
accountId = UUID.fromString(current[SessionsTable.accountId]),
sessionId = newSessionId,
familyId = UUID.fromString(familyId),
)
}
override suspend fun revokeSessionFamily(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = databaseFactory.query {
val session = SessionsTable.selectAll()
.where {
(SessionsTable.id eq sessionId.toString()) and
(SessionsTable.accountId eq accountId.toString())
}
.forUpdate()
.singleOrNull()
?: return@query false
SessionsTable.update({
(SessionsTable.accountId eq accountId.toString()) and
(SessionsTable.familyId eq session[SessionsTable.familyId])
}) {
it[SessionsTable.revokedAt] = now
} > 0
}
override suspend fun isSessionActive(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = databaseFactory.query {
val accountExists = AccountsTable.selectAll()
.where { AccountsTable.id eq accountId.toString() }
.limit(1)
.singleOrNull() != null
accountExists && SessionsTable.selectAll()
.where {
(SessionsTable.id eq sessionId.toString()) and
(SessionsTable.accountId eq accountId.toString()) and
SessionsTable.revokedAt.isNull() and
(SessionsTable.expiresAt greater now)
}
.limit(1)
.singleOrNull() != null
}
override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) {
databaseFactory.query {
AccountsTable.update({ AccountsTable.id eq accountId.toString() }) {
it[antiAbuseRestricted] = true
it[updatedAt] = now
}
}
}
}
private fun ResultRow.toAuthAccount(): AuthAccount = AuthAccount(
id = UUID.fromString(this[AccountsTable.id]),
identityFingerprint = requireNotNull(this[AccountsTable.identityFingerprint]),
antiAbuseRestricted = this[AccountsTable.antiAbuseRestricted],
)
internal suspend fun <T> DatabaseFactory.withAppleIdentityLock(
identityFingerprint: String,
block: suspend () -> T,
): T {
require(identityFingerprint.length == IDENTITY_FINGERPRINT_LENGTH)
return withMysqlNamedLock(
"apple-id:${identityFingerprint.take(IDENTITY_LOCK_FINGERPRINT_LENGTH)}",
IDENTITY_LOCK_TIMEOUT_SECONDS,
block,
)
}
private const val IDENTITY_FINGERPRINT_LENGTH = 64
private const val IDENTITY_LOCK_FINGERPRINT_LENGTH = 55
private const val IDENTITY_LOCK_TIMEOUT_SECONDS = 10
@@ -0,0 +1,118 @@
package com.osglab.account.features.auth
import com.osglab.account.common.api.ApiResponse
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.SESSION_AUTH_NAME
import com.osglab.account.features.integrity.AppAttestEvidence
import com.osglab.account.features.integrity.IntegrityEvidence
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
@Serializable
data class AppleSignInRequest(
val identityToken: String,
val authorizationCode: String,
val nonce: String,
val deviceCheckToken: String? = null,
val appAttest: AppAttestRequest? = null,
) {
override fun toString(): String =
"AppleSignInRequest(identityToken=[REDACTED], authorizationCode=[REDACTED], " +
"nonce=[REDACTED], deviceCheckToken=[REDACTED], appAttest=[REDACTED])"
}
@Serializable
data class AppAttestRequest(
val keyId: String,
val challengeId: String,
val challenge: String,
val assertion: String,
) {
override fun toString(): String =
"AppAttestRequest(keyId=[REDACTED], challengeId=[REDACTED], " +
"challenge=[REDACTED], assertion=[REDACTED])"
}
@Serializable
data class RefreshSessionRequest(val refreshToken: String) {
override fun toString(): String = "RefreshSessionRequest(refreshToken=[REDACTED])"
}
@Serializable
data class SessionTokenResponse(
val accountId: String,
val tokenType: String = "Bearer",
val accessToken: String,
val accessTokenExpiresAtEpochSeconds: Long,
val refreshToken: String,
val refreshTokenExpiresAtEpochSeconds: Long,
) {
override fun toString(): String =
"SessionTokenResponse(accountId=$accountId, tokenType=$tokenType, " +
"accessToken=[REDACTED], accessTokenExpiresAtEpochSeconds=$accessTokenExpiresAtEpochSeconds, " +
"refreshToken=[REDACTED], refreshTokenExpiresAtEpochSeconds=$refreshTokenExpiresAtEpochSeconds)"
}
class AuthRoutes(
private val sessionService: SessionService,
) {
fun register(parent: Route) {
with(parent) {
route("/v1/auth") {
post("/apple") {
val request = call.receive<AppleSignInRequest>()
val tokens = sessionService.signInWithApple(
identityToken = request.identityToken,
authorizationCode = request.authorizationCode,
nonce = request.nonce,
integrityEvidence = IntegrityEvidence(
deviceCheckToken = request.deviceCheckToken,
appAttest = request.appAttest?.let {
AppAttestEvidence(
keyId = it.keyId,
challengeId = it.challengeId,
assertion = it.assertion,
challenge = it.challenge,
)
},
),
)
call.respond(ApiResponse(data = tokens.toResponse()))
}
post("/refresh") {
val request = call.receive<RefreshSessionRequest>()
call.respond(
ApiResponse(data = sessionService.refresh(request.refreshToken).toResponse()),
)
}
authenticate(SESSION_AUTH_NAME) {
post("/logout") {
val principal = call.principal<AccountPrincipal>()
?: throw UnauthorizedException()
sessionService.logout(principal)
call.respond(HttpStatusCode.NoContent)
}
}
}
}
}
}
fun Route.authRoutes(sessionService: SessionService) =
AuthRoutes(sessionService).register(this)
private fun SessionTokens.toResponse(): SessionTokenResponse = SessionTokenResponse(
accountId = accountId.toString(),
accessToken = accessToken,
accessTokenExpiresAtEpochSeconds = accessTokenExpiresAt.epochSecond,
refreshToken = refreshToken,
refreshTokenExpiresAtEpochSeconds = refreshTokenExpiresAt.epochSecond,
)
@@ -0,0 +1,23 @@
package com.osglab.account.features.auth
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.SessionJwt
import java.time.Clock
/**
* Access tokens are accepted only while their account and refresh-token family
* still exist and remain active. This makes logout, replay response and account
* deletion immediately effective for every authenticated request.
*/
class SessionAccessAuthenticator(
private val sessionJwt: SessionJwt,
private val repository: AuthRepository,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun authenticate(serialized: String): AccountPrincipal? {
val principal = sessionJwt.verify(serialized) ?: return null
return principal.takeIf {
repository.isSessionActive(it.userId, it.sessionId, clock.instant())
}
}
}
@@ -0,0 +1,185 @@
package com.osglab.account.features.auth
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.common.errors.TokenReuseException
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.common.security.SecureTokenGenerator
import com.osglab.account.common.security.Sha256SecureTokenGenerator
import com.osglab.account.common.security.SessionJwt
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.TokenHash
import com.osglab.account.config.SessionConfig
import com.osglab.account.features.integrity.AppleSignInIntegrityPayload
import com.osglab.account.features.integrity.IntegrityEvidence
import com.osglab.account.features.integrity.IntegrityService
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.UUID
data class SessionTokens(
val accountId: UUID,
val accessToken: String,
val accessTokenExpiresAt: Instant,
val refreshToken: String,
val refreshTokenExpiresAt: Instant,
) {
override fun toString(): String =
"SessionTokens(accountId=$accountId, accessToken=[REDACTED], " +
"accessTokenExpiresAt=$accessTokenExpiresAt, refreshToken=[REDACTED], " +
"refreshTokenExpiresAt=$refreshTokenExpiresAt)"
}
fun interface AccountProvisioner {
suspend fun provision(accountId: UUID, deviceCheckToken: String?)
}
class SessionService(
private val repository: AuthRepository,
private val appleIdentityVerifier: AppleIdentityTokenVerifier,
private val appleTokenClient: AppleTokenClient,
private val integrityService: IntegrityService,
private val sessionJwt: SessionJwt,
private val fieldEncryptor: FieldEncryptor,
private val identityFingerprint: IdentityFingerprint,
private val sessionConfig: SessionConfig,
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _ -> },
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun signInWithApple(
identityToken: String,
authorizationCode: String,
nonce: String,
integrityEvidence: IntegrityEvidence,
): SessionTokens {
requireValue(identityToken, "identityToken", MAX_IDENTITY_TOKEN_LENGTH)
requireValue(authorizationCode, "authorizationCode", MAX_AUTHORIZATION_CODE_LENGTH)
requireValue(nonce, "nonce", MAX_NONCE_LENGTH)
val verifiedIntegrity = integrityService.verifyAppleSignIn(
integrityEvidence,
AppleSignInIntegrityPayload(identityToken, authorizationCode, nonce),
)
val suppliedIdentity = verifyIdentityToken(identityToken, nonce)
val exchange = exchangeCode(authorizationCode)
val exchangedIdentity = verifyIdentityToken(exchange.identityToken, nonce)
if (suppliedIdentity.subject != exchangedIdentity.subject) {
throw UnauthorizedException("Apple authorization code does not match identity token")
}
val now = clock.instant()
val fingerprint = identityFingerprint.ofAppleSubject(suppliedIdentity.subject)
val account = repository.findOrCreateAccount(
identityFingerprint = fingerprint,
encryptedAppleSubject = fieldEncryptor.encrypt(
suppliedIdentity.subject,
appleSubjectContext(fingerprint),
),
now = now,
)
integrityService.bindVerifiedKey(verifiedIntegrity.appAttestKeyId, account.id)
repository.updateAppleRefreshToken(
account.id,
fieldEncryptor.encrypt(exchange.refreshToken, appleRefreshContext(account.id)),
now,
)
accountProvisioner.provision(
account.id,
verifiedIntegrity.deviceCheckTokenForTrial.takeUnless { account.antiAbuseRestricted },
)
return createSession(account.id, now)
}
suspend fun refresh(refreshToken: String): SessionTokens {
requireValue(refreshToken, "refreshToken", MAX_REFRESH_TOKEN_LENGTH)
val now = clock.instant()
val replacement = tokenGenerator.newRefreshToken()
val replacementExpiresAt = now.plus(Duration.ofDays(sessionConfig.refreshDays))
return when (
val result = repository.rotateRefreshToken(
currentTokenHash = TokenHash.sha256(refreshToken),
newTokenHash = TokenHash.sha256(replacement),
newExpiresAt = replacementExpiresAt,
now = now,
)
) {
RefreshRotationResult.Invalid -> throw UnauthorizedException("Refresh token is invalid or expired")
RefreshRotationResult.ReuseDetected -> throw TokenReuseException()
is RefreshRotationResult.Rotated -> {
val access = sessionJwt.issue(result.accountId, result.sessionId)
SessionTokens(
accountId = result.accountId,
accessToken = access.value,
accessTokenExpiresAt = access.expiresAt,
refreshToken = replacement,
refreshTokenExpiresAt = replacementExpiresAt,
)
}
}
}
suspend fun logout(principal: AccountPrincipal) {
repository.revokeSessionFamily(principal.userId, principal.sessionId, clock.instant())
}
private suspend fun createSession(accountId: UUID, now: Instant): SessionTokens {
val refreshToken = tokenGenerator.newRefreshToken()
val refreshExpiresAt = now.plus(Duration.ofDays(sessionConfig.refreshDays))
val created = repository.createSession(
accountId = accountId,
refreshTokenHash = TokenHash.sha256(refreshToken),
expiresAt = refreshExpiresAt,
now = now,
)
val access = sessionJwt.issue(accountId, created.sessionId)
return SessionTokens(
accountId = accountId,
accessToken = access.value,
accessTokenExpiresAt = access.expiresAt,
refreshToken = refreshToken,
refreshTokenExpiresAt = refreshExpiresAt,
)
}
private suspend fun verifyIdentityToken(token: String, nonce: String): AppleIdentity =
try {
appleIdentityVerifier.verify(token, nonce)
} catch (exception: AppleVerificationUnavailableException) {
throw ExternalServiceUnavailableException("Apple identity verification")
} catch (exception: AppleTokenInvalidException) {
throw UnauthorizedException("Apple identity token is invalid")
}
private suspend fun exchangeCode(code: String): AppleTokenExchange =
try {
appleTokenClient.exchangeAuthorizationCode(code)
} catch (exception: AppleClientUnavailableException) {
throw ExternalServiceUnavailableException("Apple token service")
} catch (exception: AppleTokenEndpointException) {
if (exception.retryable) {
throw ExternalServiceUnavailableException("Apple token service")
}
throw UnauthorizedException("Apple authorization code is invalid")
}
private fun requireValue(value: String, name: String, maxLength: Int) {
if (value.isBlank()) throw InvalidRequestException("$name must not be blank")
if (value.length > maxLength) {
throw InvalidRequestException("$name exceeds the maximum length")
}
}
private companion object {
const val MAX_IDENTITY_TOKEN_LENGTH = 16_384
const val MAX_AUTHORIZATION_CODE_LENGTH = 2_048
const val MAX_NONCE_LENGTH = 256
const val MAX_REFRESH_TOKEN_LENGTH = 512
}
}
fun appleRefreshContext(accountId: UUID): String = "apple-refresh-token:$accountId"
fun appleSubjectContext(identityFingerprint: String): String = "apple-subject:$identityFingerprint"
@@ -0,0 +1,239 @@
package com.osglab.account.features.credits.domain
import java.math.BigInteger
import java.security.MessageDigest
import java.time.Instant
import java.util.UUID
import kotlinx.serialization.Serializable
@Serializable
enum class UsageKind {
ASR,
LLM,
}
enum class ReservationStatus {
RESERVED,
SETTLED,
RELEASED,
REFUNDED,
}
object ReservationStateRules {
fun canTransition(from: ReservationStatus, to: ReservationStatus): Boolean =
when (from) {
ReservationStatus.RESERVED ->
to == ReservationStatus.SETTLED || to == ReservationStatus.RELEASED
ReservationStatus.SETTLED -> to == ReservationStatus.REFUNDED
ReservationStatus.RELEASED,
ReservationStatus.REFUNDED,
-> false
}
}
enum class LedgerEntryType {
SIGNUP_TRIAL,
MANUAL_GRANT,
USAGE_RESERVE,
USAGE_SETTLE,
USAGE_RELEASE,
USAGE_REFUND,
REFERRAL_INVITER,
REFERRAL_INVITEE,
STOREKIT_PURCHASE,
SUBSCRIPTION_GRANT,
}
data class CreditAccount(
val userId: UUID,
val balance: Long,
val updatedAt: Instant,
)
data class LedgerEntry(
val id: UUID,
val userId: UUID,
val type: LedgerEntryType,
val amountDelta: Long,
val balanceAfter: Long,
val idempotencyKey: String,
val referenceId: UUID?,
val createdAt: Instant,
)
/**
* Billing metadata only. Provider input, audio, prompts and responses must
* never be persisted in a usage record.
*/
data class CreditUsageRecord(
val id: UUID,
val reservationId: UUID,
val userId: UUID,
val rateVersionId: UUID,
val usage: UsageMeasurement,
val chargedCredits: Long,
val createdAt: Instant,
)
sealed interface UsageMeasurement {
val kind: UsageKind
data class Asr(
val durationMillis: Long,
) : UsageMeasurement {
override val kind: UsageKind = UsageKind.ASR
init {
require(durationMillis >= 0) { "ASR duration must not be negative" }
}
}
data class Llm(
val inputTokens: Long,
val outputTokens: Long,
) : UsageMeasurement {
override val kind: UsageKind = UsageKind.LLM
init {
require(inputTokens >= 0) { "LLM input tokens must not be negative" }
require(outputTokens >= 0) { "LLM output tokens must not be negative" }
}
}
}
data class CreditRateVersion(
val id: UUID,
val kind: UsageKind,
val provider: String,
val model: String,
val effectiveFrom: Instant,
val effectiveUntil: Instant?,
val asrCreditsNumerator: Long?,
val asrMillisDenominator: Long?,
val inputCreditsNumerator: Long?,
val inputTokensDenominator: Long?,
val outputCreditsNumerator: Long?,
val outputTokensDenominator: Long?,
) {
init {
require(provider.isNotBlank()) { "Provider must not be blank" }
require(model.isNotBlank()) { "Model must not be blank" }
require(effectiveUntil == null || effectiveUntil > effectiveFrom) {
"Rate validity interval is invalid"
}
when (kind) {
UsageKind.ASR -> {
requirePositive(asrCreditsNumerator, "ASR numerator")
requirePositive(asrMillisDenominator, "ASR denominator")
require(inputCreditsNumerator == null && inputTokensDenominator == null)
require(outputCreditsNumerator == null && outputTokensDenominator == null)
}
UsageKind.LLM -> {
requirePositive(inputCreditsNumerator, "Input numerator")
requirePositive(inputTokensDenominator, "Input denominator")
requirePositive(outputCreditsNumerator, "Output numerator")
requirePositive(outputTokensDenominator, "Output denominator")
require(asrCreditsNumerator == null && asrMillisDenominator == null)
}
}
}
private fun requirePositive(value: Long?, name: String) {
require(value != null && value > 0) { "$name must be positive" }
}
}
data class CreditReservation(
val id: UUID,
val userId: UUID,
val rateVersionId: UUID,
val provider: String,
val model: String,
val estimatedUsage: UsageMeasurement,
val actualUsage: UsageMeasurement?,
val reservedCredits: Long,
val settledCredits: Long?,
val status: ReservationStatus,
val managedCall: Boolean,
val reserveIdempotencyKey: String,
val settleIdempotencyKey: String?,
val releaseIdempotencyKey: String?,
val refundIdempotencyKey: String?,
val createdAt: Instant,
val updatedAt: Instant,
)
object CreditCostCalculator {
fun calculate(rate: CreditRateVersion, usage: UsageMeasurement): Long {
require(rate.kind == usage.kind) { "Usage kind does not match rate version" }
return try {
when (usage) {
is UsageMeasurement.Asr -> ceilMultiplyDivide(
usage.durationMillis,
requireNotNull(rate.asrCreditsNumerator),
requireNotNull(rate.asrMillisDenominator),
)
is UsageMeasurement.Llm -> Math.addExact(
ceilMultiplyDivide(
usage.inputTokens,
requireNotNull(rate.inputCreditsNumerator),
requireNotNull(rate.inputTokensDenominator),
),
ceilMultiplyDivide(
usage.outputTokens,
requireNotNull(rate.outputCreditsNumerator),
requireNotNull(rate.outputTokensDenominator),
),
)
}
} catch (_: ArithmeticException) {
throw InvalidCreditRequest("Calculated credit cost exceeds the supported integer range")
}
}
private fun ceilMultiplyDivide(units: Long, numerator: Long, denominator: Long): Long {
if (units == 0L) return 0L
val product = BigInteger.valueOf(units).multiply(BigInteger.valueOf(numerator))
val divisor = BigInteger.valueOf(denominator)
val (quotient, remainder) = product.divideAndRemainder(divisor)
return quotient
.add(if (remainder.signum() == 0) BigInteger.ZERO else BigInteger.ONE)
.longValueExact()
}
}
open class CreditException(message: String) : RuntimeException(message)
class InvalidCreditRequest(message: String) : CreditException(message)
class InsufficientCredits(
val available: Long,
val required: Long,
) : CreditException("Insufficient credits: available=$available, required=$required")
class CreditConflict(message: String) : CreditException(message)
class CreditNotFound(message: String) : CreditException(message)
internal fun validatedIdempotencyKey(value: String): String {
val normalized = value.trim()
if (normalized.length !in 8..128) {
throw InvalidCreditRequest("Idempotency key must contain 8 to 128 characters")
}
return normalized
}
/**
* Public callers are confined to a one-way namespace so they can never reserve
* service-owned ledger keys such as referral, trial or gateway operations.
*/
fun externalIdempotencyKey(value: String): String {
val normalized = validatedIdempotencyKey(value)
val digest = MessageDigest.getInstance("SHA-256")
.digest(normalized.toByteArray(Charsets.UTF_8))
.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
return "external:$digest"
}
@@ -0,0 +1,170 @@
package com.osglab.account.features.credits.models
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.credits.domain.UsageMeasurement
import kotlinx.serialization.Serializable
@Serializable
data class UsageDto(
val kind: UsageKind,
val asrMillis: Long? = null,
val inputTokens: Long? = null,
val outputTokens: Long? = null,
) {
fun toDomain(): UsageMeasurement = when (kind) {
UsageKind.ASR -> {
if (inputTokens != null || outputTokens != null || asrMillis == null || asrMillis < 0) {
throw InvalidCreditRequest("ASR usage requires only a non-negative asrMillis value")
}
UsageMeasurement.Asr(asrMillis)
}
UsageKind.LLM -> {
if (asrMillis != null || inputTokens == null || outputTokens == null ||
inputTokens < 0 || outputTokens < 0
) {
throw InvalidCreditRequest(
"LLM usage requires non-negative inputTokens and outputTokens values",
)
}
UsageMeasurement.Llm(inputTokens, outputTokens)
}
}
companion object {
fun fromDomain(usage: UsageMeasurement): UsageDto = when (usage) {
is UsageMeasurement.Asr -> UsageDto(UsageKind.ASR, asrMillis = usage.durationMillis)
is UsageMeasurement.Llm -> UsageDto(
kind = UsageKind.LLM,
inputTokens = usage.inputTokens,
outputTokens = usage.outputTokens,
)
}
}
}
@Serializable
data class ReserveCreditsRequest(
val provider: String,
val model: String,
val estimatedUsage: UsageDto,
)
@Serializable
data class SettleCreditsRequest(
val actualUsage: UsageDto,
)
@Serializable
data class CreditAccountDto(
val userId: String,
val balance: Long,
val updatedAt: String,
) {
companion object {
fun fromDomain(account: CreditAccount) = CreditAccountDto(
userId = account.userId.toString(),
balance = account.balance,
updatedAt = account.updatedAt.toString(),
)
}
}
@Serializable
data class LedgerEntryDto(
val id: String,
val type: String,
val amountDelta: Long,
val balanceAfter: Long,
val referenceId: String?,
val createdAt: String,
) {
companion object {
fun fromDomain(entry: LedgerEntry) = LedgerEntryDto(
id = entry.id.toString(),
type = entry.type.name,
amountDelta = entry.amountDelta,
balanceAfter = entry.balanceAfter,
referenceId = entry.referenceId?.toString(),
createdAt = entry.createdAt.toString(),
)
}
}
@Serializable
data class CreditReservationDto(
val id: String,
val userId: String,
val rateVersionId: String,
val provider: String,
val model: String,
val estimatedUsage: UsageDto,
val actualUsage: UsageDto?,
val reservedCredits: Long,
val settledCredits: Long?,
val status: String,
val managedCall: Boolean,
val createdAt: String,
val updatedAt: String,
) {
companion object {
fun fromDomain(reservation: CreditReservation) = CreditReservationDto(
id = reservation.id.toString(),
userId = reservation.userId.toString(),
rateVersionId = reservation.rateVersionId.toString(),
provider = reservation.provider,
model = reservation.model,
estimatedUsage = UsageDto.fromDomain(reservation.estimatedUsage),
actualUsage = reservation.actualUsage?.let(UsageDto::fromDomain),
reservedCredits = reservation.reservedCredits,
settledCredits = reservation.settledCredits,
status = reservation.status.name,
managedCall = reservation.managedCall,
createdAt = reservation.createdAt.toString(),
updatedAt = reservation.updatedAt.toString(),
)
}
}
@Serializable
data class CreditRateVersionDto(
val id: String,
val kind: UsageKind,
val provider: String,
val model: String,
val effectiveFrom: String,
val effectiveUntil: String?,
val asrCreditsNumerator: Long?,
val asrMillisDenominator: Long?,
val inputCreditsNumerator: Long?,
val inputTokensDenominator: Long?,
val outputCreditsNumerator: Long?,
val outputTokensDenominator: Long?,
) {
companion object {
fun fromDomain(rate: CreditRateVersion) = CreditRateVersionDto(
id = rate.id.toString(),
kind = rate.kind,
provider = rate.provider,
model = rate.model,
effectiveFrom = rate.effectiveFrom.toString(),
effectiveUntil = rate.effectiveUntil?.toString(),
asrCreditsNumerator = rate.asrCreditsNumerator,
asrMillisDenominator = rate.asrMillisDenominator,
inputCreditsNumerator = rate.inputCreditsNumerator,
inputTokensDenominator = rate.inputTokensDenominator,
outputCreditsNumerator = rate.outputCreditsNumerator,
outputTokensDenominator = rate.outputTokensDenominator,
)
}
}
@Serializable
data class CreditErrorDto(
val error: String,
)
@@ -0,0 +1,55 @@
package com.osglab.account.features.credits.repositories
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.referrals.repositories.ReferralsRepository
import java.time.Instant
import java.util.UUID
interface CreditsRepository {
fun createAccountIfAbsent(userId: UUID, now: Instant)
fun lockAccount(userId: UUID): CreditAccount
fun updateAccountBalance(userId: UUID, newBalance: Long, now: Instant): CreditAccount
fun findLedgerEntry(userId: UUID, idempotencyKey: String): LedgerEntry?
fun insertLedgerEntry(entry: LedgerEntry)
fun listLedgerEntries(userId: UUID, limit: Int): List<LedgerEntry>
fun insertUsageRecord(record: CreditUsageRecord)
fun findReservationByReserveKey(userId: UUID, idempotencyKey: String): CreditReservation?
fun lockReservation(id: UUID): CreditReservation?
fun insertReservation(reservation: CreditReservation)
fun updateReservation(reservation: CreditReservation)
fun findRateVersion(id: UUID): CreditRateVersion?
fun findEffectiveRate(
kind: UsageKind,
provider: String,
model: String,
at: Instant,
): CreditRateVersion?
fun listEffectiveRates(at: Instant): List<CreditRateVersion>
}
interface BillingUnitOfWork {
val credits: CreditsRepository
val referrals: ReferralsRepository
}
interface BillingTransactionRunner {
suspend fun <T> inTransaction(block: (BillingUnitOfWork) -> T): T
}
@@ -0,0 +1,674 @@
package com.osglab.account.features.credits.repositories
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.domain.ReservationStatus
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.credits.domain.UsageMeasurement
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import com.osglab.account.features.referrals.repositories.ReferralsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
import java.util.UUID
private object CreditAccounts : Table("credit_accounts") {
val userId = varchar("user_id", 36)
val balance = long("balance")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(userId)
}
private object CreditLedger : Table("credit_ledger") {
val id = varchar("id", 36)
val userId = varchar("user_id", 36)
val entryType = enumerationByName<LedgerEntryType>("entry_type", 32)
val amountDelta = long("amount_delta")
val balanceAfter = long("balance_after")
val idempotencyKey = varchar("idempotency_key", 128)
val referenceId = varchar("reference_id", 36).nullable()
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object CreditUsageRecords : Table("credit_usage_records") {
val id = varchar("id", 36)
val reservationId = varchar("reservation_id", 36)
val userId = varchar("user_id", 36)
val rateVersionId = varchar("rate_version_id", 36)
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
val asrMillis = long("asr_millis").nullable()
val inputTokens = long("input_tokens").nullable()
val outputTokens = long("output_tokens").nullable()
val chargedCredits = long("charged_credits")
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object CreditRateVersions : Table("credit_rate_versions") {
val id = varchar("id", 36)
val kind = enumerationByName<UsageKind>("kind", 8)
val provider = varchar("provider", 100)
val model = varchar("model", 100)
val effectiveFrom = timestamp("effective_from")
val effectiveUntil = timestamp("effective_until").nullable()
val asrCreditsNumerator = long("asr_credits_numerator").nullable()
val asrMillisDenominator = long("asr_millis_denominator").nullable()
val inputCreditsNumerator = long("input_credits_numerator").nullable()
val inputTokensDenominator = long("input_tokens_denominator").nullable()
val outputCreditsNumerator = long("output_credits_numerator").nullable()
val outputTokensDenominator = long("output_tokens_denominator").nullable()
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object CreditReservations : Table("credit_reservations") {
val id = varchar("id", 36)
val userId = varchar("user_id", 36)
val rateVersionId = varchar("rate_version_id", 36)
val provider = varchar("provider", 100)
val model = varchar("model", 100)
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
val estimatedAsrMillis = long("estimated_asr_millis").nullable()
val estimatedInputTokens = long("estimated_input_tokens").nullable()
val estimatedOutputTokens = long("estimated_output_tokens").nullable()
val actualAsrMillis = long("actual_asr_millis").nullable()
val actualInputTokens = long("actual_input_tokens").nullable()
val actualOutputTokens = long("actual_output_tokens").nullable()
val reservedCredits = long("reserved_credits")
val settledCredits = long("settled_credits").nullable()
val status = enumerationByName<ReservationStatus>("status", 16)
val managedCall = bool("managed_call")
val reserveIdempotencyKey = varchar("reserve_idempotency_key", 128)
val settleIdempotencyKey = varchar("settle_idempotency_key", 128).nullable()
val releaseIdempotencyKey = varchar("release_idempotency_key", 128).nullable()
val refundIdempotencyKey = varchar("refund_idempotency_key", 128).nullable()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
private object ReferralCampaigns : Table("referral_campaigns") {
val id = varchar("id", 36)
val name = varchar("name", 100)
val startsAt = timestamp("starts_at")
val endsAt = timestamp("ends_at").nullable()
val bindingWindowSeconds = long("binding_window_seconds")
val inviterRewardCredits = long("inviter_reward_credits")
val inviteeRewardCredits = long("invitee_reward_credits")
val maxRewardedBindings = long("max_rewarded_bindings").nullable()
val budgetCredits = long("budget_credits").nullable()
val enabled = bool("enabled")
override val primaryKey = PrimaryKey(id)
}
private object ReferralCampaignBudgets : Table("referral_campaign_budgets") {
val campaignId = varchar("campaign_id", 36)
val rewardedBindings = long("rewarded_bindings")
val spentCredits = long("spent_credits")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(campaignId)
}
private object ReferralCodes : Table("referral_codes") {
val id = varchar("id", 36)
val ownerUserId = varchar("owner_user_id", 36)
val ownerIdentityFingerprint = char("owner_identity_fingerprint", 64).nullable()
val campaignId = varchar("campaign_id", 36)
val code = varchar("code", 32)
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object ReferralBindings : Table("referral_bindings") {
val id = varchar("id", 36)
val inviterUserId = varchar("inviter_user_id", 36)
val inviteeUserId = varchar("invitee_user_id", 36)
val codeId = varchar("code_id", 36)
val campaignId = varchar("campaign_id", 36)
val boundAt = timestamp("bound_at")
val rewardedAt = timestamp("rewarded_at").nullable()
val rewardSettlementId = varchar("reward_settlement_id", 36).nullable()
val rewardStatus = enumerationByName<ReferralRewardStatus>("reward_status", 24)
override val primaryKey = PrimaryKey(id)
}
class ExposedBillingTransactionRunner(
private val database: Database,
) : BillingTransactionRunner {
override suspend fun <T> inTransaction(block: (BillingUnitOfWork) -> T): T =
withContext(Dispatchers.IO) {
transaction(database) {
block(ExposedBillingUnitOfWork)
}
}
}
private object ExposedBillingUnitOfWork : BillingUnitOfWork {
override val credits: CreditsRepository = ExposedCreditsRepository
override val referrals: ReferralsRepository = ExposedReferralsRepository
}
private object ExposedCreditsRepository : CreditsRepository {
override fun createAccountIfAbsent(userId: UUID, now: Instant) {
CreditAccounts.insertIgnore {
it[CreditAccounts.userId] = userId.toString()
it[balance] = 0
it[updatedAt] = now
}
}
override fun lockAccount(userId: UUID): CreditAccount =
CreditAccounts
.selectAll()
.where { CreditAccounts.userId eq userId.toString() }
.forUpdate()
.singleOrNull()
?.toCreditAccount()
?: throw CreditNotFound("Credit account does not exist")
override fun updateAccountBalance(
userId: UUID,
newBalance: Long,
now: Instant,
): CreditAccount {
CreditAccounts.update({ CreditAccounts.userId eq userId.toString() }) {
it[balance] = newBalance
it[updatedAt] = now
}
return CreditAccount(userId, newBalance, now)
}
override fun findLedgerEntry(userId: UUID, idempotencyKey: String): LedgerEntry? =
CreditLedger
.selectAll()
.where {
(CreditLedger.userId eq userId.toString()) and
(CreditLedger.idempotencyKey eq idempotencyKey)
}
.singleOrNull()
?.toLedgerEntry()
override fun insertLedgerEntry(entry: LedgerEntry) {
CreditLedger.insert {
it[id] = entry.id.toString()
it[userId] = entry.userId.toString()
it[entryType] = entry.type
it[amountDelta] = entry.amountDelta
it[balanceAfter] = entry.balanceAfter
it[idempotencyKey] = entry.idempotencyKey
it[referenceId] = entry.referenceId?.toString()
it[createdAt] = entry.createdAt
}
}
override fun listLedgerEntries(userId: UUID, limit: Int): List<LedgerEntry> =
CreditLedger
.selectAll()
.where { CreditLedger.userId eq userId.toString() }
.orderBy(
CreditLedger.createdAt to SortOrder.DESC,
CreditLedger.id to SortOrder.DESC,
)
.limit(limit)
.map(ResultRow::toLedgerEntry)
override fun insertUsageRecord(record: CreditUsageRecord) {
CreditUsageRecords.insert {
it[id] = record.id.toString()
it[reservationId] = record.reservationId.toString()
it[userId] = record.userId.toString()
it[rateVersionId] = record.rateVersionId.toString()
it[usageKind] = record.usage.kind
when (val usage = record.usage) {
is UsageMeasurement.Asr -> {
it[asrMillis] = usage.durationMillis
it[inputTokens] = null
it[outputTokens] = null
}
is UsageMeasurement.Llm -> {
it[asrMillis] = null
it[inputTokens] = usage.inputTokens
it[outputTokens] = usage.outputTokens
}
}
it[chargedCredits] = record.chargedCredits
it[createdAt] = record.createdAt
}
}
override fun findReservationByReserveKey(
userId: UUID,
idempotencyKey: String,
): CreditReservation? = CreditReservations
.selectAll()
.where {
(CreditReservations.userId eq userId.toString()) and
(CreditReservations.reserveIdempotencyKey eq idempotencyKey)
}
.singleOrNull()
?.toCreditReservation()
override fun lockReservation(id: UUID): CreditReservation? =
CreditReservations
.selectAll()
.where { CreditReservations.id eq id.toString() }
.forUpdate()
.singleOrNull()
?.toCreditReservation()
override fun insertReservation(reservation: CreditReservation) {
CreditReservations.insert {
it[id] = reservation.id.toString()
it[userId] = reservation.userId.toString()
it[rateVersionId] = reservation.rateVersionId.toString()
it[provider] = reservation.provider
it[model] = reservation.model
setUsage(it, reservation.estimatedUsage, estimated = true)
it[actualAsrMillis] = null
it[actualInputTokens] = null
it[actualOutputTokens] = null
it[reservedCredits] = reservation.reservedCredits
it[settledCredits] = reservation.settledCredits
it[status] = reservation.status
it[managedCall] = reservation.managedCall
it[reserveIdempotencyKey] = reservation.reserveIdempotencyKey
it[settleIdempotencyKey] = reservation.settleIdempotencyKey
it[releaseIdempotencyKey] = reservation.releaseIdempotencyKey
it[refundIdempotencyKey] = reservation.refundIdempotencyKey
it[createdAt] = reservation.createdAt
it[updatedAt] = reservation.updatedAt
}
}
override fun updateReservation(reservation: CreditReservation) {
CreditReservations.update({ CreditReservations.id eq reservation.id.toString() }) {
reservation.actualUsage?.let { usage -> setUsage(it, usage, estimated = false) }
it[settledCredits] = reservation.settledCredits
it[status] = reservation.status
it[settleIdempotencyKey] = reservation.settleIdempotencyKey
it[releaseIdempotencyKey] = reservation.releaseIdempotencyKey
it[refundIdempotencyKey] = reservation.refundIdempotencyKey
it[updatedAt] = reservation.updatedAt
}
}
override fun findRateVersion(id: UUID): CreditRateVersion? =
CreditRateVersions
.selectAll()
.where { CreditRateVersions.id eq id.toString() }
.singleOrNull()
?.toCreditRateVersion()
override fun findEffectiveRate(
kind: UsageKind,
provider: String,
model: String,
at: Instant,
): CreditRateVersion? = CreditRateVersions
.selectAll()
.where {
(CreditRateVersions.kind eq kind) and
(CreditRateVersions.provider eq provider) and
(CreditRateVersions.model eq model) and
(CreditRateVersions.effectiveFrom lessEq at) and
(
CreditRateVersions.effectiveUntil.isNull() or
(CreditRateVersions.effectiveUntil greater at)
)
}
.orderBy(CreditRateVersions.effectiveFrom, SortOrder.DESC)
.limit(1)
.singleOrNull()
?.toCreditRateVersion()
override fun listEffectiveRates(at: Instant): List<CreditRateVersion> =
CreditRateVersions
.selectAll()
.where {
(CreditRateVersions.effectiveFrom lessEq at) and
(
CreditRateVersions.effectiveUntil.isNull() or
(CreditRateVersions.effectiveUntil greater at)
)
}
.orderBy(CreditRateVersions.provider to SortOrder.ASC)
.map(ResultRow::toCreditRateVersion)
private fun <T : Any> setUsage(
statement: org.jetbrains.exposed.v1.core.statements.UpdateBuilder<T>,
usage: UsageMeasurement,
estimated: Boolean,
) {
statement[CreditReservations.usageKind] = usage.kind
when (usage) {
is UsageMeasurement.Asr -> {
statement[
if (estimated) {
CreditReservations.estimatedAsrMillis
} else {
CreditReservations.actualAsrMillis
},
] =
usage.durationMillis
statement[
if (estimated) {
CreditReservations.estimatedInputTokens
} else {
CreditReservations.actualInputTokens
},
] = null
statement[
if (estimated) {
CreditReservations.estimatedOutputTokens
} else {
CreditReservations.actualOutputTokens
},
] = null
}
is UsageMeasurement.Llm -> {
statement[
if (estimated) {
CreditReservations.estimatedAsrMillis
} else {
CreditReservations.actualAsrMillis
},
] = null
statement[
if (estimated) {
CreditReservations.estimatedInputTokens
} else {
CreditReservations.actualInputTokens
},
] =
usage.inputTokens
statement[
if (estimated) {
CreditReservations.estimatedOutputTokens
} else {
CreditReservations.actualOutputTokens
},
] =
usage.outputTokens
}
}
}
}
private object ExposedReferralsRepository : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? {
val query = ReferralCodes
.selectAll()
.where { ReferralCodes.ownerUserId eq ownerUserId.toString() }
return if (campaignId == null) {
query.orderBy(ReferralCodes.createdAt, SortOrder.DESC).limit(1).singleOrNull()
} else {
query.andWhere { ReferralCodes.campaignId eq campaignId.toString() }.singleOrNull()
}?.toReferralCode()
}
override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? =
ReferralCodes
.selectAll()
.where {
(ReferralCodes.ownerUserId eq ownerUserId.toString()) and
(ReferralCodes.campaignId eq campaignId.toString())
}
.forUpdate()
.singleOrNull()
?.toReferralCode()
override fun findCode(code: String): ReferralCode? =
ReferralCodes
.selectAll()
.where { ReferralCodes.code eq code }
.singleOrNull()
?.toReferralCode()
override fun insertCodeIfAbsent(code: ReferralCode): Boolean =
ReferralCodes.insertIgnore {
it[id] = code.id.toString()
it[ownerUserId] = code.ownerUserId.toString()
it[ownerIdentityFingerprint] = code.ownerIdentityFingerprint
it[campaignId] = (code.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
it[ReferralCodes.code] = code.code
it[createdAt] = code.createdAt
}.insertedCount == 1
override fun findCampaign(id: UUID): ReferralCampaign? =
ReferralCampaigns
.selectAll()
.where { ReferralCampaigns.id eq id.toString() }
.singleOrNull()
?.toReferralCampaign()
override fun listActiveCampaigns(at: Instant): List<ReferralCampaign> =
ReferralCampaigns
.selectAll()
.where {
(ReferralCampaigns.enabled eq true) and
(ReferralCampaigns.startsAt lessEq at) and
(
ReferralCampaigns.endsAt.isNull() or
(ReferralCampaigns.endsAt greater at)
)
}
.orderBy(ReferralCampaigns.startsAt, SortOrder.DESC)
.map(ResultRow::toReferralCampaign)
override fun lockCampaignBudget(campaignId: UUID): ReferralCampaignBudget =
ReferralCampaignBudgets
.selectAll()
.where { ReferralCampaignBudgets.campaignId eq campaignId.toString() }
.forUpdate()
.single()
.toReferralCampaignBudget()
override fun updateCampaignBudget(budget: ReferralCampaignBudget) {
ReferralCampaignBudgets.update({
ReferralCampaignBudgets.campaignId eq budget.campaignId.toString()
}) {
it[rewardedBindings] = budget.rewardedBindings
it[spentCredits] = budget.spentCredits
it[updatedAt] = budget.updatedAt
}
}
override fun findBinding(inviteeUserId: UUID): ReferralBinding? =
ReferralBindings
.selectAll()
.where { ReferralBindings.inviteeUserId eq inviteeUserId.toString() }
.singleOrNull()
?.toReferralBinding()
override fun listBindingsByInviter(
inviterUserId: UUID,
limit: Int,
): List<ReferralBinding> =
ReferralBindings
.selectAll()
.where { ReferralBindings.inviterUserId eq inviterUserId.toString() }
.orderBy(ReferralBindings.boundAt, SortOrder.DESC)
.limit(limit)
.map(ResultRow::toReferralBinding)
override fun lockBinding(inviteeUserId: UUID): ReferralBinding? =
ReferralBindings
.selectAll()
.where { ReferralBindings.inviteeUserId eq inviteeUserId.toString() }
.forUpdate()
.singleOrNull()
?.toReferralBinding()
override fun insertBindingIfAbsent(binding: ReferralBinding): Boolean =
ReferralBindings.insertIgnore {
it[id] = binding.id.toString()
it[inviterUserId] = binding.inviterUserId.toString()
it[inviteeUserId] = binding.inviteeUserId.toString()
it[codeId] = binding.codeId.toString()
it[campaignId] = (binding.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
it[boundAt] = binding.boundAt
it[rewardedAt] = binding.rewardedAt
it[rewardSettlementId] = binding.rewardSettlementId?.toString()
it[rewardStatus] = binding.rewardStatus
}.insertedCount == 1
override fun markRewarded(bindingId: UUID, settlementId: UUID, rewardedAt: Instant) {
ReferralBindings.update({ ReferralBindings.id eq bindingId.toString() }) {
it[ReferralBindings.rewardedAt] = rewardedAt
it[rewardSettlementId] = settlementId.toString()
it[rewardStatus] = ReferralRewardStatus.REWARDED
}
}
override fun markRewardIneligible(bindingId: UUID) {
ReferralBindings.update({ ReferralBindings.id eq bindingId.toString() }) {
it[rewardStatus] = ReferralRewardStatus.INELIGIBLE_BUDGET
}
}
}
private fun ResultRow.toCreditAccount() = CreditAccount(
userId = UUID.fromString(this[CreditAccounts.userId]),
balance = this[CreditAccounts.balance],
updatedAt = this[CreditAccounts.updatedAt],
)
private fun ResultRow.toLedgerEntry() = LedgerEntry(
id = UUID.fromString(this[CreditLedger.id]),
userId = UUID.fromString(this[CreditLedger.userId]),
type = this[CreditLedger.entryType],
amountDelta = this[CreditLedger.amountDelta],
balanceAfter = this[CreditLedger.balanceAfter],
idempotencyKey = this[CreditLedger.idempotencyKey],
referenceId = this[CreditLedger.referenceId]?.let(UUID::fromString),
createdAt = this[CreditLedger.createdAt],
)
private fun ResultRow.toCreditRateVersion() = CreditRateVersion(
id = UUID.fromString(this[CreditRateVersions.id]),
kind = this[CreditRateVersions.kind],
provider = this[CreditRateVersions.provider],
model = this[CreditRateVersions.model],
effectiveFrom = this[CreditRateVersions.effectiveFrom],
effectiveUntil = this[CreditRateVersions.effectiveUntil],
asrCreditsNumerator = this[CreditRateVersions.asrCreditsNumerator],
asrMillisDenominator = this[CreditRateVersions.asrMillisDenominator],
inputCreditsNumerator = this[CreditRateVersions.inputCreditsNumerator],
inputTokensDenominator = this[CreditRateVersions.inputTokensDenominator],
outputCreditsNumerator = this[CreditRateVersions.outputCreditsNumerator],
outputTokensDenominator = this[CreditRateVersions.outputTokensDenominator],
)
private fun ResultRow.toCreditReservation(): CreditReservation {
val kind = this[CreditReservations.usageKind]
val estimated = when (kind) {
UsageKind.ASR -> UsageMeasurement.Asr(requireNotNull(this[CreditReservations.estimatedAsrMillis]))
UsageKind.LLM -> UsageMeasurement.Llm(
requireNotNull(this[CreditReservations.estimatedInputTokens]),
requireNotNull(this[CreditReservations.estimatedOutputTokens]),
)
}
val actual = when {
this[CreditReservations.actualAsrMillis] != null ->
UsageMeasurement.Asr(requireNotNull(this[CreditReservations.actualAsrMillis]))
this[CreditReservations.actualInputTokens] != null ->
UsageMeasurement.Llm(
requireNotNull(this[CreditReservations.actualInputTokens]),
requireNotNull(this[CreditReservations.actualOutputTokens]),
)
else -> null
}
return CreditReservation(
id = UUID.fromString(this[CreditReservations.id]),
userId = UUID.fromString(this[CreditReservations.userId]),
rateVersionId = UUID.fromString(this[CreditReservations.rateVersionId]),
provider = this[CreditReservations.provider],
model = this[CreditReservations.model],
estimatedUsage = estimated,
actualUsage = actual,
reservedCredits = this[CreditReservations.reservedCredits],
settledCredits = this[CreditReservations.settledCredits],
status = this[CreditReservations.status],
managedCall = this[CreditReservations.managedCall],
reserveIdempotencyKey = this[CreditReservations.reserveIdempotencyKey],
settleIdempotencyKey = this[CreditReservations.settleIdempotencyKey],
releaseIdempotencyKey = this[CreditReservations.releaseIdempotencyKey],
refundIdempotencyKey = this[CreditReservations.refundIdempotencyKey],
createdAt = this[CreditReservations.createdAt],
updatedAt = this[CreditReservations.updatedAt],
)
}
private fun ResultRow.toReferralCode() = ReferralCode(
id = UUID.fromString(this[ReferralCodes.id]),
ownerUserId = UUID.fromString(this[ReferralCodes.ownerUserId]),
ownerIdentityFingerprint = this[ReferralCodes.ownerIdentityFingerprint],
code = this[ReferralCodes.code],
createdAt = this[ReferralCodes.createdAt],
campaignId = UUID.fromString(this[ReferralCodes.campaignId]),
)
private fun ResultRow.toReferralCampaign() = ReferralCampaign(
id = UUID.fromString(this[ReferralCampaigns.id]),
name = this[ReferralCampaigns.name],
startsAt = this[ReferralCampaigns.startsAt],
endsAt = this[ReferralCampaigns.endsAt],
bindingWindowSeconds = this[ReferralCampaigns.bindingWindowSeconds],
inviterRewardCredits = this[ReferralCampaigns.inviterRewardCredits],
inviteeRewardCredits = this[ReferralCampaigns.inviteeRewardCredits],
maxRewardedBindings = this[ReferralCampaigns.maxRewardedBindings],
budgetCredits = this[ReferralCampaigns.budgetCredits],
enabled = this[ReferralCampaigns.enabled],
)
private fun ResultRow.toReferralCampaignBudget() = ReferralCampaignBudget(
campaignId = UUID.fromString(this[ReferralCampaignBudgets.campaignId]),
rewardedBindings = this[ReferralCampaignBudgets.rewardedBindings],
spentCredits = this[ReferralCampaignBudgets.spentCredits],
updatedAt = this[ReferralCampaignBudgets.updatedAt],
)
private fun ResultRow.toReferralBinding() = ReferralBinding(
id = UUID.fromString(this[ReferralBindings.id]),
inviterUserId = UUID.fromString(this[ReferralBindings.inviterUserId]),
inviteeUserId = UUID.fromString(this[ReferralBindings.inviteeUserId]),
codeId = UUID.fromString(this[ReferralBindings.codeId]),
boundAt = this[ReferralBindings.boundAt],
rewardedAt = this[ReferralBindings.rewardedAt],
rewardSettlementId = this[ReferralBindings.rewardSettlementId]?.let(UUID::fromString),
campaignId = UUID.fromString(this[ReferralBindings.campaignId]),
rewardStatus = this[ReferralBindings.rewardStatus],
)
@@ -0,0 +1,94 @@
package com.osglab.account.features.credits.routes
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditException
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InsufficientCredits
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.models.CreditAccountDto
import com.osglab.account.features.credits.models.CreditErrorDto
import com.osglab.account.features.credits.models.CreditRateVersionDto
import com.osglab.account.features.credits.models.LedgerEntryDto
import com.osglab.account.features.credits.services.CreditOperations
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.principal
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import java.util.UUID
fun interface AuthenticatedUserExtractor {
suspend fun extract(call: ApplicationCall): UUID?
}
object JwtSubjectUserExtractor : AuthenticatedUserExtractor {
override suspend fun extract(call: ApplicationCall): UUID? = call.jwtSubjectUserId()
}
class CreditRouteInstaller(
private val service: CreditOperations,
private val authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
fun install(parent: Route) {
parent.route("/v1/credits") {
get("/balance") {
call.creditCall(authenticatedUser) { userId ->
CreditAccountDto.fromDomain(service.getAccount(userId))
}
}
get("/ledger") {
call.creditCall(authenticatedUser) { userId ->
val rawLimit = call.request.queryParameters["limit"]
val limit = rawLimit?.toIntOrNull()
?: if (rawLimit == null) 50 else {
throw InvalidCreditRequest("Ledger limit must be an integer")
}
service.listLedger(userId, limit).map(LedgerEntryDto::fromDomain)
}
}
get("/rates") {
call.creditCall(authenticatedUser) {
service.listEffectiveRates().map(CreditRateVersionDto::fromDomain)
}
}
}
}
}
fun Route.creditRoutes(
service: CreditOperations,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
CreditRouteInstaller(service, authenticatedUser).install(this)
}
/** Reads the principal produced by the session authentication boundary. */
internal fun ApplicationCall.jwtSubjectUserId(): UUID? =
principal<AccountPrincipal>()?.userId
private suspend fun ApplicationCall.creditCall(
authenticatedUser: AuthenticatedUserExtractor,
block: suspend (UUID) -> Any,
) {
val userId = authenticatedUser.extract(this)
if (userId == null) {
respond(HttpStatusCode.Unauthorized, CreditErrorDto("Authentication required"))
return
}
try {
respond(block(userId))
} catch (exception: InsufficientCredits) {
respond(HttpStatusCode.PaymentRequired, CreditErrorDto(exception.message.orEmpty()))
} catch (exception: InvalidCreditRequest) {
respond(HttpStatusCode.BadRequest, CreditErrorDto(exception.message.orEmpty()))
} catch (exception: CreditNotFound) {
respond(HttpStatusCode.NotFound, CreditErrorDto(exception.message.orEmpty()))
} catch (exception: CreditConflict) {
respond(HttpStatusCode.Conflict, CreditErrorDto(exception.message.orEmpty()))
} catch (exception: CreditException) {
respond(HttpStatusCode.UnprocessableEntity, CreditErrorDto(exception.message.orEmpty()))
}
}
@@ -0,0 +1,572 @@
package com.osglab.account.features.credits.services
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditCostCalculator
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.InsufficientCredits
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.domain.ReservationStatus
import com.osglab.account.features.credits.domain.ReservationStateRules
import com.osglab.account.features.credits.domain.UsageMeasurement
import com.osglab.account.features.credits.domain.validatedIdempotencyKey
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.credits.repositories.BillingUnitOfWork
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import java.time.Clock
import java.time.Instant
import java.util.UUID
data class ReferralRewardConfig(
val inviterCredits: Long,
val inviteeCredits: Long,
) {
init {
require(inviterCredits > 0) { "Inviter reward must be positive" }
require(inviteeCredits > 0) { "Invitee reward must be positive" }
}
}
/**
* Public boundary used by routes, gateway adapters and account provisioning.
* Implementations must preserve transactionality and idempotency guarantees.
*/
interface CreditOperations {
suspend fun getAccount(userId: UUID): CreditAccount
suspend fun listEffectiveRates(): List<CreditRateVersion>
suspend fun listLedger(userId: UUID, limit: Int = 50): List<LedgerEntry>
suspend fun getReservation(userId: UUID, reservationId: UUID): CreditReservation
suspend fun grantSignupTrial(
userId: UUID,
credits: Long,
idempotencyKey: String,
): CreditAccount
suspend fun reserve(
userId: UUID,
provider: String,
model: String,
estimatedUsage: UsageMeasurement,
managedCall: Boolean,
idempotencyKey: String,
): CreditReservation
suspend fun settle(
userId: UUID,
reservationId: UUID,
actualUsage: UsageMeasurement,
idempotencyKey: String,
): CreditReservation
suspend fun release(
userId: UUID,
reservationId: UUID,
idempotencyKey: String,
): CreditReservation
suspend fun refund(
userId: UUID,
reservationId: UUID,
idempotencyKey: String,
): CreditReservation
}
class CreditService(
private val transactions: BillingTransactionRunner,
private val referralRewards: ReferralRewardConfig,
private val clock: Clock = Clock.systemUTC(),
private val newId: () -> UUID = UUID::randomUUID,
) : CreditOperations {
override suspend fun getAccount(userId: UUID): CreditAccount = transactions.inTransaction { unit ->
unit.credits.createAccountIfAbsent(userId, clock.instant())
unit.credits.lockAccount(userId)
}
override suspend fun listEffectiveRates(): List<CreditRateVersion> =
transactions.inTransaction { it.credits.listEffectiveRates(clock.instant()) }
override suspend fun listLedger(userId: UUID, limit: Int): List<LedgerEntry> {
if (limit !in 1..100) throw InvalidCreditRequest("Ledger limit must be between 1 and 100")
return transactions.inTransaction { it.credits.listLedgerEntries(userId, limit) }
}
override suspend fun getReservation(
userId: UUID,
reservationId: UUID,
): CreditReservation =
transactions.inTransaction { unit ->
requireOwnedReservation(unit, userId, reservationId)
}
/**
* Trusted internal adapters recover ownership from an opaque reservation ID.
* User-facing routes must call the ownership-checking overload above.
*/
suspend fun getReservation(reservationId: UUID): CreditReservation =
transactions.inTransaction { unit ->
unit.credits.lockReservation(reservationId)
?: throw CreditNotFound("Reservation does not exist")
}
override suspend fun grantSignupTrial(
userId: UUID,
credits: Long,
idempotencyKey: String,
): CreditAccount {
if (credits <= 0) throw InvalidCreditRequest("Signup trial credits must be positive")
val key = validatedIdempotencyKey(idempotencyKey)
return transactions.inTransaction { unit ->
val now = clock.instant()
unit.credits.createAccountIfAbsent(userId, now)
val account = unit.credits.lockAccount(userId)
val existing = unit.credits.findLedgerEntry(userId, key)
if (existing != null) {
requireIdempotentLedger(existing, LedgerEntryType.SIGNUP_TRIAL, credits, null)
return@inTransaction account
}
applyLedgerDelta(
unit = unit,
account = account,
delta = credits,
type = LedgerEntryType.SIGNUP_TRIAL,
key = key,
referenceId = null,
now = now,
)
}
}
override suspend fun reserve(
userId: UUID,
provider: String,
model: String,
estimatedUsage: UsageMeasurement,
managedCall: Boolean,
idempotencyKey: String,
): CreditReservation {
val normalizedProvider = validatedName(provider, "Provider")
val normalizedModel = validatedName(model, "Model")
val key = validatedIdempotencyKey(idempotencyKey)
return transactions.inTransaction { unit ->
val now = clock.instant()
unit.credits.createAccountIfAbsent(userId, now)
val account = unit.credits.lockAccount(userId)
unit.credits.findReservationByReserveKey(userId, key)?.let { existing ->
if (existing.provider != normalizedProvider ||
existing.model != normalizedModel ||
existing.estimatedUsage != estimatedUsage ||
existing.managedCall != managedCall
) {
throw CreditConflict("Idempotency key was already used with a different reservation")
}
return@inTransaction existing
}
ensureUnusedLedgerKey(unit, userId, key)
val rate = unit.credits.findEffectiveRate(
estimatedUsage.kind,
normalizedProvider,
normalizedModel,
now,
) ?: throw CreditNotFound("No effective rate exists for this provider and model")
val required = calculatePositiveCost(rate, estimatedUsage, "Estimated usage")
if (account.balance < required) {
throw InsufficientCredits(account.balance, required)
}
val reservationId = newId()
applyLedgerDelta(
unit = unit,
account = account,
delta = -required,
type = LedgerEntryType.USAGE_RESERVE,
key = key,
referenceId = reservationId,
now = now,
)
CreditReservation(
id = reservationId,
userId = userId,
rateVersionId = rate.id,
provider = normalizedProvider,
model = normalizedModel,
estimatedUsage = estimatedUsage,
actualUsage = null,
reservedCredits = required,
settledCredits = null,
status = ReservationStatus.RESERVED,
managedCall = managedCall,
reserveIdempotencyKey = key,
settleIdempotencyKey = null,
releaseIdempotencyKey = null,
refundIdempotencyKey = null,
createdAt = now,
updatedAt = now,
).also(unit.credits::insertReservation)
}
}
override suspend fun settle(
userId: UUID,
reservationId: UUID,
actualUsage: UsageMeasurement,
idempotencyKey: String,
): CreditReservation {
val key = validatedIdempotencyKey(idempotencyKey)
return transactions.inTransaction { unit ->
val now = clock.instant()
val reservation = requireOwnedReservation(unit, userId, reservationId)
if (reservation.status == ReservationStatus.SETTLED &&
reservation.settleIdempotencyKey == key
) {
if (reservation.actualUsage != actualUsage) {
throw CreditConflict(
"Idempotency key was already used with different actual usage",
)
}
return@inTransaction reservation
}
requireReserved(reservation, "settle")
if (reservation.estimatedUsage.kind != actualUsage.kind) {
throw InvalidCreditRequest("Actual usage kind differs from reserved usage kind")
}
val rate = unit.credits.findRateVersion(reservation.rateVersionId)
?: throw CreditNotFound("Reserved rate version no longer exists")
val actualCredits = CreditCostCalculator.calculate(rate, actualUsage)
val binding = if (reservation.managedCall && actualCredits > 0) {
unit.referrals.lockBinding(userId)?.takeIf {
it.rewardStatus == ReferralRewardStatus.PENDING
}
} else {
null
}
val rewardPlan = binding?.let { prepareReferralReward(unit, it, now) }
val accountIds = buildSet {
add(userId)
rewardPlan?.let { add(it.binding.inviterUserId) }
}.sortedBy(UUID::toString)
accountIds.forEach { unit.credits.createAccountIfAbsent(it, now) }
val accounts = accountIds.associateWith(unit.credits::lockAccount).toMutableMap()
ensureUnusedLedgerKey(unit, userId, key)
val account = requireNotNull(accounts[userId])
val settlementDelta = Math.subtractExact(reservation.reservedCredits, actualCredits)
val resultingBalance = Math.addExact(account.balance, settlementDelta)
if (resultingBalance < 0) {
throw InsufficientCredits(account.balance, -settlementDelta)
}
accounts[userId] = applyLedgerDelta(
unit = unit,
account = account,
delta = settlementDelta,
type = LedgerEntryType.USAGE_SETTLE,
key = key,
referenceId = reservation.id,
now = now,
)
val settled = reservation.copy(
actualUsage = actualUsage,
settledCredits = actualCredits,
status = ReservationStatus.SETTLED,
settleIdempotencyKey = key,
updatedAt = now,
)
unit.credits.updateReservation(settled)
unit.credits.insertUsageRecord(
CreditUsageRecord(
id = newId(),
reservationId = reservation.id,
userId = userId,
rateVersionId = rate.id,
usage = actualUsage,
chargedCredits = actualCredits,
createdAt = now,
),
)
if (rewardPlan != null) {
grantReferralRewards(
unit = unit,
bindingId = rewardPlan.binding.id,
inviterUserId = rewardPlan.binding.inviterUserId,
inviteeUserId = rewardPlan.binding.inviteeUserId,
settlementId = reservation.id,
inviterCredits = rewardPlan.inviterCredits,
inviteeCredits = rewardPlan.inviteeCredits,
lockedAccounts = accounts,
now = now,
)
}
settled
}
}
override suspend fun release(
userId: UUID,
reservationId: UUID,
idempotencyKey: String,
): CreditReservation = terminalCreditOperation(
userId = userId,
reservationId = reservationId,
idempotencyKey = idempotencyKey,
targetStatus = ReservationStatus.RELEASED,
ledgerType = LedgerEntryType.USAGE_RELEASE,
amount = { it.reservedCredits },
existingKey = { it.releaseIdempotencyKey },
update = { reservation, key, now ->
reservation.copy(
status = ReservationStatus.RELEASED,
releaseIdempotencyKey = key,
updatedAt = now,
)
},
allowedStatus = ReservationStatus.RESERVED,
)
override suspend fun refund(
userId: UUID,
reservationId: UUID,
idempotencyKey: String,
): CreditReservation = terminalCreditOperation(
userId = userId,
reservationId = reservationId,
idempotencyKey = idempotencyKey,
targetStatus = ReservationStatus.REFUNDED,
ledgerType = LedgerEntryType.USAGE_REFUND,
amount = { requireNotNull(it.settledCredits) },
existingKey = { it.refundIdempotencyKey },
update = { reservation, key, now ->
reservation.copy(
status = ReservationStatus.REFUNDED,
refundIdempotencyKey = key,
updatedAt = now,
)
},
allowedStatus = ReservationStatus.SETTLED,
)
private suspend fun terminalCreditOperation(
userId: UUID,
reservationId: UUID,
idempotencyKey: String,
targetStatus: ReservationStatus,
ledgerType: LedgerEntryType,
amount: (CreditReservation) -> Long,
existingKey: (CreditReservation) -> String?,
update: (CreditReservation, String, Instant) -> CreditReservation,
allowedStatus: ReservationStatus,
): CreditReservation {
val key = validatedIdempotencyKey(idempotencyKey)
return transactions.inTransaction { unit ->
val now = clock.instant()
val reservation = requireOwnedReservation(unit, userId, reservationId)
if (reservation.status == targetStatus && existingKey(reservation) == key) {
return@inTransaction reservation
}
if (reservation.status != allowedStatus ||
!ReservationStateRules.canTransition(reservation.status, targetStatus)
) {
throw CreditConflict(
"Reservation in ${reservation.status} state cannot become $targetStatus",
)
}
val account = unit.credits.lockAccount(userId)
ensureUnusedLedgerKey(unit, userId, key)
applyLedgerDelta(
unit = unit,
account = account,
delta = amount(reservation),
type = ledgerType,
key = key,
referenceId = reservation.id,
now = now,
)
update(reservation, key, now).also(unit.credits::updateReservation)
}
}
private fun grantReferralRewards(
unit: BillingUnitOfWork,
bindingId: UUID,
inviterUserId: UUID,
inviteeUserId: UUID,
settlementId: UUID,
inviterCredits: Long,
inviteeCredits: Long,
lockedAccounts: Map<UUID, CreditAccount>,
now: Instant,
) {
val inviteeKey = "internal:referral:$bindingId:invitee"
val inviterKey = "internal:referral:$bindingId:inviter"
applyLedgerDelta(
unit,
requireNotNull(lockedAccounts[inviteeUserId]),
inviteeCredits,
LedgerEntryType.REFERRAL_INVITEE,
inviteeKey,
bindingId,
now,
)
applyLedgerDelta(
unit,
requireNotNull(lockedAccounts[inviterUserId]),
inviterCredits,
LedgerEntryType.REFERRAL_INVITER,
inviterKey,
bindingId,
now,
)
unit.referrals.markRewarded(bindingId, settlementId, now)
}
private fun prepareReferralReward(
unit: BillingUnitOfWork,
binding: ReferralBinding,
now: Instant,
): ReferralRewardPlan? {
val campaignId = binding.campaignId
?: return ReferralRewardPlan(
binding,
referralRewards.inviterCredits,
referralRewards.inviteeCredits,
)
val campaign = unit.referrals.findCampaign(campaignId)
?: throw CreditNotFound("Referral campaign no longer exists")
val budget = unit.referrals.lockCampaignBudget(campaignId)
val nextCount = addOrNull(budget.rewardedBindings, 1)
val nextSpent = addOrNull(budget.spentCredits, campaign.rewardCost)
if (nextCount == null || nextSpent == null) {
unit.referrals.markRewardIneligible(binding.id)
return null
}
val withinCount = campaign.maxRewardedBindings?.let { nextCount <= it } ?: true
val withinBudget = campaign.budgetCredits?.let { nextSpent <= it } ?: true
if (!withinCount || !withinBudget) {
unit.referrals.markRewardIneligible(binding.id)
return null
}
unit.referrals.updateCampaignBudget(
budget.copy(
rewardedBindings = nextCount,
spentCredits = nextSpent,
updatedAt = now,
),
)
return ReferralRewardPlan(
binding,
campaign.inviterRewardCredits,
campaign.inviteeRewardCredits,
)
}
private fun requireOwnedReservation(
unit: BillingUnitOfWork,
userId: UUID,
reservationId: UUID,
): CreditReservation {
val reservation = unit.credits.lockReservation(reservationId)
?: throw CreditNotFound("Reservation does not exist")
if (reservation.userId != userId) throw CreditNotFound("Reservation does not exist")
return reservation
}
private fun requireReserved(reservation: CreditReservation, operation: String) {
if (!ReservationStateRules.canTransition(reservation.status, ReservationStatus.SETTLED)) {
throw CreditConflict("Reservation in ${reservation.status} state cannot be $operation")
}
}
private fun ensureUnusedLedgerKey(
unit: BillingUnitOfWork,
userId: UUID,
key: String,
) {
if (unit.credits.findLedgerEntry(userId, key) != null) {
throw CreditConflict("Idempotency key was already used by another credit operation")
}
}
private fun applyLedgerDelta(
unit: BillingUnitOfWork,
account: CreditAccount,
delta: Long,
type: LedgerEntryType,
key: String,
referenceId: UUID?,
now: Instant,
): CreditAccount {
val newBalance = try {
Math.addExact(account.balance, delta)
} catch (_: ArithmeticException) {
throw InvalidCreditRequest("Credit balance exceeds the supported integer range")
}
if (newBalance < 0) throw InsufficientCredits(account.balance, -delta)
unit.credits.insertLedgerEntry(
LedgerEntry(
id = newId(),
userId = account.userId,
type = type,
amountDelta = delta,
balanceAfter = newBalance,
idempotencyKey = key,
referenceId = referenceId,
createdAt = now,
),
)
return unit.credits.updateAccountBalance(account.userId, newBalance, now)
}
private fun requireIdempotentLedger(
existing: LedgerEntry,
expectedType: LedgerEntryType,
expectedDelta: Long,
expectedReferenceId: UUID?,
) {
if (existing.type != expectedType ||
existing.amountDelta != expectedDelta ||
existing.referenceId != expectedReferenceId
) {
throw CreditConflict("Idempotency key was already used with different parameters")
}
}
private fun calculatePositiveCost(
rate: CreditRateVersion,
usage: UsageMeasurement,
label: String,
): Long {
val amount = CreditCostCalculator.calculate(rate, usage)
if (amount <= 0) throw InvalidCreditRequest("$label must cost at least one credit")
return amount
}
private fun validatedName(value: String, label: String): String {
val normalized = value.trim()
if (normalized.isEmpty() || normalized.length > 100) {
throw InvalidCreditRequest("$label must contain 1 to 100 characters")
}
return normalized
}
private fun addOrNull(left: Long, right: Long): Long? =
try {
Math.addExact(left, right)
} catch (_: ArithmeticException) {
null
}
private data class ReferralRewardPlan(
val binding: ReferralBinding,
val inviterCredits: Long,
val inviteeCredits: Long,
)
}
@@ -0,0 +1,49 @@
package com.osglab.account.features.gateway
import com.osglab.account.features.gateway.asr.AsrStreamingLimits
import java.time.Duration
/**
* Environment-backed settings supplied by the host application. Provider
* endpoints, models, policies and credentials are never accepted from clients.
*/
data class GatewaySettings(
val issuer: String,
val audience: String,
val accessTokenHmacSecret: ByteArray,
val refreshTokenHmacSecret: ByteArray,
val accessTokenLifetime: Duration = Duration.ofMinutes(5),
val refreshTokenLifetime: Duration = Duration.ofDays(30),
val maximumGrantLifetime: Duration = Duration.ofDays(90),
val llmProviderTimeout: Duration = Duration.ofMinutes(2),
val asrProviderTimeout: Duration = Duration.ofMinutes(6),
val deepSeek: DeepSeekSettings? = null,
val volcengine: VolcengineSettings? = null,
val asrLimits: AsrStreamingLimits = AsrStreamingLimits(),
) {
init {
require(issuer.isNotBlank())
require(audience.isNotBlank())
require(accessTokenHmacSecret.size >= 32)
require(refreshTokenHmacSecret.size >= 32)
require(accessTokenLifetime > Duration.ZERO)
require(refreshTokenLifetime > Duration.ZERO)
require(maximumGrantLifetime >= accessTokenLifetime)
require(llmProviderTimeout > Duration.ZERO)
require(asrProviderTimeout > Duration.ZERO)
}
}
data class DeepSeekSettings(
val endpoint: String,
val apiKey: String,
val model: String,
)
data class VolcengineSettings(
val endpoint: String,
val resourceId: String,
val appId: String? = null,
val accessToken: String? = null,
val apiKey: String? = null,
)
@@ -0,0 +1,172 @@
package com.osglab.account.features.gateway.adapters
import com.osglab.account.features.credits.domain.UsageMeasurement
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.auth.SessionAccessAuthenticator
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewaySubject
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.CreditReservationPort
import com.osglab.account.features.gateway.ports.GatewayIdentityPort
import com.osglab.account.features.gateway.ports.ProviderUsageEstimate
import io.ktor.http.HttpHeaders
import io.ktor.server.application.ApplicationCall
import java.util.UUID
class SessionIdentityAdapter(
private val sessionAuthenticator: SessionAccessAuthenticator,
) : GatewayIdentityPort, AuthenticatedUserExtractor {
override suspend fun resolve(call: ApplicationCall): GatewaySubject? =
principal(call)?.let {
// The host session may mint a grant, while the resulting gateway
// token remains limited to the scopes selected by that grant.
GatewaySubject(
userId = it.accountId.toString(),
scopes = GatewayCapability.entries.toSet(),
)
}
override suspend fun extract(call: ApplicationCall): UUID? = principal(call)?.accountId
private suspend fun principal(call: ApplicationCall) =
call.request.headers[HttpHeaders.Authorization]
?.takeIf { it.startsWith(BEARER_PREFIX, ignoreCase = true) }
?.substring(BEARER_PREFIX.length)
?.trim()
?.takeIf(String::isNotEmpty)
?.let { sessionAuthenticator.authenticate(it) }
private companion object {
const val BEARER_PREFIX = "Bearer "
}
}
class CreditReservationAdapter(
private val creditService: CreditService,
private val llmModel: String,
private val asrModel: String,
) : CreditReservationPort {
override suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
): CreditReservation = reserve(
accountId = accountId,
estimate = ProviderUsageEstimate(
meter = meter,
units = estimatedUnits,
inputUnits = estimatedUnits.takeIf { meter == UsageMeter.LLM_TOKEN },
outputUnits = 0L.takeIf { meter == UsageMeter.LLM_TOKEN },
),
requestId = requestId,
)
override suspend fun reserve(
accountId: String,
estimate: ProviderUsageEstimate,
requestId: String,
): CreditReservation {
val userId = accountId.toUuid()
val usage = estimate.toUsage()
val (provider, model) = providerAndModel(estimate.meter)
val reservation = creditService.reserve(
userId = userId,
provider = provider,
model = model,
estimatedUsage = usage,
managedCall = true,
idempotencyKey = "internal:gateway-reserve:$accountId:$requestId",
)
return CreditReservation(
id = reservation.id.toString(),
reservedUnits = reservation.reservedCredits,
)
}
override suspend fun settle(reservationId: String, actualUnits: Long) {
settle(
reservationId,
ProviderUsage(UsageMeter.AUDIO_MILLISECOND, actualUnits),
)
}
override suspend fun settle(reservationId: String, usage: ProviderUsage) {
val id = reservationId.toUuid()
val reservation = creditService.getReservation(id)
creditService.settle(
userId = reservation.userId,
reservationId = id,
actualUsage = reservation.estimatedUsage.kind.let { kind ->
when (kind) {
com.osglab.account.features.credits.domain.UsageKind.ASR ->
UsageMeasurement.Asr(usage.units)
com.osglab.account.features.credits.domain.UsageKind.LLM ->
UsageMeasurement.Llm(
inputTokens = requireNotNull(usage.inputUnits) {
"LLM provider usage omitted input tokens"
},
outputTokens = requireNotNull(usage.outputUnits) {
"LLM provider usage omitted output tokens"
},
)
}
},
idempotencyKey = "internal:gateway-settle:$reservationId",
)
}
override suspend fun release(reservationId: String) {
val id = reservationId.toUuid()
val reservation = creditService.getReservation(id)
creditService.release(
userId = reservation.userId,
reservationId = id,
idempotencyKey = "internal:gateway-release:$reservationId",
)
}
override suspend fun refund(reservationId: String) {
val id = reservationId.toUuid()
val reservation = creditService.getReservation(id)
creditService.refund(
userId = reservation.userId,
reservationId = id,
idempotencyKey = "internal:gateway-refund:$reservationId",
)
}
private fun providerAndModel(meter: UsageMeter): Pair<String, String> = when (meter) {
UsageMeter.LLM_TOKEN -> DEEPSEEK_PROVIDER to llmModel
UsageMeter.AUDIO_MILLISECOND -> VOLCENGINE_PROVIDER to asrModel
}
private fun ProviderUsageEstimate.toUsage(): UsageMeasurement {
require(units >= 0) { "Estimated usage cannot be negative" }
return when (meter) {
UsageMeter.LLM_TOKEN -> UsageMeasurement.Llm(
inputTokens = requireNotNull(inputUnits) {
"LLM usage estimate omitted input tokens"
},
outputTokens = requireNotNull(outputUnits) {
"LLM usage estimate omitted output tokens"
},
)
UsageMeter.AUDIO_MILLISECOND -> UsageMeasurement.Asr(units)
}
}
private fun String.toUuid(): UUID =
runCatching { UUID.fromString(this) }
.getOrElse { throw IllegalArgumentException("Account or reservation ID is invalid") }
private companion object {
const val DEEPSEEK_PROVIDER = "deepseek"
const val VOLCENGINE_PROVIDER = "volcengine-sauc-v3"
}
}
@@ -0,0 +1,21 @@
package com.osglab.account.features.gateway.agent
import kotlinx.serialization.Serializable
/**
* A declarative plan only. It intentionally has no URL, executable command,
* tool invocation, or provider-controlled action payload.
*/
@Serializable
data class AgentPlan(
val summary: String,
val steps: List<AgentStep>,
val warnings: List<String> = emptyList(),
)
@Serializable
data class AgentStep(
val id: String,
val title: String,
val description: String,
)
@@ -0,0 +1,328 @@
package com.osglab.account.features.gateway.asr
import com.osglab.account.features.gateway.models.AsrGatewayOptions
import com.osglab.account.features.gateway.models.AsrProviderRequest
import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.providers.volcengine.VolcengineUsageException
import com.osglab.account.features.gateway.services.GatewayService
import com.osglab.account.features.gateway.services.PreparedGatewayRequest
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import kotlin.time.TimeSource
typealias KtorVolcengineStreamingClient =
com.osglab.account.features.gateway.providers.volcengine.KtorVolcengineAsrTransport
typealias VolcengineStreamingClient =
com.osglab.account.features.gateway.providers.volcengine.VolcengineStreamingClient
@Serializable
data class CreateAsrSessionRequest(
val format: String = "pcm",
val codec: String = "raw",
val sampleRate: Int = 16_000,
val bits: Int = 16,
val channels: Int = 1,
val language: String? = null,
val estimatedDurationMillis: Long,
)
@Serializable
data class CreateAsrSessionResponse(
val sessionId: String,
val websocketPath: String,
val maxFrameBytes: Int,
val idleTimeoutMillis: Long,
)
data class AsrStreamingLimits(
val maxFrameBytes: Int = GatewayLimits.MAX_AUDIO_FRAME_BYTES,
val maxFrames: Int = GatewayLimits.MAX_AUDIO_FRAMES,
val maxAudioBytes: Long = GatewayLimits.MAX_AUDIO_BYTES.toLong(),
val maxDurationMillis: Long = GatewayLimits.MAX_AUDIO_MILLIS,
val maxConcurrentSessionsPerUser: Int = 2,
val idleTimeoutMillis: Long = 15_000,
val connectTimeoutMillis: Long = 30_000,
) {
init {
require(maxFrameBytes > 0)
require(maxFrames > 0)
require(maxAudioBytes > 0)
require(maxDurationMillis > 0)
require(maxConcurrentSessionsPerUser > 0)
require(idleTimeoutMillis > 0)
require(connectTimeoutMillis > 0)
}
}
/**
* Owns only short-lived session metadata. Audio frames are forwarded from the
* downstream flow to the upstream client and are never written to disk.
*/
class AsrStreamingService(
private val gateway: GatewayService,
private val upstream: VolcengineStreamingClient,
private val scope: CoroutineScope,
val limits: AsrStreamingLimits = AsrStreamingLimits(),
) {
private val sessions = ConcurrentHashMap<String, Session>()
private val userGates = ConcurrentHashMap<String, UserGate>()
suspend fun createSession(
principal: GatewayPrincipal,
requestId: String,
request: CreateAsrSessionRequest,
): CreateAsrSessionResponse {
val options = request.toOptions()
.let {
val rawPcm = it.codec == "raw" && it.format in setOf("pcm", "wav")
if (!rawPcm) it.copy(estimatedDurationMillis = limits.maxDurationMillis)
else it
}
.also(::validateOptions)
val gate = userGates.compute(principal.userId) { _, existing ->
(existing ?: UserGate(Semaphore(limits.maxConcurrentSessionsPerUser))).also {
it.activeSessions.incrementAndGet()
}
}!!
if (!gate.permits.tryAcquire()) {
releaseGate(principal.userId, gate, releasePermit = false)
throw AsrConcurrencyLimitException()
}
val prepared = try {
gateway.prepare(
subject = principal,
request = AsrProviderRequest(
requestId = requestId,
options = options,
audio = ByteArray(0),
),
)
} catch (failure: Throwable) {
releaseGate(principal.userId, gate)
throw failure
}
val sessionId = UUID.randomUUID().toString()
val session = Session(
id = sessionId,
principal = principal,
requestId = requestId,
options = options,
prepared = prepared,
gate = gate,
)
sessions[sessionId] = session
session.expiry = scope.launch {
delay(limits.connectTimeoutMillis)
expire(session)
}
return CreateAsrSessionResponse(
sessionId = sessionId,
websocketPath = "/v1/gateway/asr/sessions/$sessionId/stream",
maxFrameBytes = limits.maxFrameBytes,
idleTimeoutMillis = limits.idleTimeoutMillis,
)
}
suspend fun stream(
sessionId: String,
principal: GatewayPrincipal,
audioFrames: Flow<ByteArray>,
output: ProviderOutput,
): ProviderUsage {
val session = sessions[sessionId] ?: throw AsrSessionNotFoundException()
if (session.principal.userId != principal.userId ||
session.principal.grantId != principal.grantId
) {
throw AsrSessionNotFoundException()
}
if (!session.state.compareAndSet(SessionState.READY, SessionState.STREAMING)) {
throw AsrSessionAlreadyUsedException()
}
session.expiry?.cancel()
val started = TimeSource.Monotonic.markNow()
val result = try {
withTimeout(limits.maxDurationMillis) {
upstream.transcribe(session.options, bounded(audioFrames, session.options), output)
}
} catch (failure: Throwable) {
release(session, failure)
throw failure
}
if (!result.hasResult || result.durationMillis !in 1..session.options.estimatedDurationMillis) {
val failure = VolcengineUsageException("ASR result was empty or duration exceeded reservation")
release(session, failure)
throw failure
}
val usage = ProviderUsage(
meter = UsageMeter.AUDIO_MILLISECOND,
units = result.durationMillis,
providerRequestId = result.providerRequestId,
serverDurationMillis = started.elapsedNow().inWholeMilliseconds.coerceAtLeast(1),
)
try {
gateway.settlePrepared(session.prepared, usage)
session.state.set(SessionState.SETTLED)
return usage
} finally {
// Once upstream succeeded, settlement failure must not release the
// reservation. The credit port owns idempotent retry/reconciliation.
finish(session)
}
}
private fun bounded(
source: Flow<ByteArray>,
options: AsrGatewayOptions,
): Flow<ByteArray> = flow {
var frames = 0
var bytes = 0L
val rawPcm = options.codec == "raw" && options.format in setOf("pcm", "wav")
val acceptedBytes = if (rawPcm) {
val bytesPerMillisecond = Math.multiplyExact(
options.sampleRate.toLong(),
Math.multiplyExact(options.bits.toLong(), options.channels.toLong()),
) / 8_000L
val audioBytes = Math.multiplyExact(options.estimatedDurationMillis, bytesPerMillisecond)
if (options.format == "wav") {
Math.addExact(audioBytes, WAV_HEADER_ALLOWANCE_BYTES)
} else {
audioBytes
}
} else {
limits.maxAudioBytes
}
val boundedAcceptedBytes = minOf(limits.maxAudioBytes, acceptedBytes)
try {
source.collect { frame ->
require(frame.isNotEmpty()) { "audio frame must not be empty" }
require(frame.size <= limits.maxFrameBytes) { "audio frame exceeds the limit" }
frames = Math.addExact(frames, 1)
require(frames <= limits.maxFrames) { "audio frame count exceeds the limit" }
bytes = Math.addExact(bytes, frame.size.toLong())
require(bytes <= boundedAcceptedBytes) { "audio stream exceeds the declared duration" }
emit(frame)
}
} catch (failure: CancellationException) {
throw failure
}
}
private suspend fun expire(session: Session) {
if (session.state.compareAndSet(SessionState.READY, SessionState.RELEASED)) {
runCatching { gateway.releasePrepared(session.prepared, AsrSessionExpiredException()) }
finish(session)
}
}
private suspend fun release(session: Session, failure: Throwable) {
if (session.state.getAndSet(SessionState.RELEASED) != SessionState.RELEASED) {
withContext(NonCancellable) {
runCatching { gateway.releasePrepared(session.prepared, failure) }
.onFailure(failure::addSuppressed)
}
}
finish(session)
}
private fun finish(session: Session) {
if (sessions.remove(session.id, session)) {
releaseGate(session.principal.userId, session.gate)
}
session.expiry?.cancel()
}
private fun releaseGate(
userId: String,
gate: UserGate,
releasePermit: Boolean = true,
) {
if (releasePermit) gate.permits.release()
userGates.compute(userId) { _, current ->
if (current !== gate) {
current
} else if (gate.activeSessions.decrementAndGet() == 0) {
null
} else {
gate
}
}
}
private fun validateOptions(options: AsrGatewayOptions) {
require(options.estimatedDurationMillis in 1..limits.maxDurationMillis) {
"estimatedDurationMillis is out of range"
}
require(options.format in setOf("pcm", "wav", "ogg", "mp3")) { "unsupported audio format" }
require(options.codec in setOf("raw", "opus")) { "unsupported audio codec" }
require(options.sampleRate == 16_000) { "only 16000 Hz audio is supported" }
require(options.bits == 16) { "only 16-bit audio is supported" }
require(options.channels in 1..2) { "channels must be 1 or 2" }
require(options.language == null || options.language.length <= 32) { "language is too long" }
require(options.format != "ogg" || options.codec == "opus") { "ogg audio requires opus codec" }
}
private fun CreateAsrSessionRequest.toOptions() = AsrGatewayOptions(
format = format,
codec = codec,
sampleRate = sampleRate,
bits = bits,
channels = channels,
language = language,
estimatedDurationMillis = estimatedDurationMillis,
)
private data class Session(
val id: String,
val principal: GatewayPrincipal,
val requestId: String,
val options: AsrGatewayOptions,
val prepared: PreparedGatewayRequest,
val gate: UserGate,
val state: AtomicReference<SessionState> = AtomicReference(SessionState.READY),
var expiry: Job? = null,
)
private data class UserGate(
val permits: Semaphore,
val activeSessions: AtomicInteger = AtomicInteger(),
)
private enum class SessionState {
READY,
STREAMING,
SETTLED,
RELEASED,
}
private companion object {
const val WAV_HEADER_ALLOWANCE_BYTES = 44L
}
}
class AsrConcurrencyLimitException : RuntimeException("Too many concurrent ASR sessions")
class AsrSessionNotFoundException : RuntimeException("ASR session was not found")
class AsrSessionAlreadyUsedException : RuntimeException("ASR session was already used")
class AsrSessionExpiredException : RuntimeException("ASR session expired before connection")
@@ -0,0 +1,243 @@
package com.osglab.account.features.gateway.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Serializable
enum class GatewayCapability {
@SerialName("polish")
POLISH,
@SerialName("ai")
AI,
@SerialName("agent")
AGENT,
@SerialName("asr")
ASR,
}
@Serializable
enum class UsageMeter {
@SerialName("llm_token")
LLM_TOKEN,
@SerialName("audio_millisecond")
AUDIO_MILLISECOND,
}
/**
* Authenticated identity supplied by the host application. The gateway never
* parses or verifies JWTs itself.
*/
data class GatewayPrincipal(
val userId: String,
val grantId: String? = null,
// Callers must grant capabilities explicitly. An identity with omitted
// scopes is intentionally unable to invoke a managed provider.
val scopes: Set<GatewayCapability> = emptySet(),
) {
// Kept as a compatibility name for the existing account-scoped persistence.
val accountId: String
get() = userId
}
typealias GatewaySubject = GatewayPrincipal
@Serializable
data class TextGatewayRequest(
val input: String,
val context: String? = null,
val maxOutputTokens: Int = 512,
val temperature: Double = 0.2,
val stream: Boolean = false,
)
@Serializable
data class AsrGatewayOptions(
val format: String = "pcm",
val codec: String = "raw",
val sampleRate: Int = 16_000,
val bits: Int = 16,
val channels: Int = 1,
val language: String? = null,
val estimatedDurationMillis: Long,
)
sealed interface ProviderRequest {
val requestId: String
val capability: GatewayCapability
}
data class TextProviderRequest(
override val requestId: String,
override val capability: GatewayCapability,
val input: String,
val context: String?,
val maxOutputTokens: Int,
val temperature: Double,
val stream: Boolean,
) : ProviderRequest
data class AsrProviderRequest(
override val requestId: String,
val options: AsrGatewayOptions,
val audio: ByteArray,
) : ProviderRequest {
override val capability: GatewayCapability = GatewayCapability.ASR
}
data class ProviderUsage(
val meter: UsageMeter,
val units: Long,
val providerRequestId: String? = null,
val inputUnits: Long? = null,
val outputUnits: Long? = null,
val serverDurationMillis: Long = 0,
)
fun interface ProviderOutput {
suspend fun emit(bytes: ByteArray)
}
object GatewayLimits {
const val MAX_JSON_BODY_BYTES = 300 * 1024
const val MAX_AUDIO_BYTES = 20 * 1024 * 1024
const val MAX_AUDIO_FRAME_BYTES = 64 * 1024
const val MAX_AUDIO_FRAMES = 10_000
const val MAX_UPSTREAM_RESPONSE_BYTES = 8 * 1024 * 1024
const val MAX_SSE_EVENTS = 8_192
const val MAX_SSE_LINE_BYTES = 128 * 1024
const val MAX_AUDIO_MILLIS = 10 * 60 * 1_000L
const val MAX_TEXT_INPUT_CHARS = 32_000
const val MAX_TEXT_CONTEXT_CHARS = 32_000
const val MAX_OUTPUT_TOKENS = 4_096
}
object TextRequestPolicy {
fun validate(
request: TextGatewayRequest,
capability: GatewayCapability? = null,
) {
require(request.input.isNotBlank()) { "input must not be blank" }
require(request.input.length <= GatewayLimits.MAX_TEXT_INPUT_CHARS) { "input is too long" }
require((request.context?.length ?: 0) <= GatewayLimits.MAX_TEXT_CONTEXT_CHARS) {
"context is too long"
}
require(request.maxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS) {
"maxOutputTokens is out of range"
}
require(request.temperature in 0.0..1.0 && request.temperature.isFinite()) {
"temperature is out of range"
}
require(capability != GatewayCapability.AGENT || !request.stream) {
"agent requests must be non-streaming so the structured result can be validated"
}
}
}
object AudioDurationPolicy {
fun reservationMillis(
audioBytes: Int,
options: AsrGatewayOptions,
): Long {
require(audioBytes in 1..GatewayLimits.MAX_AUDIO_BYTES) {
"audio exceeds the gateway limit"
}
require(options.estimatedDurationMillis in 1..GatewayLimits.MAX_AUDIO_MILLIS) {
"estimatedDurationMillis is out of range"
}
val rawPcm = options.codec == "raw" &&
(options.format == "pcm" || options.format == "wav")
if (rawPcm) {
val payloadBytes = if (options.format == "wav") {
(audioBytes - WAV_HEADER_ALLOWANCE_BYTES).coerceAtLeast(1)
} else {
audioBytes
}
val bitsPerSecond = Math.multiplyExact(
Math.multiplyExact(options.sampleRate.toLong(), options.bits.toLong()),
options.channels.toLong(),
)
val computed = ceilDivide(
Math.multiplyExact(payloadBytes.toLong(), 8_000L),
bitsPerSecond,
)
val tolerance = maxOf(100L, computed / 10L)
require(options.estimatedDurationMillis + tolerance >= computed) {
"declared audio duration is shorter than the PCM payload"
}
return maxOf(computed, options.estimatedDurationMillis)
.coerceAtMost(GatewayLimits.MAX_AUDIO_MILLIS)
}
// Compressed duration cannot be proven from bytes alone. Reject an
// impossible low declaration and reserve the full accepted duration.
val minimumFromBytes = ceilDivide(
Math.multiplyExact(audioBytes.toLong(), 8_000L),
MAX_COMPRESSED_BITS_PER_SECOND,
)
require(options.estimatedDurationMillis >= minimumFromBytes) {
"declared audio duration is implausible for the compressed payload"
}
return GatewayLimits.MAX_AUDIO_MILLIS
}
private fun ceilDivide(numerator: Long, denominator: Long): Long =
Math.addExact(numerator, denominator - 1L) / denominator
private const val WAV_HEADER_ALLOWANCE_BYTES = 44
private const val MAX_COMPRESSED_BITS_PER_SECOND = 512_000L
}
@Serializable
data class ProviderDescriptor(
val id: String,
val capabilities: Set<GatewayCapability>,
val streaming: Boolean,
val usageMeter: UsageMeter,
)
@Serializable
data class GatewayCatalogResponse(
val providers: List<ProviderDescriptor>,
)
@Serializable
data class GatewayErrorResponse(
val code: String,
val message: String,
val requestId: String,
)
@Serializable
data class CreateGatewayGrantRequest(
val scopes: Set<GatewayCapability>,
val lifetimeSeconds: Long? = null,
)
@Serializable
data class RefreshGatewayGrantRequest(
val refreshToken: String,
)
@Serializable
data class GatewayGrantTokens(
val grantId: String,
val scopes: Set<GatewayCapability>,
val accessToken: String,
val accessExpiresAt: String,
val refreshToken: String,
val refreshExpiresAt: String,
)
data class GatewayGrant(
val id: String,
val accountId: String,
val scopes: Set<GatewayCapability>,
val expiresAt: Instant,
val revokedAt: Instant? = null,
)
@@ -0,0 +1,8 @@
package com.osglab.account.features.gateway.polish
typealias DeepSeekClient =
com.osglab.account.features.gateway.providers.deepseek.DeepSeekClient
typealias KtorDeepSeekClient =
com.osglab.account.features.gateway.providers.deepseek.KtorDeepSeekClient
typealias DeepSeekGatewayProvider =
com.osglab.account.features.gateway.providers.deepseek.DeepSeekProvider
@@ -0,0 +1,200 @@
package com.osglab.account.features.gateway.ports
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayGrant
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import io.ktor.server.application.ApplicationCall
import java.time.Instant
data class CreditReservation(
val id: String,
val reservedUnits: Long,
)
data class ProviderUsageEstimate(
val meter: UsageMeter,
val units: Long,
val inputUnits: Long? = null,
val outputUnits: Long? = null,
)
/**
* The account/credit feature implements this port. Implementations must make
* reserve and settle idempotent for the supplied request ID.
*/
interface CreditReservationPort {
suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
): CreditReservation
suspend fun reserve(
accountId: String,
estimate: ProviderUsageEstimate,
requestId: String,
): CreditReservation =
reserve(accountId, estimate.meter, estimate.units, requestId)
suspend fun settle(reservationId: String, actualUnits: Long)
suspend fun settle(reservationId: String, usage: ProviderUsage) {
settle(reservationId, usage.units)
}
suspend fun release(reservationId: String)
/**
* Reverses an already settled reservation. Implementations must make this
* operation idempotent for the reservation.
*/
suspend fun refund(reservationId: String) {
throw UnsupportedOperationException("Billing refund is not configured")
}
}
typealias BillingPort = CreditReservationPort
typealias CreditMeterPort = CreditReservationPort
typealias CreditMeter = CreditReservationPort
/**
* Authentication remains outside the gateway. Return null when the call has
* no valid application identity.
*/
interface GatewayPrincipalResolver {
suspend fun resolve(call: ApplicationCall): GatewayPrincipal?
}
fun interface GatewayPrincipalPort : GatewayPrincipalResolver
typealias GatewayIdentityPort = GatewayPrincipalPort
fun interface GatewayAccessTokenPort : GatewayPrincipalResolver
/**
* Grant lookup remains outside the routes so gateway_grants can be backed by
* the account database without coupling this feature to its schema library.
*/
fun interface GatewayGrantPort {
suspend fun isAllowed(accountId: String, capability: GatewayCapability): Boolean
}
data class NewGatewayGrant(
val id: String,
val accountId: String,
val idempotencyKey: String,
val scopes: Set<GatewayCapability>,
val expiresAt: Instant,
val refreshTokenId: String,
val refreshFamilyId: String,
val refreshTokenHash: String,
val refreshExpiresAt: Instant,
)
data class StoredGatewayRefresh(
val grant: GatewayGrant,
val tokenId: String,
val familyId: String,
val expiresAt: Instant,
)
sealed interface GatewayRefreshRotationResult {
data class Rotated(val refresh: StoredGatewayRefresh) : GatewayRefreshRotationResult
data object Invalid : GatewayRefreshRotationResult
data object ReuseDetected : GatewayRefreshRotationResult
}
/**
* Security-sensitive grant and rotating-refresh state. Implementations must
* lock the current refresh row while rotating it.
*/
interface GatewayGrantRepository : GatewayGrantPort {
suspend fun create(grant: NewGatewayGrant, now: Instant): StoredGatewayRefresh
suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): GatewayRefreshRotationResult
suspend fun revoke(accountId: String, grantId: String, now: Instant): Boolean
suspend fun findActive(
grantId: String,
accountId: String,
scopes: Set<GatewayCapability>,
now: Instant,
): GatewayGrant?
}
data class ProviderRequestMetadata(
val requestId: String,
val accountId: String,
val reservationId: String,
val providerId: String,
val capability: GatewayCapability,
)
data class ProviderRefund(
val requestId: String,
val accountId: String,
val reservationId: String,
)
enum class ProviderRequestState {
CLAIMED,
STARTED,
SETTLEMENT_PENDING,
SETTLED,
RELEASED,
MANUAL_REVIEW,
}
data class PendingSettlement(
val requestId: String,
val accountId: String,
val reservationId: String,
val usage: ProviderUsage,
)
class GatewayRequestAlreadyClaimedException(
val state: ProviderRequestState,
) : RuntimeException("Gateway request is already ${state.name.lowercase()}")
/**
* Persists metadata and usage only. Request prompts, audio and provider
* response bodies must never be passed to this port.
*/
interface GatewayUsagePort {
/**
* Atomically claims an account-scoped request ID. Any existing state is a
* terminal replay from the gateway's perspective and must not call upstream.
*/
suspend fun claim(metadata: ProviderRequestMetadata)
suspend fun markStarted(accountId: String, requestId: String)
suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
)
suspend fun markSucceeded(accountId: String, requestId: String, usage: ProviderUsage)
suspend fun markReleased(accountId: String, requestId: String, errorCode: String)
suspend fun markManualReview(accountId: String, requestId: String, errorCode: String)
suspend fun findSettlementPending(limit: Int): List<PendingSettlement>
suspend fun markRefunded(accountId: String, requestId: String) = Unit
suspend fun findRefundPending(limit: Int): List<ProviderRefund> = emptyList()
}
@@ -0,0 +1,43 @@
package com.osglab.account.features.gateway.providers
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
interface GatewayProvider {
val descriptor: ProviderDescriptor
fun accepts(request: ProviderRequest): Boolean =
request.capability in descriptor.capabilities
suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage
}
class ProviderCatalog(
providers: Collection<GatewayProvider>,
) {
private val providers = providers.toList()
init {
require(this.providers.map { it.descriptor.id }.distinct().size == this.providers.size) {
"Gateway provider IDs must be unique"
}
}
fun descriptors(): List<ProviderDescriptor> =
providers.map(GatewayProvider::descriptor).sortedBy(ProviderDescriptor::id)
fun providerFor(request: ProviderRequest): GatewayProvider =
providers.firstOrNull { it.accepts(request) }
?: throw UnsupportedGatewayCapabilityException(request.capability.name.lowercase())
}
class UnsupportedGatewayCapabilityException(capability: String) :
IllegalArgumentException("No gateway provider is configured for capability '$capability'")
/**
* Upstream returned an invalid metering/result envelope. Gateway orchestration
* treats this as provider failure and releases the reservation.
*/
open class ProviderCompletionException(message: String) : RuntimeException(message)
@@ -0,0 +1,448 @@
package com.osglab.account.features.gateway.providers.deepseek
import com.osglab.account.features.gateway.agent.AgentPlan
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCompletionException
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.header
import io.ktor.client.request.preparePost
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.Url
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.utils.io.readLineStrict
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readRemaining
import kotlinx.io.readByteArray
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class DeepSeekConfig(
val endpoint: String,
val apiKey: String,
val model: String,
) {
init {
val url = runCatching { Url(endpoint) }
.getOrElse { throw DeepSeekConfigurationException("DEEPSEEK_ENDPOINT is invalid") }
if (url.protocol.name != "https") {
throw DeepSeekConfigurationException("DEEPSEEK_ENDPOINT must use HTTPS")
}
if (apiKey.isBlank()) {
throw DeepSeekConfigurationException("DEEPSEEK_API_KEY must not be blank")
}
if (model.isBlank()) {
throw DeepSeekConfigurationException("DEEPSEEK_MODEL must not be blank")
}
}
}
fun interface DeepSeekClient {
suspend fun complete(request: TextProviderRequest, output: ProviderOutput): ProviderUsage
}
class DeepSeekProvider(
private val upstream: DeepSeekClient,
) : GatewayProvider {
constructor(
client: HttpClient,
config: DeepSeekConfig,
json: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
},
) : this(KtorDeepSeekClient(client, config, json))
override val descriptor = ProviderDescriptor(
id = "deepseek",
capabilities = setOf(
GatewayCapability.POLISH,
GatewayCapability.AI,
GatewayCapability.AGENT,
),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
override fun accepts(request: ProviderRequest): Boolean =
request is TextProviderRequest && super.accepts(request)
override suspend fun execute(
request: ProviderRequest,
output: ProviderOutput,
): ProviderUsage {
require(request is TextProviderRequest) { "DeepSeek only accepts text requests" }
validate(request)
return upstream.complete(request, output)
}
private fun validate(request: TextProviderRequest) {
require(request.input.isNotBlank()) { "input must not be blank" }
require(request.input.length <= GatewayLimits.MAX_TEXT_INPUT_CHARS) { "input is too long" }
require((request.context?.length ?: 0) <= GatewayLimits.MAX_TEXT_CONTEXT_CHARS) {
"context is too long"
}
require(request.maxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS) {
"maxOutputTokens is out of range"
}
require(request.temperature in 0.0..1.0 && request.temperature.isFinite()) {
"temperature is out of range"
}
require(request.capability != GatewayCapability.AGENT || !request.stream) {
"agent requests must be non-streaming"
}
}
}
/**
* Production DeepSeek client. Endpoint and model exist only in server-side
* configuration and cannot be overridden by a gateway request.
*/
class KtorDeepSeekClient(
private val client: HttpClient,
private val config: DeepSeekConfig,
private val json: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
},
) : DeepSeekClient {
override suspend fun complete(
request: TextProviderRequest,
output: ProviderOutput,
): ProviderUsage {
val payload = DeepSeekChatRequest(
model = config.model,
messages = controlledMessages(request),
maxTokens = request.maxOutputTokens,
temperature = request.temperature,
stream = request.stream,
streamOptions = if (request.stream) StreamOptions(includeUsage = true) else null,
responseFormat = if (request.capability == GatewayCapability.AGENT) {
ResponseFormat(type = "json_object")
} else {
null
},
)
return client.preparePost("${config.endpoint.trimEnd('/')}/chat/completions") {
bearerAuth(config.apiKey)
contentType(ContentType.Application.Json)
header(HttpHeaders.Accept, if (request.stream) ContentType.Text.EventStream else ContentType.Application.Json)
header("X-Request-ID", request.requestId)
setBody(payload)
}.execute { response ->
if (!response.status.isSuccess()) {
// Consume but never log or persist a provider body.
runCatching { response.body<ByteReadChannel>().readBounded() }
throw DeepSeekProviderException("DeepSeek returned HTTP ${response.status.value}")
}
val expectedContentType = if (request.stream) {
ContentType.Text.EventStream
} else {
ContentType.Application.Json
}
val responseContentType = response.headers[HttpHeaders.ContentType]
?.let { runCatching { ContentType.parse(it) }.getOrNull() }
if (responseContentType?.match(expectedContentType) != true) {
// Consume the bounded body without exposing it to logs or callers.
runCatching { response.body<ByteReadChannel>().readBounded() }
throw DeepSeekProviderException("DeepSeek returned an unexpected content type")
}
if (request.stream) {
forwardSse(response.body(), request, output)
} else {
forwardJson(response.body<ByteReadChannel>().readBounded(), request, output)
}
}
}
private suspend fun forwardJson(
bytes: ByteArray,
request: TextProviderRequest,
output: ProviderOutput,
): ProviderUsage {
val payload = bytes.decodeToString()
val content = extractAssistantContent(payload)
?: throw DeepSeekEmptyResultException()
if (content.isBlank()) throw DeepSeekEmptyResultException()
if (request.capability == GatewayCapability.AGENT) validateAgentContent(content)
val usage = extractUsage(payload)?.toProviderUsageOrNull()
?: throw DeepSeekProviderException("DeepSeek response omitted token usage")
output.emit(bytes)
return usage
}
private suspend fun forwardSse(
channel: io.ktor.utils.io.ByteReadChannel,
request: TextProviderRequest,
output: ProviderOutput,
): ProviderUsage {
var usage: DeepSeekUsage? = null
var providerUsage: ProviderUsage? = null
var emittedBytes = 0L
var eventCount = 0
var contentBytes = 0L
var terminalChoiceSeen = false
var doneSeen = false
val assistantContent = StringBuilder()
while (true) {
val line = channel.readLineStrict(
limit = GatewayLimits.MAX_SSE_LINE_BYTES.toLong(),
) ?: break
val encoded = "$line\n".encodeToByteArray()
emittedBytes = Math.addExact(emittedBytes, encoded.size.toLong())
if (emittedBytes > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
throw DeepSeekProviderException("DeepSeek stream exceeded the response limit")
}
if (line.startsWith("data:")) {
eventCount += 1
if (eventCount > GatewayLimits.MAX_SSE_EVENTS) {
throw DeepSeekProviderException("DeepSeek stream exceeded the event limit")
}
val data = line.removePrefix("data:").trim()
if (data == "[DONE]") {
if (!terminalChoiceSeen) {
throw DeepSeekProviderException("DeepSeek stream ended without a terminal choice")
}
providerUsage = usage?.toProviderUsageOrNull()
?: throw DeepSeekProviderException("DeepSeek stream omitted token usage")
doneSeen = true
output.emit(encoded)
break
}
val event = runCatching { json.parseToJsonElement(data).jsonObject }
.getOrElse { throw DeepSeekProviderException("DeepSeek returned malformed SSE data") }
if (event["error"] != null && event["error"] !is JsonNull) {
throw DeepSeekProviderException("DeepSeek returned an error event")
}
val eventUsage = extractUsage(data)
eventUsage?.let { usage = it }
val choices = runCatching {
event["choices"]?.jsonArray
?: throw IllegalArgumentException("choices is missing")
}.getOrElse {
throw DeepSeekProviderException("DeepSeek SSE data omitted valid choices")
}
val choice = runCatching { choices.firstOrNull()?.jsonObject }
.getOrElse {
throw DeepSeekProviderException("DeepSeek returned an invalid choice")
}
if (choice == null && eventUsage == null) {
throw DeepSeekProviderException("DeepSeek returned an empty non-usage event")
}
val finishReason = choice?.get("finish_reason")
if (finishReason != null && finishReason !is JsonNull) {
if (!finishReason.jsonPrimitive.isString ||
finishReason.jsonPrimitive.content.isBlank()
) {
throw DeepSeekProviderException("DeepSeek returned an invalid finish reason")
}
terminalChoiceSeen = true
}
extractStreamContent(data)?.let { chunk ->
contentBytes = Math.addExact(contentBytes, chunk.encodeToByteArray().size.toLong())
if (contentBytes > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
throw DeepSeekProviderException("DeepSeek content exceeded the response limit")
}
assistantContent.append(chunk)
}
}
output.emit(encoded)
}
if (!doneSeen) throw DeepSeekProviderException("DeepSeek stream closed before [DONE]")
if (assistantContent.isBlank()) throw DeepSeekEmptyResultException()
if (request.capability == GatewayCapability.AGENT) {
validateAgentContent(assistantContent.toString())
}
return requireNotNull(providerUsage)
}
private fun extractUsage(payload: String): DeepSeekUsage? =
runCatching {
val usage = json.parseToJsonElement(payload).jsonObject["usage"]?.jsonObject ?: return null
val input = usage["prompt_tokens"]?.jsonPrimitive?.content?.toLong()
val output = usage["completion_tokens"]?.jsonPrimitive?.content?.toLong()
val total = usage["total_tokens"]?.jsonPrimitive?.content?.toLong()
?: if (input != null && output != null) Math.addExact(input, output) else return null
DeepSeekUsage(total, input, output)
}.getOrNull()
private fun DeepSeekUsage.toProviderUsageOrNull(): ProviderUsage? {
val inputTokens = input ?: return null
val outputTokens = output ?: return null
if (inputTokens < 0 || outputTokens < 0 || total < 0 ||
Math.addExact(inputTokens, outputTokens) != total
) {
throw DeepSeekUsageException("DeepSeek returned inconsistent token usage")
}
return ProviderUsage(
meter = UsageMeter.LLM_TOKEN,
units = total,
inputUnits = inputTokens,
outputUnits = outputTokens,
)
}
private fun validateAgentContent(content: String) {
val plan = runCatching {
STRICT_AGENT_JSON.decodeFromString<AgentPlan>(content)
}.getOrElse {
throw DeepSeekProviderException("Agent response did not match the required schema")
}
if (plan.summary.isBlank() ||
plan.summary.length > MAX_AGENT_FIELD_CHARS ||
plan.steps.isEmpty() ||
plan.steps.size > MAX_AGENT_STEPS ||
plan.steps.any {
it.id.isBlank() ||
it.title.isBlank() ||
it.description.isBlank() ||
it.id.length > MAX_AGENT_ID_CHARS ||
it.title.length > MAX_AGENT_FIELD_CHARS ||
it.description.length > MAX_AGENT_FIELD_CHARS
} ||
plan.warnings.size > MAX_AGENT_WARNINGS ||
plan.warnings.any { it.length > MAX_AGENT_FIELD_CHARS }
) {
throw DeepSeekProviderException("Agent response exceeded the structured-result policy")
}
}
private fun extractAssistantContent(payload: String): String? = runCatching {
json.parseToJsonElement(payload)
.jsonObject["choices"]
?.let { choices -> choices.jsonArray.firstOrNull() }
?.jsonObject
?.get("message")
?.jsonObject
?.get("content")
?.jsonPrimitive
?.takeIf { it.isString }
?.content
}.getOrNull()
private fun extractStreamContent(payload: String): String? = runCatching {
json.parseToJsonElement(payload)
.jsonObject["choices"]
?.let { choices -> choices.jsonArray.firstOrNull() }
?.jsonObject
?.get("delta")
?.jsonObject
?.get("content")
?.jsonPrimitive
?.takeIf { it.isString }
?.content
}.getOrNull()
private suspend fun ByteReadChannel.readBounded(): ByteArray {
val bytes = readRemaining(GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES.toLong() + 1L)
.readByteArray()
if (bytes.size > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
throw DeepSeekProviderException("DeepSeek response exceeded the gateway limit")
}
return bytes
}
private fun controlledMessages(request: TextProviderRequest): List<ChatMessage> {
val system = when (request.capability) {
GatewayCapability.POLISH ->
"Polish the user's text while preserving meaning. Return only the polished text."
GatewayCapability.AI ->
"Answer the user's question accurately and concisely. Do not claim actions you did not perform."
GatewayCapability.AGENT ->
"""
Return only JSON with this schema:
{"summary":"string","steps":[{"id":"string","title":"string","description":"string"}],"warnings":["string"]}.
Produce a declarative plan only. Never execute actions, invoke tools, include commands or URLs,
or claim that any client-side or external side effect occurred.
""".trimIndent()
GatewayCapability.ASR -> error("ASR is not a DeepSeek capability")
}
val userText = buildString {
request.context?.takeIf(String::isNotBlank)?.let {
append("Context:\n")
append(it)
append("\n\n")
}
append(request.input)
}
return listOf(ChatMessage("system", system), ChatMessage("user", userText))
}
private companion object {
const val MAX_AGENT_ID_CHARS = 128
const val MAX_AGENT_FIELD_CHARS = 4_096
const val MAX_AGENT_STEPS = 64
const val MAX_AGENT_WARNINGS = 64
val STRICT_AGENT_JSON = Json {
ignoreUnknownKeys = false
explicitNulls = false
}
}
}
@Serializable
private data class DeepSeekChatRequest(
val model: String,
val messages: List<ChatMessage>,
@SerialName("max_tokens")
val maxTokens: Int,
val temperature: Double,
val stream: Boolean,
@SerialName("stream_options")
val streamOptions: StreamOptions?,
@SerialName("response_format")
val responseFormat: ResponseFormat?,
)
@Serializable
private data class ChatMessage(
val role: String,
val content: String,
)
@Serializable
private data class StreamOptions(
@SerialName("include_usage")
val includeUsage: Boolean,
)
@Serializable
private data class ResponseFormat(
val type: String,
)
class DeepSeekConfigurationException(message: String) : IllegalStateException(message)
class DeepSeekProviderException(message: String) : RuntimeException(message)
class DeepSeekUsageException(message: String) : ProviderCompletionException(message)
class DeepSeekEmptyResultException : RuntimeException("DeepSeek returned an empty result")
private data class DeepSeekUsage(
val total: Long,
val input: Long?,
val output: Long?,
)
@@ -0,0 +1,248 @@
package com.osglab.account.features.gateway.providers.volcengine
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
enum class SaucMessageType(val code: Int) {
FULL_CLIENT_REQUEST(0x1),
AUDIO_ONLY_REQUEST(0x2),
FULL_SERVER_RESPONSE(0x9),
ERROR_RESPONSE(0xF),
;
companion object {
fun from(code: Int): SaucMessageType =
entries.firstOrNull { it.code == code }
?: throw SaucProtocolException("Unsupported SAUC message type: $code")
}
}
enum class SaucSerialization(val code: Int) {
RAW(0),
JSON(1),
}
enum class SaucCompression(val code: Int) {
NONE(0),
GZIP(1),
}
data class SaucFrame(
val type: SaucMessageType,
val flags: Int,
val serialization: SaucSerialization,
val compression: SaucCompression,
val payload: ByteArray,
val sequence: Int? = null,
val errorCode: Int? = null,
) {
val isLast: Boolean
get() = flags == FLAG_LAST_WITHOUT_SEQUENCE || flags == FLAG_LAST_WITH_SEQUENCE
companion object {
const val FLAG_LAST_WITHOUT_SEQUENCE = 0x2
const val FLAG_LAST_WITH_SEQUENCE = 0x3
}
}
/**
* Codec for the binary framing documented for the SAUC v3 API. The API name is
* v3, while the binary header's protocol-version nibble is currently v1.
*/
class SaucV3Codec(
private val maxPayloadBytes: Int = 4 * 1024 * 1024,
) {
fun fullClientRequest(jsonPayload: ByteArray): ByteArray =
encode(
type = SaucMessageType.FULL_CLIENT_REQUEST,
flags = 0,
serialization = SaucSerialization.JSON,
compression = SaucCompression.GZIP,
payload = gzip(jsonPayload),
)
fun audioRequest(audio: ByteArray, isLast: Boolean): ByteArray =
encode(
type = SaucMessageType.AUDIO_ONLY_REQUEST,
flags = if (isLast) SaucFrame.FLAG_LAST_WITHOUT_SEQUENCE else 0,
serialization = SaucSerialization.RAW,
compression = SaucCompression.GZIP,
payload = gzip(audio),
)
fun decodeServerFrame(bytes: ByteArray): SaucFrame {
if (bytes.size < MIN_FRAME_BYTES) {
throw SaucProtocolException("SAUC frame is shorter than 8 bytes")
}
val protocolVersion = bytes[0].toInt().ushr(4) and 0x0F
val headerWords = bytes[0].toInt() and 0x0F
if (protocolVersion != PROTOCOL_VERSION) {
throw SaucProtocolException("Unsupported SAUC protocol version: $protocolVersion")
}
if (headerWords < 1) {
throw SaucProtocolException("Invalid SAUC header size")
}
val headerBytes = headerWords * 4
if (headerBytes > bytes.size - 4) {
throw SaucProtocolException("SAUC header exceeds frame bounds")
}
val type = SaucMessageType.from(bytes[1].toInt().ushr(4) and 0x0F)
if (type != SaucMessageType.FULL_SERVER_RESPONSE && type != SaucMessageType.ERROR_RESPONSE) {
throw SaucProtocolException("Unexpected server message type: $type")
}
val flags = bytes[1].toInt() and 0x0F
if (type == SaucMessageType.FULL_SERVER_RESPONSE &&
flags != FLAG_SEQUENCE &&
flags != SaucFrame.FLAG_LAST_WITH_SEQUENCE
) {
throw SaucProtocolException("SAUC server response must include a sequence number")
}
if (type == SaucMessageType.ERROR_RESPONSE && flags != 0) {
throw SaucProtocolException("SAUC error response used unsupported flags")
}
val serializationCode = bytes[2].toInt().ushr(4) and 0x0F
val serialization = SaucSerialization.entries.firstOrNull { it.code == serializationCode }
?: throw SaucProtocolException(
"Unsupported SAUC serialization value: $serializationCode",
)
val compressionCode = bytes[2].toInt() and 0x0F
val compression = SaucCompression.entries.firstOrNull { it.code == compressionCode }
?: throw SaucProtocolException(
"Unsupported SAUC compression value: $compressionCode",
)
if (serialization != SaucSerialization.JSON) {
throw SaucProtocolException("SAUC server response must use JSON serialization")
}
if (bytes[3].toInt() != 0) {
throw SaucProtocolException("SAUC reserved header byte must be zero")
}
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN)
buffer.position(headerBytes)
val sequence = when {
type == SaucMessageType.FULL_SERVER_RESPONSE &&
(flags == FLAG_SEQUENCE || flags == SaucFrame.FLAG_LAST_WITH_SEQUENCE) ->
requireInt(buffer, "sequence")
else -> null
}
val errorCode = if (type == SaucMessageType.ERROR_RESPONSE) {
requireInt(buffer, "error code")
} else {
null
}
val payloadSize = requireInt(buffer, "payload size")
if (payloadSize < 0 || payloadSize > maxPayloadBytes) {
throw SaucProtocolException("SAUC payload size is outside the allowed range")
}
if (buffer.remaining() != payloadSize) {
throw SaucProtocolException(
"SAUC payload size mismatch: declared $payloadSize, received ${buffer.remaining()}",
)
}
val encodedPayload = ByteArray(payloadSize).also(buffer::get)
val payload = when (compression) {
SaucCompression.NONE -> encodedPayload
SaucCompression.GZIP -> gunzip(encodedPayload)
}
return SaucFrame(
type = type,
flags = flags,
serialization = serialization,
compression = compression,
payload = payload,
sequence = sequence,
errorCode = errorCode,
)
}
private fun encode(
type: SaucMessageType,
flags: Int,
serialization: SaucSerialization,
compression: SaucCompression,
payload: ByteArray,
): ByteArray {
require(payload.size <= maxPayloadBytes) { "SAUC payload exceeds configured maximum" }
val buffer = ByteBuffer.allocate(MIN_FRAME_BYTES + payload.size).order(ByteOrder.BIG_ENDIAN)
buffer.put(((PROTOCOL_VERSION shl 4) or HEADER_WORDS).toByte())
buffer.put(((type.code shl 4) or flags).toByte())
buffer.put(((serialization.code shl 4) or compression.code).toByte())
buffer.put(0.toByte())
buffer.putInt(payload.size)
buffer.put(payload)
return buffer.array()
}
private fun requireInt(buffer: ByteBuffer, field: String): Int {
if (buffer.remaining() < Int.SIZE_BYTES) {
throw SaucProtocolException("SAUC frame is missing $field")
}
return buffer.int
}
private fun gzip(bytes: ByteArray): ByteArray =
ByteArrayOutputStream().use { output ->
GZIPOutputStream(output).use { it.write(bytes) }
output.toByteArray()
}
private fun gunzip(bytes: ByteArray): ByteArray =
runCatching {
GZIPInputStream(ByteArrayInputStream(bytes)).use { input ->
val decoded = input.readNBytes(maxPayloadBytes + 1)
if (decoded.size > maxPayloadBytes) {
throw SaucProtocolException("Decompressed SAUC payload exceeds the allowed range")
}
decoded
}
}.getOrElse {
if (it is SaucProtocolException) throw it
throw SaucProtocolException("Invalid gzip payload", it)
}
private companion object {
const val PROTOCOL_VERSION = 1
const val HEADER_WORDS = 1
const val MIN_FRAME_BYTES = 8
const val FLAG_SEQUENCE = 0x1
}
}
class SaucSequenceValidator {
private var lastSequence = 0
private var finalSeen = false
fun accept(frame: SaucFrame) {
if (frame.type == SaucMessageType.ERROR_RESPONSE) return
if (finalSeen) throw SaucProtocolException("SAUC frame arrived after the final frame")
val sequence = frame.sequence
?: throw SaucProtocolException("SAUC server frame omitted its sequence")
val expected = Math.addExact(lastSequence, 1)
if (frame.isLast) {
if (sequence >= 0 || Math.abs(sequence.toLong()) != expected.toLong()) {
throw SaucProtocolException("SAUC final sequence is invalid")
}
finalSeen = true
} else {
if (sequence != expected) {
throw SaucProtocolException("SAUC sequence is not strictly increasing")
}
lastSequence = sequence
}
}
}
class SaucProtocolException(message: String, cause: Throwable? = null) :
RuntimeException(message, cause)
@@ -0,0 +1,400 @@
package com.osglab.account.features.gateway.providers.volcengine
import com.osglab.account.features.gateway.models.AsrGatewayOptions
import com.osglab.account.features.gateway.models.AsrProviderRequest
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCompletionException
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.http.Url
import io.ktor.websocket.Frame
import io.ktor.websocket.readBytes
import io.ktor.websocket.send
import java.util.UUID
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class VolcengineAsrConfig(
val endpoint: String,
val resourceId: String,
val appId: String? = null,
val accessToken: String? = null,
val apiKey: String? = null,
val responseTimeoutMillis: Long = 360_000,
) {
init {
val url = runCatching { Url(endpoint) }
.getOrElse { throw VolcengineConfigurationException("VOLCENGINE_ASR_ENDPOINT is invalid") }
if (url.protocol.name != "wss") {
throw VolcengineConfigurationException("VOLCENGINE_ASR_ENDPOINT must use WSS")
}
if (resourceId.isBlank()) {
throw VolcengineConfigurationException("VOLCENGINE_RESOURCE_ID must not be blank")
}
val hasNewCredential = !apiKey.isNullOrBlank()
val hasLegacyCredential = !appId.isNullOrBlank() && !accessToken.isNullOrBlank()
if (!hasNewCredential && !hasLegacyCredential) {
throw VolcengineConfigurationException(
"Configure VOLCENGINE_API_KEY or both VOLCENGINE_APP_ID and VOLCENGINE_ACCESS_TOKEN",
)
}
if (responseTimeoutMillis <= 0) {
throw VolcengineConfigurationException("Volcengine response timeout must be positive")
}
}
}
data class AsrTransportResult(
val durationMillis: Long,
val providerRequestId: String,
val hasResult: Boolean = true,
)
/**
* Transport boundary kept separate from the provider and billing orchestration,
* allowing protocol behavior and failure paths to be tested without a network.
*/
fun interface VolcengineAsrTransport {
suspend fun transcribe(
request: AsrProviderRequest,
output: ProviderOutput,
): AsrTransportResult
}
/**
* Streaming upstream boundary. Tests can inject a mock without opening a
* socket; the production implementation forwards audio frames in memory.
*/
fun interface VolcengineStreamingClient {
suspend fun transcribe(
options: AsrGatewayOptions,
audioFrames: Flow<ByteArray>,
output: ProviderOutput,
): AsrTransportResult
}
class KtorVolcengineAsrTransport(
private val client: HttpClient,
private val config: VolcengineAsrConfig,
private val codec: SaucV3Codec = SaucV3Codec(),
private val json: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
},
) : VolcengineAsrTransport, VolcengineStreamingClient {
override suspend fun transcribe(
request: AsrProviderRequest,
output: ProviderOutput,
): AsrTransportResult = transcribe(
options = request.options,
audioFrames = flow {
var start = 0
while (start < request.audio.size) {
val end = minOf(start + AUDIO_CHUNK_BYTES, request.audio.size)
emit(request.audio.copyOfRange(start, end))
start = end
}
},
output = output,
)
override suspend fun transcribe(
options: AsrGatewayOptions,
audioFrames: Flow<ByteArray>,
output: ProviderOutput,
): AsrTransportResult {
val providerRequestId = UUID.randomUUID().toString()
var finalDurationMillis: Long? = null
var finalHasResult = false
var outputBytes = 0L
var frameCount = 0
val sequenceValidator = SaucSequenceValidator()
withTimeout(config.responseTimeoutMillis) {
client.webSocket(
urlString = config.endpoint,
request = {
headers.append("X-Api-Resource-Id", config.resourceId)
headers.append("X-Api-Request-Id", providerRequestId)
headers.append("X-Api-Connect-Id", providerRequestId)
headers.append("X-Api-Sequence", "-1")
val apiKey = config.apiKey?.takeIf(String::isNotBlank)
if (apiKey != null) {
headers.append("X-Api-Key", apiKey)
} else {
headers.append("X-Api-App-Key", requireNotNull(config.appId))
headers.append("X-Api-Access-Key", requireNotNull(config.accessToken))
}
},
) {
coroutineScope {
val receiver = launch {
for (webSocketFrame in incoming) {
if (webSocketFrame !is Frame.Binary) {
throw SaucProtocolException("Volcengine returned a non-binary WebSocket frame")
}
val frame = codec.decodeServerFrame(webSocketFrame.readBytes())
sequenceValidator.accept(frame)
if (frame.type == SaucMessageType.ERROR_RESPONSE) {
throw VolcengineProviderException(
"Volcengine SAUC error ${frame.errorCode ?: "unknown"}",
)
}
frameCount += 1
if (frameCount > GatewayLimits.MAX_SSE_EVENTS) {
throw VolcengineProviderException("Volcengine returned too many ASR frames")
}
outputBytes = Math.addExact(
outputBytes,
frame.payload.size.toLong() + 1L,
)
if (outputBytes > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
throw VolcengineProviderException("Volcengine ASR output exceeded the limit")
}
if (frame.isLast) {
finalDurationMillis = extractFinalDuration(frame, json)
finalHasResult = hasRecognitionResult(frame.payload, json)
}
// The transcript is forwarded only; it is never logged or persisted.
output.emit(frame.payload + "\n".encodeToByteArray())
if (frame.isLast) {
return@launch
}
}
throw VolcengineProviderException(
"Volcengine closed before the final ASR frame",
)
}
send(
Frame.Binary(
fin = true,
data = codec.fullClientRequest(buildFullRequest(options)),
),
)
var pending: ByteArray? = null
val packet = ByteArray(AUDIO_CHUNK_BYTES)
var packetSize = 0
suspend fun queuePacket(next: ByteArray) {
pending?.let { previous ->
send(Frame.Binary(fin = true, data = codec.audioRequest(previous, false)))
delay(AUDIO_PACKET_INTERVAL_MILLIS)
}
pending = next
}
audioFrames.collect { next ->
require(next.isNotEmpty()) { "ASR audio frame must not be empty" }
require(next.size <= GatewayLimits.MAX_AUDIO_FRAME_BYTES) {
"ASR audio frame exceeds the gateway limit"
}
var offset = 0
while (offset < next.size) {
val copied = minOf(AUDIO_CHUNK_BYTES - packetSize, next.size - offset)
next.copyInto(packet, packetSize, offset, offset + copied)
packetSize += copied
offset += copied
if (packetSize == AUDIO_CHUNK_BYTES) {
queuePacket(packet.copyOf())
packetSize = 0
}
}
}
if (packetSize > 0) {
queuePacket(packet.copyOf(packetSize))
}
val finalAudio = pending
?: throw VolcengineProviderException("ASR stream contained no audio")
send(Frame.Binary(fin = true, data = codec.audioRequest(finalAudio, true)))
try {
receiver.join()
} finally {
receiver.cancel()
}
}
}
}
return AsrTransportResult(
durationMillis = requireNotNull(finalDurationMillis),
providerRequestId = providerRequestId,
hasResult = finalHasResult,
)
}
private fun buildFullRequest(options: AsrGatewayOptions): ByteArray {
val payload = FullAsrRequest(
audio = AudioOptions(
format = options.format,
codec = options.codec,
rate = options.sampleRate,
bits = options.bits,
channel = options.channels,
language = options.language,
),
request = RecognitionOptions(),
)
return json.encodeToString(payload).encodeToByteArray()
}
private companion object {
// Approximately 200 ms for 16 kHz, 16-bit mono PCM; compressed formats
// still use this bounded transport chunk size.
const val AUDIO_CHUNK_BYTES = 6_400
const val AUDIO_PACKET_INTERVAL_MILLIS = 100L
}
}
class VolcengineAsrProvider(
private val transport: VolcengineAsrTransport,
) : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "volcengine-sauc-v3",
capabilities = setOf(GatewayCapability.ASR),
streaming = true,
usageMeter = UsageMeter.AUDIO_MILLISECOND,
)
override fun accepts(request: ProviderRequest): Boolean =
request is AsrProviderRequest
override suspend fun execute(
request: ProviderRequest,
output: ProviderOutput,
): ProviderUsage {
require(request is AsrProviderRequest) { "Volcengine only accepts ASR requests" }
validate(request)
val result = transport.transcribe(request, output)
if (!result.hasResult ||
result.durationMillis !in 1..request.options.estimatedDurationMillis
) {
throw VolcengineUsageException("Volcengine returned an empty or invalid ASR result")
}
return ProviderUsage(
meter = UsageMeter.AUDIO_MILLISECOND,
units = result.durationMillis,
providerRequestId = result.providerRequestId,
)
}
private fun validate(request: AsrProviderRequest) {
require(request.audio.isNotEmpty()) { "audio must not be empty" }
require(request.audio.size <= GatewayLimits.MAX_AUDIO_BYTES) { "audio exceeds the gateway limit" }
require(request.options.estimatedDurationMillis in 1..GatewayLimits.MAX_AUDIO_MILLIS) {
"estimatedDurationMillis is out of range"
}
require(request.options.format in setOf("pcm", "wav", "ogg", "mp3")) {
"unsupported audio format"
}
require(request.options.codec in setOf("raw", "opus")) { "unsupported audio codec" }
require(request.options.sampleRate == 16_000) { "only 16000 Hz audio is supported" }
require(request.options.bits == 16) { "only 16-bit audio is supported" }
require(request.options.channels in 1..2) { "channels must be 1 or 2" }
require(request.options.format != "ogg" || request.options.codec == "opus") {
"ogg audio requires opus codec"
}
}
}
@Serializable
private data class FullAsrRequest(
val audio: AudioOptions,
val request: RecognitionOptions,
)
@Serializable
private data class AudioOptions(
val format: String,
val codec: String,
val rate: Int,
val bits: Int,
val channel: Int,
val language: String? = null,
)
@Serializable
private data class RecognitionOptions(
@SerialName("model_name")
val modelName: String = "bigmodel",
@SerialName("result_type")
val resultType: String = "full",
@SerialName("show_utterances")
val showUtterances: Boolean = true,
)
class VolcengineConfigurationException(message: String) : IllegalStateException(message)
class VolcengineProviderException(message: String) : RuntimeException(message)
class VolcengineUsageException(message: String) : ProviderCompletionException(message)
internal fun extractFinalDuration(
frame: SaucFrame,
json: Json = Json { ignoreUnknownKeys = true },
): Long {
if (!frame.isLast) {
throw VolcengineUsageException("Only the final ASR frame may provide billable duration")
}
val duration = runCatching {
json.parseToJsonElement(frame.payload.decodeToString())
.jsonObject["audio_info"]
?.jsonObject
?.get("duration")
?.jsonPrimitive
?.content
?.toLong()
}.getOrNull()
?: throw VolcengineUsageException("Final ASR response omitted audio_info.duration")
if (duration !in 0..GatewayLimits.MAX_AUDIO_MILLIS) {
throw VolcengineUsageException("Final ASR duration is outside the allowed range")
}
return duration
}
internal fun hasRecognitionResult(
payload: ByteArray,
json: Json = Json { ignoreUnknownKeys = true },
): Boolean = runCatching {
val result = json.parseToJsonElement(payload.decodeToString()).jsonObject["result"]
when (result) {
is JsonObject -> result.hasRecognizedText()
is JsonArray -> result.any { (it as? JsonObject)?.hasRecognizedText() == true }
null, JsonNull -> false
else -> false
}
}.getOrDefault(false)
private fun JsonObject.hasRecognizedText(): Boolean {
val directText = this["text"]
?.let { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() }
if (!directText.isNullOrBlank()) return true
return (this["utterances"] as? JsonArray).orEmpty().any { utterance ->
(utterance as? JsonObject)
?.get("text")
?.let { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() }
?.isNotBlank() == true
}
}
@@ -0,0 +1,485 @@
package com.osglab.account.features.gateway.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayGrant
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
import com.osglab.account.features.gateway.ports.GatewayRefreshRotationResult
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.ports.NewGatewayGrant
import com.osglab.account.features.gateway.ports.PendingSettlement
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
import com.osglab.account.features.gateway.ports.ProviderRequestState
import com.osglab.account.features.gateway.ports.StoredGatewayRefresh
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Clock
private object ProviderRequestsTable : Table("provider_requests") {
val requestId = varchar("request_id", 64)
val accountId = varchar("account_id", 36)
val reservationId = varchar("reservation_id", 36).nullable()
val providerId = varchar("provider_id", 64)
val capability = varchar("capability", 32)
val status = varchar("status", 24)
val providerRequestId = varchar("provider_request_id", 128).nullable()
val usageMeter = varchar("usage_meter", 32).nullable()
val usageUnits = long("usage_units").nullable()
val usageInputUnits = long("usage_input_units").nullable()
val usageOutputUnits = long("usage_output_units").nullable()
val serverDurationMillis = long("server_duration_millis").nullable()
val errorCode = varchar("error_code", 96).nullable()
val createdAt = timestamp("created_at")
val completedAt = timestamp("completed_at").nullable()
override val primaryKey = PrimaryKey(accountId, requestId)
}
private object UsageRecordsTable : Table("usage_records") {
val id = long("id").autoIncrement()
val accountId = varchar("account_id", 36)
val requestId = varchar("request_id", 64)
val meter = varchar("meter", 32)
val units = long("units")
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object GatewayGrantsTable : Table("gateway_grants") {
val id = varchar("id", 36)
val accountId = varchar("account_id", 36)
val idempotencyKey = varchar("idempotency_key", 128)
val expiresAt = timestamp("expires_at")
val revokedAt = timestamp("revoked_at").nullable()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
private object GatewayGrantScopesTable : Table("gateway_grant_scopes") {
val grantId = varchar("grant_id", 36)
val capability = varchar("capability", 32)
override val primaryKey = PrimaryKey(grantId, capability)
}
private object GatewayRefreshTokensTable : Table("gateway_refresh_tokens") {
val id = varchar("id", 36)
val grantId = varchar("grant_id", 36)
val familyId = varchar("family_id", 36)
val tokenHash = char("token_hash", 64)
val replacedById = varchar("replaced_by_id", 36).nullable()
val rotationIdempotencyKey = varchar("rotation_idempotency_key", 128).nullable()
val expiresAt = timestamp("expires_at")
val revokedAt = timestamp("revoked_at").nullable()
val reuseDetectedAt = timestamp("reuse_detected_at").nullable()
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
class ExposedGatewayRepository(
private val databaseFactory: DatabaseFactory,
private val clock: Clock = Clock.systemUTC(),
) : GatewayGrantRepository, GatewayUsagePort {
override suspend fun isAllowed(accountId: String, capability: GatewayCapability): Boolean =
databaseFactory.query {
val now = clock.instant()
GatewayGrantsTable.selectAll()
.where {
(GatewayGrantsTable.accountId eq accountId) and
GatewayGrantsTable.revokedAt.isNull() and
(GatewayGrantsTable.expiresAt greater now)
}
.any { row ->
GatewayGrantScopesTable.selectAll()
.where {
(GatewayGrantScopesTable.grantId eq row[GatewayGrantsTable.id]) and
(GatewayGrantScopesTable.capability eq capability.name)
}
.limit(1)
.singleOrNull() != null
}
}
override suspend fun create(grant: NewGatewayGrant, now: java.time.Instant): StoredGatewayRefresh =
databaseFactory.query {
val inserted = GatewayGrantsTable.insertIgnore {
it[id] = grant.id
it[accountId] = grant.accountId
it[idempotencyKey] = grant.idempotencyKey
it[expiresAt] = grant.expiresAt
it[createdAt] = now
it[updatedAt] = now
}.insertedCount == 1
if (!inserted) {
val existing = GatewayGrantsTable.selectAll()
.where {
(GatewayGrantsTable.accountId eq grant.accountId) and
(GatewayGrantsTable.idempotencyKey eq grant.idempotencyKey)
}
.forUpdate()
.single()
val stored = existing.toGrant()
require(stored.scopes == grant.scopes) {
"Idempotency key was already used with a different gateway grant"
}
return@query activeRefresh(stored)
}
grant.scopes.forEach { scope ->
GatewayGrantScopesTable.insert {
it[grantId] = grant.id
it[capability] = scope.name
}
}
GatewayRefreshTokensTable.insert {
it[id] = grant.refreshTokenId
it[grantId] = grant.id
it[familyId] = grant.refreshFamilyId
it[tokenHash] = grant.refreshTokenHash
it[expiresAt] = minOf(grant.refreshExpiresAt, grant.expiresAt)
it[createdAt] = now
}
StoredGatewayRefresh(
grant = GatewayGrant(grant.id, grant.accountId, grant.scopes, grant.expiresAt),
tokenId = grant.refreshTokenId,
familyId = grant.refreshFamilyId,
expiresAt = minOf(grant.refreshExpiresAt, grant.expiresAt),
)
}
override suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: java.time.Instant,
now: java.time.Instant,
): GatewayRefreshRotationResult = databaseFactory.query {
val current = GatewayRefreshTokensTable.selectAll()
.where { GatewayRefreshTokensTable.tokenHash eq currentTokenHash }
.forUpdate()
.singleOrNull()
?: return@query GatewayRefreshRotationResult.Invalid
val grantRow = GatewayGrantsTable.selectAll()
.where { GatewayGrantsTable.id eq current[GatewayRefreshTokensTable.grantId] }
.forUpdate()
.single()
current[GatewayRefreshTokensTable.replacedById]?.let { replacedBy ->
if (current[GatewayRefreshTokensTable.rotationIdempotencyKey] == rotationIdempotencyKey) {
val replacement = GatewayRefreshTokensTable.selectAll()
.where { GatewayRefreshTokensTable.id eq replacedBy }
.single()
return@query GatewayRefreshRotationResult.Rotated(
replacement.toStoredRefresh(grantRow.toGrant()),
)
}
GatewayRefreshTokensTable.update({
GatewayRefreshTokensTable.familyId eq current[GatewayRefreshTokensTable.familyId]
}) {
it[revokedAt] = now
}
GatewayRefreshTokensTable.update({ GatewayRefreshTokensTable.id eq current[GatewayRefreshTokensTable.id] }) {
it[reuseDetectedAt] = now
}
GatewayGrantsTable.update({ GatewayGrantsTable.id eq grantRow[GatewayGrantsTable.id] }) {
it[revokedAt] = now
it[updatedAt] = now
}
return@query GatewayRefreshRotationResult.ReuseDetected
}
if (current[GatewayRefreshTokensTable.revokedAt] != null ||
!current[GatewayRefreshTokensTable.expiresAt].isAfter(now) ||
grantRow[GatewayGrantsTable.revokedAt] != null ||
!grantRow[GatewayGrantsTable.expiresAt].isAfter(now)
) {
GatewayRefreshTokensTable.update({ GatewayRefreshTokensTable.id eq current[GatewayRefreshTokensTable.id] }) {
it[revokedAt] = now
}
return@query GatewayRefreshRotationResult.Invalid
}
val expiresAt = minOf(newExpiresAt, grantRow[GatewayGrantsTable.expiresAt])
GatewayRefreshTokensTable.insert {
it[id] = newTokenId
it[grantId] = current[GatewayRefreshTokensTable.grantId]
it[familyId] = current[GatewayRefreshTokensTable.familyId]
it[tokenHash] = newTokenHash
it[GatewayRefreshTokensTable.expiresAt] = expiresAt
it[createdAt] = now
}
GatewayRefreshTokensTable.update({ GatewayRefreshTokensTable.id eq current[GatewayRefreshTokensTable.id] }) {
it[replacedById] = newTokenId
it[GatewayRefreshTokensTable.rotationIdempotencyKey] = rotationIdempotencyKey
it[revokedAt] = now
}
GatewayRefreshRotationResult.Rotated(
StoredGatewayRefresh(
grant = grantRow.toGrant(),
tokenId = newTokenId,
familyId = current[GatewayRefreshTokensTable.familyId],
expiresAt = expiresAt,
),
)
}
override suspend fun revoke(accountId: String, grantId: String, now: java.time.Instant): Boolean =
databaseFactory.query {
val changed = GatewayGrantsTable.update({
(GatewayGrantsTable.id eq grantId) and
(GatewayGrantsTable.accountId eq accountId) and
GatewayGrantsTable.revokedAt.isNull()
}) {
it[revokedAt] = now
it[updatedAt] = now
}
if (changed > 0) {
GatewayRefreshTokensTable.update({ GatewayRefreshTokensTable.grantId eq grantId }) {
it[revokedAt] = now
}
}
changed > 0
}
override suspend fun findActive(
grantId: String,
accountId: String,
scopes: Set<GatewayCapability>,
now: java.time.Instant,
): GatewayGrant? = databaseFactory.query {
GatewayGrantsTable.selectAll()
.where {
(GatewayGrantsTable.id eq grantId) and
(GatewayGrantsTable.accountId eq accountId) and
GatewayGrantsTable.revokedAt.isNull() and
(GatewayGrantsTable.expiresAt greater now)
}
.singleOrNull()
?.toGrant()
?.takeIf { it.scopes == scopes }
}
override suspend fun claim(metadata: ProviderRequestMetadata) {
databaseFactory.query {
val inserted = ProviderRequestsTable.insertIgnore {
it[requestId] = metadata.requestId
it[accountId] = metadata.accountId
it[reservationId] = metadata.reservationId
it[providerId] = metadata.providerId
it[capability] = metadata.capability.name
it[status] = ProviderRequestState.CLAIMED.name
it[createdAt] = clock.instant()
}.insertedCount == 1
if (!inserted) {
val existing = ProviderRequestsTable.selectAll()
.where {
(ProviderRequestsTable.accountId eq metadata.accountId) and
(ProviderRequestsTable.requestId eq metadata.requestId)
}
.single()
throw GatewayRequestAlreadyClaimedException(existing.requestState())
}
}
}
override suspend fun markStarted(accountId: String, requestId: String) {
transition(accountId, requestId, ProviderRequestState.CLAIMED, ProviderRequestState.STARTED)
}
override suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
) {
databaseFactory.query {
val changed = ProviderRequestsTable.update({
requestKey(accountId, requestId) and
(ProviderRequestsTable.status eq ProviderRequestState.STARTED.name)
}) {
it[status] = ProviderRequestState.SETTLEMENT_PENDING.name
it[providerRequestId] = usage.providerRequestId
it[usageMeter] = usage.meter.name
it[usageUnits] = usage.units
it[usageInputUnits] = usage.inputUnits
it[usageOutputUnits] = usage.outputUnits
it[serverDurationMillis] = usage.serverDurationMillis
it[errorCode] = null
}
check(changed == 1) { "Gateway request cannot enter settlement pending" }
}
}
override suspend fun markSucceeded(
accountId: String,
requestId: String,
usage: ProviderUsage,
) {
databaseFactory.query {
val now = clock.instant()
val inserted = UsageRecordsTable.insertIgnore {
it[UsageRecordsTable.accountId] = accountId
it[UsageRecordsTable.requestId] = requestId
it[meter] = usage.meter.name
it[units] = usage.units
it[createdAt] = now
}.insertedCount == 1
if (!inserted) {
val existing = UsageRecordsTable.selectAll()
.where {
(UsageRecordsTable.accountId eq accountId) and
(UsageRecordsTable.requestId eq requestId) and
(UsageRecordsTable.meter eq usage.meter.name)
}
.single()
check(existing[UsageRecordsTable.units] == usage.units) {
"Usage retry differs from the recorded value"
}
}
val changed = ProviderRequestsTable.update({
requestKey(accountId, requestId) and
(
(ProviderRequestsTable.status eq ProviderRequestState.SETTLEMENT_PENDING.name) or
(ProviderRequestsTable.status eq ProviderRequestState.SETTLED.name)
)
}) {
it[status] = ProviderRequestState.SETTLED.name
it[providerRequestId] = usage.providerRequestId
it[completedAt] = now
it[errorCode] = null
}
check(changed == 1) { "Gateway request cannot be marked succeeded" }
}
}
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) {
databaseFactory.query {
val changed = ProviderRequestsTable.update({
requestKey(accountId, requestId) and
(
(ProviderRequestsTable.status eq ProviderRequestState.CLAIMED.name) or
(ProviderRequestsTable.status eq ProviderRequestState.STARTED.name)
)
}) {
it[status] = ProviderRequestState.RELEASED.name
it[ProviderRequestsTable.errorCode] = errorCode.take(96)
it[completedAt] = clock.instant()
}
check(changed == 1) { "Gateway request cannot be released" }
}
}
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) {
databaseFactory.query {
val changed = ProviderRequestsTable.update({
requestKey(accountId, requestId) and
(
(ProviderRequestsTable.status eq ProviderRequestState.CLAIMED.name) or
(ProviderRequestsTable.status eq ProviderRequestState.STARTED.name) or
(ProviderRequestsTable.status eq ProviderRequestState.SETTLEMENT_PENDING.name)
)
}) {
it[status] = ProviderRequestState.MANUAL_REVIEW.name
it[ProviderRequestsTable.errorCode] = errorCode.take(96)
}
check(changed == 1) { "Gateway request cannot enter manual review" }
}
}
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> {
require(limit in 1..1_000)
return databaseFactory.query {
ProviderRequestsTable.selectAll()
.where { ProviderRequestsTable.status eq ProviderRequestState.SETTLEMENT_PENDING.name }
.orderBy(ProviderRequestsTable.createdAt)
.limit(limit)
.map { row ->
PendingSettlement(
requestId = row[ProviderRequestsTable.requestId],
accountId = row[ProviderRequestsTable.accountId],
reservationId = requireNotNull(row[ProviderRequestsTable.reservationId]),
usage = ProviderUsage(
meter = UsageMeter.valueOf(requireNotNull(row[ProviderRequestsTable.usageMeter])),
units = requireNotNull(row[ProviderRequestsTable.usageUnits]),
providerRequestId = row[ProviderRequestsTable.providerRequestId],
inputUnits = row[ProviderRequestsTable.usageInputUnits],
outputUnits = row[ProviderRequestsTable.usageOutputUnits],
serverDurationMillis = row[ProviderRequestsTable.serverDurationMillis] ?: 0,
),
)
}
}
}
private suspend fun transition(
accountId: String,
requestId: String,
from: ProviderRequestState,
to: ProviderRequestState,
) {
databaseFactory.query {
val changed = ProviderRequestsTable.update({
requestKey(accountId, requestId) and (ProviderRequestsTable.status eq from.name)
}) {
it[status] = to.name
}
check(changed == 1) { "Gateway request cannot transition from $from to $to" }
}
}
private fun org.jetbrains.exposed.v1.core.ResultRow.toGrant(): GatewayGrant {
val grantId = this[GatewayGrantsTable.id]
val scopes = GatewayGrantScopesTable.selectAll()
.where { GatewayGrantScopesTable.grantId eq grantId }
.map { GatewayCapability.valueOf(it[GatewayGrantScopesTable.capability]) }
.toSet()
return GatewayGrant(
id = grantId,
accountId = this[GatewayGrantsTable.accountId],
scopes = scopes,
expiresAt = this[GatewayGrantsTable.expiresAt],
revokedAt = this[GatewayGrantsTable.revokedAt],
)
}
private fun activeRefresh(grant: GatewayGrant): StoredGatewayRefresh {
val row = GatewayRefreshTokensTable.selectAll()
.where {
(GatewayRefreshTokensTable.grantId eq grant.id) and
GatewayRefreshTokensTable.replacedById.isNull() and
GatewayRefreshTokensTable.revokedAt.isNull()
}
.singleOrNull()
?: throw IllegalStateException("Idempotent gateway grant has no active refresh token")
return row.toStoredRefresh(grant)
}
private fun org.jetbrains.exposed.v1.core.ResultRow.toStoredRefresh(
grant: GatewayGrant,
): StoredGatewayRefresh = StoredGatewayRefresh(
grant = grant,
tokenId = this[GatewayRefreshTokensTable.id],
familyId = this[GatewayRefreshTokensTable.familyId],
expiresAt = this[GatewayRefreshTokensTable.expiresAt],
)
}
private fun requestKey(accountId: String, requestId: String) =
(ProviderRequestsTable.accountId eq accountId) and
(ProviderRequestsTable.requestId eq requestId)
private fun org.jetbrains.exposed.v1.core.ResultRow.requestState(): ProviderRequestState =
runCatching { ProviderRequestState.valueOf(this[ProviderRequestsTable.status]) }
.getOrDefault(ProviderRequestState.MANUAL_REVIEW)
@@ -0,0 +1,568 @@
package com.osglab.account.features.gateway.routes
import com.osglab.account.features.gateway.asr.AsrConcurrencyLimitException
import com.osglab.account.features.gateway.asr.AsrSessionAlreadyUsedException
import com.osglab.account.features.gateway.asr.AsrSessionNotFoundException
import com.osglab.account.features.gateway.asr.AsrStreamingService
import com.osglab.account.features.gateway.asr.CreateAsrSessionRequest
import com.osglab.account.features.gateway.models.AsrGatewayOptions
import com.osglab.account.features.gateway.models.AsrProviderRequest
import com.osglab.account.features.gateway.models.AudioDurationPolicy
import com.osglab.account.features.gateway.models.CreateGatewayGrantRequest
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayCatalogResponse
import com.osglab.account.features.gateway.models.GatewayErrorResponse
import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.GatewaySubject
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.RefreshGatewayGrantRequest
import com.osglab.account.features.gateway.models.TextGatewayRequest
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.TextRequestPolicy
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
import com.osglab.account.features.gateway.ports.GatewayIdentityPort
import com.osglab.account.features.gateway.ports.GatewayPrincipalResolver
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
import com.osglab.account.features.gateway.providers.UnsupportedGatewayCapabilityException
import com.osglab.account.features.gateway.services.GatewayAccessDeniedException
import com.osglab.account.features.gateway.services.GatewayGrantService
import com.osglab.account.features.gateway.services.GatewayRefreshTokenInvalidException
import com.osglab.account.features.gateway.services.GatewayRefreshTokenReuseException
import com.osglab.account.features.gateway.services.GatewayService
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.receive
import io.ktor.server.request.receiveChannel
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.response.respondBytesWriter
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.server.websocket.webSocket
import io.ktor.utils.io.writeFully
import io.ktor.utils.io.readRemaining
import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readBytes
import io.ktor.websocket.readText
import io.ktor.websocket.send
import java.io.ByteArrayOutputStream
import java.util.UUID
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withTimeout
import kotlinx.io.readByteArray
import kotlinx.serialization.json.Json
fun Route.configureGatewayRoutes(
service: GatewayService,
appIdentity: GatewayIdentityPort,
gatewayIdentity: GatewayAccessTokenPort,
grantService: GatewayGrantService? = null,
asrStreaming: AsrStreamingService? = null,
) {
route("/v1/gateway") {
if (grantService != null) {
post("/grants") {
val requestId = call.gatewayRequestId()
val principal = call.requireSubject(appIdentity, requestId) ?: return@post
val idempotencyKey = call.request.headers[IDEMPOTENCY_HEADER]
?: return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"missing_idempotency_key",
"$IDEMPOTENCY_HEADER is required",
requestId,
)
val body = runCatching {
ROUTE_JSON.decodeFromString<CreateGatewayGrantRequest>(
call.receiveBounded(GatewayLimits.MAX_JSON_BODY_BYTES).decodeToString(),
)
}.getOrElse {
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
return@post call.respondGatewayFailure(it, requestId)
}
return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request",
"Gateway grant request is invalid",
requestId,
)
}
runCatching { grantService.create(principal, body, idempotencyKey) }
.onSuccess { call.respond(HttpStatusCode.Created, it) }
.onFailure { call.respondGatewayFailure(it, requestId) }
}
post("/grants/refresh") {
val requestId = call.gatewayRequestId()
val idempotencyKey = call.request.headers[IDEMPOTENCY_HEADER]
?: return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"missing_idempotency_key",
"$IDEMPOTENCY_HEADER is required",
requestId,
)
val body = runCatching {
ROUTE_JSON.decodeFromString<RefreshGatewayGrantRequest>(
call.receiveBounded(GatewayLimits.MAX_JSON_BODY_BYTES).decodeToString(),
)
}.getOrElse {
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
return@post call.respondGatewayFailure(it, requestId)
}
return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request",
"Gateway refresh request is invalid",
requestId,
)
}
runCatching { grantService.refresh(body.refreshToken, idempotencyKey) }
.onSuccess { call.respond(it) }
.onFailure { call.respondGatewayFailure(it, requestId) }
}
delete("/grants/{grantId}") {
val requestId = call.gatewayRequestId()
val principal = call.requireSubject(appIdentity, requestId) ?: return@delete
val grantId = call.parameters["grantId"]
?: return@delete call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_grant",
"Grant ID is required",
requestId,
)
runCatching { grantService.revoke(principal, grantId) }
.onSuccess { call.respond(HttpStatusCode.NoContent) }
.onFailure { call.respondGatewayFailure(it, requestId) }
}
}
get("/catalog") {
val requestId = call.gatewayRequestId()
call.requireSubject(gatewayIdentity, requestId) ?: return@get
call.respond(GatewayCatalogResponse(service.catalog()))
}
if (asrStreaming != null) {
post("/asr/sessions") {
val requestId = call.requireProviderRequestId() ?: return@post
val principal = call.requireSubject(gatewayIdentity, requestId) ?: return@post
val request = runCatching {
ROUTE_JSON.decodeFromString<CreateAsrSessionRequest>(
call.receiveBounded(GatewayLimits.MAX_JSON_BODY_BYTES).decodeToString(),
)
}.getOrElse {
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
return@post call.respondGatewayFailure(it, requestId)
}
return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request",
"ASR session request is invalid",
requestId,
)
}
try {
call.respond(HttpStatusCode.Created, asrStreaming.createSession(principal, requestId, request))
} catch (failure: Throwable) {
call.respondGatewayFailure(failure, requestId)
}
}
webSocket("/asr/sessions/{sessionId}/stream") {
val principal = gatewayIdentity.resolve(call)
if (principal == null) {
close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Authentication required"))
return@webSocket
}
val sessionId = call.parameters["sessionId"]
if (sessionId == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Session is required"))
return@webSocket
}
try {
val audio = flow {
while (true) {
when (val frame = withTimeout(asrStreaming.limits.idleTimeoutMillis) {
incoming.receive()
}) {
is Frame.Binary -> {
if (!frame.fin || frame.data.size > asrStreaming.limits.maxFrameBytes) {
throw IllegalArgumentException("ASR frame exceeds the gateway limit")
}
emit(frame.readBytes())
}
is Frame.Text -> {
if (frame.readText() == """{"type":"end"}""") break
throw IllegalArgumentException("Unsupported ASR control frame")
}
is Frame.Close ->
throw CancellationException("Downstream ASR connection closed")
else -> throw IllegalArgumentException("Unsupported ASR WebSocket frame")
}
}
}
asrStreaming.stream(
sessionId = sessionId,
principal = principal,
audioFrames = audio,
output = ProviderOutput { bytes ->
send(Frame.Binary(fin = true, data = bytes))
},
)
close(CloseReason(CloseReason.Codes.NORMAL, "Complete"))
} catch (failure: CancellationException) {
throw failure
} catch (_: Throwable) {
// Error metadata only; never echo audio or transcript content.
send("""{"type":"gateway_error","code":"asr_failed"}""")
close(CloseReason(CloseReason.Codes.INTERNAL_ERROR, "ASR failed"))
}
}
}
post("/llm/{capability}") {
val requestId = call.requireProviderRequestId() ?: return@post
val subject = call.requireSubject(gatewayIdentity, requestId) ?: return@post
val capability = call.parameters["capability"].toTextCapability()
?: return@post call.respondGatewayError(
HttpStatusCode.NotFound,
"unknown_capability",
"Only polish, ai and agent are supported",
requestId,
)
val body = runCatching {
ROUTE_JSON.decodeFromString<TextGatewayRequest>(
call.receiveBounded(GatewayLimits.MAX_JSON_BODY_BYTES).decodeToString(),
).also { TextRequestPolicy.validate(it, capability) }
}
.getOrElse {
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
return@post call.respondGatewayFailure(it, requestId)
}
return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request",
"Request body is invalid",
requestId,
)
}
val providerRequest = TextProviderRequest(
requestId = requestId,
capability = capability,
input = body.input,
context = body.context,
maxOutputTokens = body.maxOutputTokens,
temperature = body.temperature,
stream = body.stream,
)
if (body.stream) {
val prepared = try {
service.prepare(subject, providerRequest)
} catch (failure: Throwable) {
call.respondGatewayFailure(failure, requestId)
return@post
}
var executionStarted = false
try {
call.respondBytesWriter(ContentType.Text.EventStream) {
executionStarted = true
var emittedBytes = 0L
try {
service.executePrepared(prepared, ProviderOutput { bytes ->
emittedBytes = Math.addExact(emittedBytes, bytes.size.toLong())
if (emittedBytes > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
throw GatewayOutputLimitException()
}
writeFully(bytes)
flush()
})
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
// The response may already be committed. Emit metadata only.
val errorEvent =
"event: gateway_error\ndata: {\"code\":\"provider_error\",\"requestId\":\"$requestId\"}\n\n"
writeFully(errorEvent.encodeToByteArray())
flush()
}
}
} catch (failure: Throwable) {
if (!executionStarted) {
service.releasePrepared(prepared, failure)
}
throw failure
}
} else {
call.executeBuffered(
service = service,
subject = subject,
request = providerRequest,
contentType = ContentType.Application.Json,
)
}
}
post("/asr") {
val requestId = call.requireProviderRequestId() ?: return@post
val subject = call.requireSubject(gatewayIdentity, requestId) ?: return@post
val estimatedDuration = call.request.headers["X-Audio-Duration-Ms"]?.toLongOrNull()
?: return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"missing_audio_duration",
"X-Audio-Duration-Ms is required",
requestId,
)
val audio = runCatching {
call.receiveBounded(GatewayLimits.MAX_AUDIO_BYTES)
}
.getOrElse {
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
return@post call.respondGatewayFailure(it, requestId)
}
return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_audio",
"Audio body is invalid",
requestId,
)
}
val rawOptions = AsrGatewayOptions(
format = call.request.headers["X-Audio-Format"] ?: "pcm",
codec = call.request.headers["X-Audio-Codec"] ?: "raw",
sampleRate = call.request.headers["X-Audio-Sample-Rate"]?.toIntOrNull() ?: 16_000,
bits = call.request.headers["X-Audio-Bits"]?.toIntOrNull() ?: 16,
channels = call.request.headers["X-Audio-Channels"]?.toIntOrNull() ?: 1,
language = call.request.headers["X-Audio-Language"]?.take(32),
estimatedDurationMillis = estimatedDuration,
)
val options = runCatching {
rawOptions.copy(
estimatedDurationMillis = AudioDurationPolicy.reservationMillis(
audio.size,
rawOptions,
),
)
}.getOrElse {
return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_audio_duration",
it.message ?: "Audio duration is invalid",
requestId,
)
}
val request = AsrProviderRequest(
requestId = requestId,
options = options,
audio = audio,
)
call.executeBuffered(
service = service,
subject = subject,
request = request,
contentType = ContentType.parse("application/x-ndjson"),
)
}
}
}
private suspend fun ApplicationCall.executeBuffered(
service: GatewayService,
subject: GatewaySubject,
request: com.osglab.account.features.gateway.models.ProviderRequest,
contentType: ContentType,
) {
val output = ByteArrayOutputStream(minOf(64 * 1024, GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES))
var emittedBytes = 0L
try {
service.execute(subject, request, ProviderOutput { bytes ->
emittedBytes = Math.addExact(emittedBytes, bytes.size.toLong())
if (emittedBytes > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
throw GatewayOutputLimitException()
}
output.write(bytes)
})
respondBytes(output.toByteArray(), contentType)
} catch (failure: Throwable) {
respondGatewayFailure(failure, request.requestId)
}
}
private suspend fun ApplicationCall.requireSubject(
identity: GatewayPrincipalResolver,
requestId: String,
): GatewaySubject? {
val subject = identity.resolve(this)
if (subject == null) {
respondGatewayError(
HttpStatusCode.Unauthorized,
"unauthorized",
"Authentication is required",
requestId,
)
}
return subject
}
private suspend fun ApplicationCall.respondGatewayFailure(
failure: Throwable,
requestId: String,
) {
when (failure) {
is GatewayRequestAlreadyClaimedException -> respondGatewayError(
HttpStatusCode.Conflict,
"request_already_claimed",
"This account request ID is already ${failure.state.name.lowercase()}",
requestId,
)
is GatewayBodyTooLargeException -> respondGatewayError(
HttpStatusCode.PayloadTooLarge,
"request_too_large",
"Request body exceeds the gateway limit",
requestId,
)
is GatewayRequestTimeoutException -> respondGatewayError(
HttpStatusCode.RequestTimeout,
"request_timeout",
"Request body was not received within the time limit",
requestId,
)
is GatewayAccessDeniedException -> respondGatewayError(
HttpStatusCode.Forbidden,
"gateway_grant_denied",
"Gateway access is not granted",
requestId,
)
is GatewayRefreshTokenInvalidException,
is GatewayRefreshTokenReuseException -> respondGatewayError(
HttpStatusCode.Unauthorized,
"invalid_gateway_refresh",
"Gateway refresh token is invalid",
requestId,
)
is AsrConcurrencyLimitException -> respondGatewayError(
HttpStatusCode.TooManyRequests,
"asr_concurrency_limit",
"Too many concurrent ASR sessions",
requestId,
)
is AsrSessionNotFoundException,
is AsrSessionAlreadyUsedException -> respondGatewayError(
HttpStatusCode.NotFound,
"asr_session_unavailable",
"ASR session is unavailable",
requestId,
)
is UnsupportedGatewayCapabilityException -> respondGatewayError(
HttpStatusCode.ServiceUnavailable,
"provider_unavailable",
"No provider is configured for this capability",
requestId,
)
is IllegalArgumentException -> respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request",
failure.message ?: "Request is invalid",
requestId,
)
else -> respondGatewayError(
HttpStatusCode.BadGateway,
"gateway_failure",
"The managed provider request failed",
requestId,
)
}
}
private suspend fun ApplicationCall.respondGatewayError(
status: HttpStatusCode,
code: String,
message: String,
requestId: String,
) {
respond(status, GatewayErrorResponse(code, message, requestId))
}
private fun ApplicationCall.gatewayRequestId(): String {
val supplied = request.headers[REQUEST_ID_HEADER]
return supplied?.takeIf { REQUEST_ID.matches(it) } ?: UUID.randomUUID().toString()
}
private suspend fun ApplicationCall.requireProviderRequestId(): String? {
val key = request.headers[REQUEST_ID_HEADER]
val responseRequestId = key?.takeIf(REQUEST_ID::matches) ?: UUID.randomUUID().toString()
if (key == null) {
respondGatewayError(
HttpStatusCode.BadRequest,
"missing_request_id",
"$REQUEST_ID_HEADER is required for idempotent provider requests",
responseRequestId,
)
return null
}
if (!REQUEST_ID.matches(key)) {
respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request_id",
"$REQUEST_ID_HEADER is invalid",
responseRequestId,
)
return null
}
return key
}
private suspend fun ApplicationCall.receiveBounded(maxBytes: Int): ByteArray {
val declared = request.headers[io.ktor.http.HttpHeaders.ContentLength]?.toLongOrNull()
if (declared != null && declared > maxBytes) throw GatewayBodyTooLargeException()
val bytes = try {
withTimeout(REQUEST_BODY_TIMEOUT_MILLIS) {
receiveChannel()
.readRemaining(maxBytes.toLong() + 1L)
.readByteArray()
}
} catch (_: TimeoutCancellationException) {
throw GatewayRequestTimeoutException()
}
if (bytes.size > maxBytes) throw GatewayBodyTooLargeException()
return bytes
}
private fun String?.toTextCapability(): GatewayCapability? =
when (this) {
"polish" -> GatewayCapability.POLISH
"ai" -> GatewayCapability.AI
"agent" -> GatewayCapability.AGENT
else -> null
}
private val REQUEST_ID = Regex("[A-Za-z0-9_-]{8,64}")
private const val REQUEST_ID_HEADER = "X-Request-ID"
private const val IDEMPOTENCY_HEADER = "Idempotency-Key"
private val ROUTE_JSON = Json {
ignoreUnknownKeys = false
explicitNulls = false
}
private class GatewayBodyTooLargeException : IllegalArgumentException()
private class GatewayRequestTimeoutException : RuntimeException()
private class GatewayOutputLimitException : RuntimeException("Provider output exceeds the gateway limit")
private const val REQUEST_BODY_TIMEOUT_MILLIS = 30_000L
@@ -0,0 +1,247 @@
package com.osglab.account.features.gateway.services
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.MACSigner
import com.nimbusds.jose.crypto.MACVerifier
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.features.gateway.GatewaySettings
import com.osglab.account.features.gateway.models.CreateGatewayGrantRequest
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayGrant
import com.osglab.account.features.gateway.models.GatewayGrantTokens
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
import com.osglab.account.features.gateway.ports.GatewayRefreshRotationResult
import com.osglab.account.features.gateway.ports.NewGatewayGrant
import com.osglab.account.features.gateway.ports.StoredGatewayRefresh
import io.ktor.http.HttpHeaders
import io.ktor.server.application.ApplicationCall
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.Date
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
class GatewayGrantService(
private val repository: GatewayGrantRepository,
private val settings: GatewaySettings,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun create(
principal: GatewayPrincipal,
request: CreateGatewayGrantRequest,
idempotencyKey: String,
): GatewayGrantTokens {
validateIdempotencyKey(idempotencyKey)
require(request.scopes.isNotEmpty()) { "At least one gateway scope is required" }
require(principal.scopes.containsAll(request.scopes)) {
"Requested gateway scopes exceed the issuing identity"
}
val lifetime = request.lifetimeSeconds
?.let(Duration::ofSeconds)
?: settings.maximumGrantLifetime
require(lifetime >= settings.accessTokenLifetime && lifetime <= settings.maximumGrantLifetime) {
"Gateway grant lifetime is outside the allowed range"
}
val now = clock.instant()
val grantId = UUID.randomUUID().toString()
val tokenId = UUID.randomUUID().toString()
val familyId = UUID.randomUUID().toString()
val grantExpiresAt = now.plus(lifetime)
val refreshExpiresAt = minOf(now.plus(settings.refreshTokenLifetime), grantExpiresAt)
val refreshToken = refreshToken(grantId, familyId, tokenId)
val stored = repository.create(
NewGatewayGrant(
id = grantId,
accountId = principal.accountId,
idempotencyKey = idempotencyKey,
scopes = request.scopes,
expiresAt = grantExpiresAt,
refreshTokenId = tokenId,
refreshFamilyId = familyId,
refreshTokenHash = tokenHash(refreshToken),
refreshExpiresAt = refreshExpiresAt,
),
now,
)
return issue(stored)
}
suspend fun refresh(refreshToken: String, idempotencyKey: String): GatewayGrantTokens {
validateIdempotencyKey(idempotencyKey)
if (refreshToken.length !in 32..MAX_REFRESH_TOKEN_CHARS) {
throw GatewayRefreshTokenInvalidException()
}
val newTokenId = UUID.randomUUID().toString()
val parsed = parseRefreshToken(refreshToken)
val newToken = refreshToken(parsed.grantId, parsed.familyId, newTokenId)
val now = clock.instant()
val result = repository.rotateRefresh(
currentTokenHash = tokenHash(refreshToken),
rotationIdempotencyKey = idempotencyKey,
newTokenId = newTokenId,
newTokenHash = tokenHash(newToken),
newExpiresAt = now.plus(settings.refreshTokenLifetime),
now = now,
)
return when (result) {
is GatewayRefreshRotationResult.Rotated -> issue(result.refresh)
GatewayRefreshRotationResult.Invalid -> throw GatewayRefreshTokenInvalidException()
GatewayRefreshRotationResult.ReuseDetected -> throw GatewayRefreshTokenReuseException()
}
}
suspend fun revoke(principal: GatewayPrincipal, grantId: String): Boolean {
require(runCatching { UUID.fromString(grantId) }.isSuccess) { "Grant ID is invalid" }
return repository.revoke(principal.accountId, grantId, clock.instant())
}
suspend fun authenticate(serialized: String): GatewayPrincipal? {
val principal = verifyAccessToken(serialized) ?: return null
return repository.findActive(
grantId = requireNotNull(principal.grantId),
accountId = principal.accountId,
scopes = principal.scopes,
now = clock.instant(),
)?.let {
GatewayPrincipal(it.accountId, it.id, it.scopes)
}
}
private fun issue(refresh: StoredGatewayRefresh): GatewayGrantTokens {
val now = clock.instant()
val accessExpiresAt = minOf(now.plus(settings.accessTokenLifetime), refresh.grant.expiresAt)
require(accessExpiresAt.isAfter(now)) { "Gateway grant has expired" }
require(refresh.expiresAt.isAfter(now)) { "Gateway refresh token has expired" }
val claims = JWTClaimsSet.Builder()
.issuer(settings.issuer)
.audience(settings.audience)
.subject(refresh.grant.accountId)
.jwtID(UUID.randomUUID().toString())
.issueTime(Date.from(now))
.notBeforeTime(Date.from(now.minusSeconds(CLOCK_SKEW_SECONDS)))
.expirationTime(Date.from(accessExpiresAt))
.claim(CLAIM_TYPE, ACCESS_TOKEN_TYPE)
.claim(CLAIM_GRANT_ID, refresh.grant.id)
.claim(CLAIM_SCOPES, refresh.grant.scopes.map { it.name.lowercase() }.sorted())
.build()
val jwt = SignedJWT(JWSHeader(JWSAlgorithm.HS256), claims)
jwt.sign(MACSigner(settings.accessTokenHmacSecret))
return GatewayGrantTokens(
grantId = refresh.grant.id,
scopes = refresh.grant.scopes,
accessToken = jwt.serialize(),
accessExpiresAt = accessExpiresAt.toString(),
refreshToken = refreshToken(refresh.grant.id, refresh.familyId, refresh.tokenId),
refreshExpiresAt = refresh.expiresAt.toString(),
)
}
private fun verifyAccessToken(serialized: String): GatewayPrincipal? = runCatching {
val jwt = SignedJWT.parse(serialized)
require(jwt.header.algorithm == JWSAlgorithm.HS256)
require(jwt.verify(MACVerifier(settings.accessTokenHmacSecret)))
val claims = jwt.jwtClaimsSet
val now = clock.instant()
require(claims.issuer == settings.issuer)
require(settings.audience in claims.audience)
require(claims.getStringClaim(CLAIM_TYPE) == ACCESS_TOKEN_TYPE)
require(claims.expirationTime?.toInstant()?.isAfter(now.minusSeconds(CLOCK_SKEW_SECONDS)) == true)
require(claims.notBeforeTime?.toInstant()?.isBefore(now.plusSeconds(CLOCK_SKEW_SECONDS)) != false)
require(claims.issueTime?.toInstant()?.isAfter(now.plusSeconds(CLOCK_SKEW_SECONDS)) != true)
val scopes = claims.getStringListClaim(CLAIM_SCOPES)
.map { GatewayCapability.valueOf(it.uppercase()) }
.toSet()
require(scopes.isNotEmpty())
GatewayPrincipal(
userId = claims.subject,
grantId = UUID.fromString(claims.getStringClaim(CLAIM_GRANT_ID)).toString(),
scopes = scopes,
)
}.getOrNull()
private fun refreshToken(grantId: String, familyId: String, tokenId: String): String {
val publicPart = "$grantId.$familyId.$tokenId"
val mac = Mac.getInstance(HMAC_ALGORITHM)
mac.init(SecretKeySpec(settings.refreshTokenHmacSecret, HMAC_ALGORITHM))
val secret = Base64.getUrlEncoder().withoutPadding()
.encodeToString(mac.doFinal(publicPart.toByteArray(StandardCharsets.US_ASCII)))
return "$REFRESH_PREFIX$publicPart.$secret"
}
private fun parseRefreshToken(value: String): RefreshTokenParts {
if (!value.startsWith(REFRESH_PREFIX)) throw GatewayRefreshTokenInvalidException()
val parts = value.removePrefix(REFRESH_PREFIX).split('.')
if (parts.size != 4) throw GatewayRefreshTokenInvalidException()
val grantId = canonicalUuid(parts[0])
val familyId = canonicalUuid(parts[1])
canonicalUuid(parts[2])
val expected = refreshToken(grantId, familyId, parts[2])
if (!MessageDigest.isEqual(
expected.toByteArray(StandardCharsets.US_ASCII),
value.toByteArray(StandardCharsets.US_ASCII),
)
) {
throw GatewayRefreshTokenInvalidException()
}
return RefreshTokenParts(grantId, familyId)
}
private fun canonicalUuid(value: String): String =
runCatching { UUID.fromString(value).toString() }
.getOrElse { throw GatewayRefreshTokenInvalidException() }
private fun tokenHash(value: String): String =
MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(StandardCharsets.US_ASCII))
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
private fun validateIdempotencyKey(value: String) {
require(IDEMPOTENCY_KEY.matches(value)) { "Idempotency key is invalid" }
}
private data class RefreshTokenParts(val grantId: String, val familyId: String)
private companion object {
const val HMAC_ALGORITHM = "HmacSHA256"
const val CLAIM_TYPE = "typ"
const val CLAIM_GRANT_ID = "gid"
const val CLAIM_SCOPES = "scp"
const val ACCESS_TOKEN_TYPE = "gateway_access"
const val REFRESH_PREFIX = "gwrt_"
const val CLOCK_SKEW_SECONDS = 30L
const val MAX_REFRESH_TOKEN_CHARS = 512
val IDEMPOTENCY_KEY = Regex("[A-Za-z0-9._:-]{8,128}")
}
}
class GatewayBearerIdentity(
private val grants: GatewayGrantService,
) : GatewayAccessTokenPort {
override suspend fun resolve(call: ApplicationCall): GatewayPrincipal? {
val token = call.request.headers[HttpHeaders.Authorization]
?.takeIf { it.startsWith(BEARER_PREFIX, ignoreCase = true) }
?.substring(BEARER_PREFIX.length)
?.trim()
?.takeIf { it.isNotEmpty() && it.length <= MAX_ACCESS_TOKEN_CHARS }
?: return null
return grants.authenticate(token)
}
private companion object {
const val BEARER_PREFIX = "Bearer "
const val MAX_ACCESS_TOKEN_CHARS = 4_096
}
}
class GatewayRefreshTokenInvalidException : RuntimeException("Gateway refresh token is invalid")
class GatewayRefreshTokenReuseException : RuntimeException("Gateway refresh token reuse was detected")
@@ -0,0 +1,336 @@
package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.AsrProviderRequest
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewaySubject
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.CreditReservationPort
import com.osglab.account.features.gateway.ports.GatewayGrantPort
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.ports.ProviderUsageEstimate
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.time.TimeSource
class GatewayService(
private val catalog: ProviderCatalog,
private val credits: CreditReservationPort,
private val grants: GatewayGrantPort,
private val usageRecords: GatewayUsagePort,
private val usageEstimator: GatewayUsageEstimator = ConservativeGatewayUsageEstimator,
private val llmProviderTimeoutMillis: Long = 120_000L,
private val asrProviderTimeoutMillis: Long = 360_000L,
) {
init {
require(llmProviderTimeoutMillis > 0)
require(asrProviderTimeoutMillis > 0)
}
fun catalog() = catalog.descriptors()
suspend fun execute(
subject: GatewaySubject,
request: ProviderRequest,
output: ProviderOutput,
): ProviderUsage = executePrepared(prepare(subject, request), output)
suspend fun prepare(
subject: GatewaySubject,
request: ProviderRequest,
): PreparedGatewayRequest {
require(PROVIDER_REQUEST_ID.matches(request.requestId)) {
"Gateway request idempotency key is invalid"
}
if (request.capability !in subject.scopes) {
throw GatewayAccessDeniedException(request.capability)
}
if (!grants.isAllowed(subject.accountId, request.capability)) {
throw GatewayAccessDeniedException(request.capability)
}
val provider = catalog.providerFor(request)
val estimate = usageEstimator.estimate(request)
validateEstimate(request, estimate)
val reservation = credits.reserve(
accountId = subject.accountId,
estimate = estimate,
requestId = request.requestId,
)
try {
usageRecords.claim(
ProviderRequestMetadata(
requestId = request.requestId,
accountId = subject.accountId,
reservationId = reservation.id,
providerId = provider.descriptor.id,
capability = request.capability,
),
)
} catch (replay: GatewayRequestAlreadyClaimedException) {
// The existing claim owns the reservation. Releasing it here would
// refund an in-flight or completed request.
throw replay
} catch (failure: Throwable) {
releaseAfterFailure(reservation, failure)
throw failure
}
try {
usageRecords.markStarted(subject.accountId, request.requestId)
} catch (failure: Throwable) {
releaseAndRecord(
subject.accountId,
request.requestId,
reservation,
failure,
)
throw failure
}
return PreparedGatewayRequest(subject, request, provider, estimate, reservation)
}
suspend fun executePrepared(
prepared: PreparedGatewayRequest,
output: ProviderOutput,
): ProviderUsage {
val request = prepared.request
val provider = prepared.provider
val started = TimeSource.Monotonic.markNow()
val usage = try {
withTimeout(providerTimeoutMillis(request)) {
provider.execute(request, output)
}
} catch (failure: Throwable) {
releasePrepared(prepared, failure)
throw failure
}
return settlePrepared(
prepared,
usage.copy(serverDurationMillis = started.elapsedNow().inWholeMilliseconds.coerceAtLeast(1)),
)
}
suspend fun settlePrepared(
prepared: PreparedGatewayRequest,
usage: ProviderUsage,
): ProviderUsage {
val subject = prepared.subject
val request = prepared.request
val estimate = prepared.estimate
val reservation = prepared.reservation
try {
validateUsage(usage, estimate)
} catch (failure: Throwable) {
releasePrepared(prepared, failure)
throw failure
}
// Once upstream has completed, cancellation must not interrupt durable
// metering. The reservation remains frozen if any settlement step fails.
withContext(NonCancellable) {
val pendingRecorded = runCatching {
usageRecords.markSettlementPending(subject.accountId, request.requestId, usage)
}.isSuccess
val settled = runCatching { credits.settle(reservation.id, usage) }.isSuccess
if (settled && pendingRecorded) {
// Metadata failure after a successful settlement must not turn a
// successful provider response into a client-visible 502. The
// reconciliation job safely repeats the idempotent settlement.
runCatching {
usageRecords.markSucceeded(subject.accountId, request.requestId, usage)
}
} else if (!pendingRecorded) {
runCatching {
usageRecords.markManualReview(
subject.accountId,
request.requestId,
if (settled) "usage_record_pending" else "settlement_state_unavailable",
)
}
}
}
// Provider success is returned even while settlement is pending. Its
// reservation remains frozen and is never released by this path.
return usage
}
suspend fun releasePrepared(
prepared: PreparedGatewayRequest,
failure: Throwable,
) {
releaseAndRecord(
prepared.subject.accountId,
prepared.request.requestId,
prepared.reservation,
failure,
)
}
suspend fun markPreparedForReview(
prepared: PreparedGatewayRequest,
errorCode: String,
failure: Throwable,
) {
runCatching {
usageRecords.markManualReview(
prepared.subject.accountId,
prepared.request.requestId,
errorCode,
)
}.onFailure(failure::addSuppressed)
}
private suspend fun releaseAndRecord(
accountId: String,
requestId: String,
reservation: CreditReservation,
failure: Throwable,
): Unit = withContext(NonCancellable) {
val released = runCatching { credits.release(reservation.id) }
if (released.isSuccess) {
runCatching {
usageRecords.markReleased(
accountId,
requestId,
failure::class.simpleName ?: "provider_error",
)
}.onFailure(failure::addSuppressed)
} else {
released.exceptionOrNull()?.let(failure::addSuppressed)
runCatching {
usageRecords.markManualReview(accountId, requestId, "release_pending")
}.onFailure(failure::addSuppressed)
}
}
private fun validateUsage(usage: ProviderUsage, estimate: ProviderUsageEstimate) {
if (usage.meter != estimate.meter) {
throw GatewayUsagePolicyException("Provider usage meter differs from the reservation")
}
if (usage.units < 0 || usage.units > estimate.units) {
throw GatewayUsagePolicyException("Provider usage exceeds the reserved policy boundary")
}
when (usage.meter) {
UsageMeter.LLM_TOKEN -> {
val input = usage.inputUnits
?: throw GatewayUsagePolicyException("Provider omitted input token usage")
val output = usage.outputUnits
?: throw GatewayUsagePolicyException("Provider omitted output token usage")
if (input < 0 || output < 0) {
throw GatewayUsagePolicyException("Provider token usage cannot be negative")
}
val total = Math.addExact(input, output)
if (usage.units != total ||
input > requireNotNull(estimate.inputUnits) ||
output > requireNotNull(estimate.outputUnits)
) {
throw GatewayUsagePolicyException("Provider token usage is inconsistent")
}
}
UsageMeter.AUDIO_MILLISECOND -> Unit
}
}
private fun providerTimeoutMillis(request: ProviderRequest): Long =
when (request) {
is TextProviderRequest -> llmProviderTimeoutMillis
is AsrProviderRequest -> asrProviderTimeoutMillis
}
private fun validateEstimate(request: ProviderRequest, estimate: ProviderUsageEstimate) {
require(estimate.units > 0) { "Estimated usage must be positive" }
when (request) {
is TextProviderRequest -> {
require(estimate.meter == UsageMeter.LLM_TOKEN)
val input = requireNotNull(estimate.inputUnits)
val output = requireNotNull(estimate.outputUnits)
require(input >= 0 && output >= request.maxOutputTokens)
require(Math.addExact(input, output) == estimate.units)
}
is AsrProviderRequest -> {
require(estimate.meter == UsageMeter.AUDIO_MILLISECOND)
require(estimate.units >= request.options.estimatedDurationMillis)
require(estimate.inputUnits == null && estimate.outputUnits == null)
}
}
}
private suspend fun releaseAfterFailure(
reservation: CreditReservation,
failure: Throwable,
): Unit = withContext(NonCancellable) {
runCatching { credits.release(reservation.id) }
.onFailure(failure::addSuppressed)
}
private companion object {
val PROVIDER_REQUEST_ID = Regex("[A-Za-z0-9._:-]{8,64}")
}
}
data class PreparedGatewayRequest(
val subject: GatewaySubject,
val request: ProviderRequest,
val provider: GatewayProvider,
val estimate: ProviderUsageEstimate,
val reservation: CreditReservation,
)
class GatewayReconciliationService(
private val credits: CreditReservationPort,
private val usageRecords: GatewayUsagePort,
) {
suspend fun reconcile(limit: Int = 100): Int {
var completed = 0
usageRecords.findSettlementPending(limit).forEach { pending ->
try {
credits.settle(pending.reservationId, pending.usage)
usageRecords.markSucceeded(
pending.accountId,
pending.requestId,
pending.usage,
)
completed += 1
} catch (failure: CancellationException) {
throw failure
} catch (_: Exception) {
// Durable pending state is retried on the next pass.
}
}
return completed
}
}
/**
* Explicit recovery boundary for a settled provider call that must be fully
* reversed. The billing implementation owns idempotency and immutable ledger
* entries; normal provider failures use release before settlement instead.
*/
class GatewayRefundService(
private val billing: CreditReservationPort,
) {
suspend fun refund(reservationId: String) {
billing.refund(reservationId)
}
}
class GatewayUsagePolicyException(message: String) : RuntimeException(message)
class GatewayAccessDeniedException(capability: GatewayCapability) :
RuntimeException("Gateway grant does not allow ${capability.name.lowercase()}")
@@ -0,0 +1,46 @@
package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.AsrProviderRequest
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.ProviderUsageEstimate
/**
* Estimates the maximum metered usage before an upstream call. Actual usage is
* still supplied by the provider adapter and validated against this boundary.
*/
fun interface GatewayUsageEstimator {
fun estimate(request: ProviderRequest): ProviderUsageEstimate
}
object ConservativeGatewayUsageEstimator : GatewayUsageEstimator {
override fun estimate(request: ProviderRequest): ProviderUsageEstimate =
when (request) {
is TextProviderRequest -> estimateText(request)
is AsrProviderRequest -> ProviderUsageEstimate(
meter = UsageMeter.AUDIO_MILLISECOND,
units = request.options.estimatedDurationMillis,
)
}
private fun estimateText(request: TextProviderRequest): ProviderUsageEstimate {
// UTF-8 bytes are a conservative BPE upper bound. The fixed allowance
// covers server-controlled system messages and chat framing.
val inputBytes = request.input.encodeToByteArray().size.toLong()
val contextBytes = request.context?.encodeToByteArray()?.size?.toLong() ?: 0L
val input = Math.addExact(
Math.addExact(inputBytes, contextBytes),
LLM_PROMPT_OVERHEAD_TOKENS,
)
val output = request.maxOutputTokens.toLong()
return ProviderUsageEstimate(
meter = UsageMeter.LLM_TOKEN,
units = Math.addExact(input, output),
inputUnits = input,
outputUnits = output,
)
}
private const val LLM_PROMPT_OVERHEAD_TOKENS = 256L
}
@@ -0,0 +1,29 @@
package com.osglab.account.features.health
import com.osglab.account.config.DatabaseFactory
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
fun Route.healthRoutes(databaseFactory: DatabaseFactory) {
route("/health") {
get("/live") {
call.respond(HealthResponse(status = "UP"))
}
get("/ready") {
val databaseReady = databaseFactory.isReady()
if (databaseReady) {
call.respond(HealthResponse(status = "UP"))
} else {
call.respond(HttpStatusCode.ServiceUnavailable, HealthResponse(status = "DOWN"))
}
}
}
}
@Serializable
private data class HealthResponse(val status: String)
@@ -0,0 +1,674 @@
package com.osglab.account.features.integrity
import com.osglab.account.common.errors.ConflictException
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.config.IntegrityConfig
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Clock
import java.time.Instant
import java.util.Base64
import java.util.UUID
enum class AppAttestChallengePurpose {
ATTESTATION,
ASSERTION,
}
enum class AppAttestChallengeStatus {
ISSUED,
CONSUMED,
EXPIRED,
}
enum class AppAttestKeyStatus {
ACTIVE,
REVOKED,
}
data class AppAttestChallenge(
val id: UUID,
val keyId: String,
val purpose: AppAttestChallengePurpose,
/**
* Present only on issuance. Persistence stores [challengeHash], never the
* bearer challenge itself.
*/
val value: ByteArray,
val challengeHash: String,
val accountId: UUID?,
val status: AppAttestChallengeStatus,
val createdAt: Instant,
val expiresAt: Instant,
val consumedAt: Instant? = null,
)
data class StoredAppAttestKey(
val keyId: String,
val publicKey: ByteArray,
val receipt: ByteArray,
val counter: Long,
val accountId: UUID?,
val status: AppAttestKeyStatus = AppAttestKeyStatus.ACTIVE,
)
sealed interface ConsumedChallenge {
data object Valid : ConsumedChallenge
data object MissingOrMismatched : ConsumedChallenge
data object Expired : ConsumedChallenge
data object Replayed : ConsumedChallenge
}
interface AppAttestRepository {
suspend fun createChallenge(challenge: AppAttestChallenge)
/**
* Locks and consumes a challenge atomically. Repeating the same request
* deterministically returns [ConsumedChallenge.Replayed].
*/
suspend fun consumeChallenge(
id: UUID,
purpose: AppAttestChallengePurpose,
keyId: String,
challengeHash: String,
accountId: UUID?,
now: Instant,
): ConsumedChallenge
suspend fun saveKey(key: StoredAppAttestKey): Boolean
suspend fun findKey(keyId: String): StoredAppAttestKey?
suspend fun updateCounter(keyId: String, expectedCounter: Long, newCounter: Long, now: Instant): Boolean
suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant): Boolean
}
data class AttestedKeyMaterial(
val publicKey: ByteArray,
val receipt: ByteArray,
val initialCounter: Long,
)
interface AppAttestCrypto {
suspend fun validateAttestation(
attestationObject: ByteArray,
keyId: String,
challenge: ByteArray,
): AttestedKeyMaterial
/**
* [clientDataHash] is computed by the server from the operation payload.
* It must never be accepted as an arbitrary client-controlled identity.
*/
suspend fun validateAssertion(
assertionObject: ByteArray,
clientDataHash: ByteArray,
publicKey: ByteArray,
lastCounter: Long,
): Long
}
class AppAttestService(
private val repository: AppAttestRepository,
private val crypto: AppAttestCrypto,
private val config: IntegrityConfig,
private val clock: Clock = Clock.systemUTC(),
private val secureRandom: SecureRandom = SecureRandom(),
private val newId: () -> UUID = UUID::randomUUID,
) : AppAttestVerifier {
suspend fun issueChallenge(
purpose: AppAttestChallengePurpose,
keyId: String,
accountId: UUID? = null,
): AppAttestChallenge {
validateKeyId(keyId)
if (purpose == AppAttestChallengePurpose.ASSERTION) {
val key = repository.findKey(keyId)
?: throw InvalidRequestException("App Attest key is not registered")
if (key.status != AppAttestKeyStatus.ACTIVE) {
throw InvalidRequestException("App Attest key is not active")
}
if (accountId != null && key.accountId != null && key.accountId != accountId) {
throw InvalidRequestException("App Attest key is not associated with this account")
}
}
val now = clock.instant()
val value = ByteArray(CHALLENGE_BYTES).also(secureRandom::nextBytes)
val challenge = AppAttestChallenge(
id = newId(),
keyId = keyId,
purpose = purpose,
value = value,
challengeHash = sha256Hex(value),
accountId = accountId,
status = AppAttestChallengeStatus.ISSUED,
createdAt = now,
expiresAt = now.plusSeconds(config.challengeLifetimeSeconds),
)
repository.createChallenge(challenge)
return challenge
}
suspend fun attest(
challengeId: String,
challenge: String,
keyId: String,
attestationObject: String,
accountId: UUID? = null,
) {
validateKeyId(keyId)
val challengeBytes = decodeBase64Url(challenge, CHALLENGE_BYTES, "challenge")
consumeChallenge(
challengeId = challengeId,
purpose = AppAttestChallengePurpose.ATTESTATION,
keyId = keyId,
challenge = challengeBytes,
accountId = accountId,
)
val material = validateAttestation(attestationObject, keyId, challengeBytes)
val stored = StoredAppAttestKey(
keyId = keyId,
publicKey = material.publicKey,
receipt = material.receipt,
counter = material.initialCounter,
accountId = accountId,
)
if (!repository.saveKey(stored)) {
throw ConflictException("App Attest key is already registered")
}
}
/**
* Verifies a challenge-only assertion used by the public integrity route.
* Business operations should use [verifyBoundAssertion] with their own
* server-canonical payload hash.
*/
suspend fun assertChallenge(
challengeId: String,
challenge: String,
keyId: String,
assertionObject: String,
clientDataHash: String,
): Long {
val challengeBytes = decodeBase64Url(challenge, CHALLENGE_BYTES, "challenge")
val suppliedHash = decodeBase64Url(clientDataHash, SHA256_BYTES, "clientDataHash")
val expectedHash = sha256(challengeBytes)
if (!MessageDigest.isEqual(suppliedHash, expectedHash)) {
throw InvalidRequestException("clientDataHash is not bound to the challenge")
}
return verifyBoundAssertion(
challengeId = challengeId,
challenge = challengeBytes,
keyId = keyId,
assertionObject = assertionObject,
expectedClientDataHash = expectedHash,
)
}
suspend fun verifyBoundAssertion(
challengeId: String,
challenge: ByteArray,
keyId: String,
assertionObject: String,
expectedClientDataHash: ByteArray,
expectedAccountId: UUID? = null,
): Long {
validateKeyId(keyId)
require(expectedClientDataHash.size == SHA256_BYTES) {
"Server clientDataHash must contain 32 bytes"
}
consumeChallenge(
challengeId = challengeId,
purpose = AppAttestChallengePurpose.ASSERTION,
keyId = keyId,
challenge = challenge,
accountId = expectedAccountId,
)
val key = repository.findKey(keyId)
?: throw AppAttestRejectedException("App Attest key is not registered")
if (key.status != AppAttestKeyStatus.ACTIVE) {
throw AppAttestRejectedException("App Attest key is not active")
}
if (expectedAccountId != null && key.accountId != expectedAccountId) {
throw AppAttestRejectedException("App Attest key is not associated with this account")
}
val newCounter = validateAssertion(
assertionObject = assertionObject,
clientDataHash = expectedClientDataHash,
key = key,
)
if (!repository.updateCounter(
keyId,
expectedCounter = key.counter,
newCounter = newCounter,
now = clock.instant(),
)
) {
throw AppAttestRejectedException("App Attest counter did not advance atomically")
}
return newCounter
}
override suspend fun verify(
evidence: AppAttestEvidence,
payload: AppleSignInIntegrityPayload,
): IntegrityVerification = try {
val challenge = evidence.challenge
?.let { decodeBase64Url(it, CHALLENGE_BYTES, "challenge") }
?: throw AppAttestRejectedException("App Attest challenge is missing")
val canonical = AppAttestCanonicalPayload.appleSignIn(challenge, payload)
verifyBoundAssertion(
challengeId = evidence.challengeId,
challenge = challenge,
keyId = evidence.keyId,
assertionObject = evidence.assertion,
expectedClientDataHash = sha256(canonical),
)
IntegrityVerification.Verified
} catch (exception: AppAttestRejectedException) {
IntegrityVerification.Rejected(exception.message ?: "App Attest rejected the assertion")
} catch (exception: InvalidRequestException) {
IntegrityVerification.Rejected(exception.message)
} catch (exception: AppAttestUnavailableException) {
IntegrityVerification.Unavailable(exception.message ?: "App Attest verification is unavailable")
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
IntegrityVerification.Unavailable("App Attest verification is unavailable")
}
override suspend fun bindKeyToAccount(keyId: String, accountId: UUID) {
if (!repository.bindKeyToAccount(keyId, accountId, clock.instant())) {
throw InvalidRequestException("App Attest key belongs to another account")
}
}
private suspend fun validateAttestation(
attestationObject: String,
keyId: String,
challenge: ByteArray,
): AttestedKeyMaterial = try {
crypto.validateAttestation(
decodeBase64(attestationObject, MAX_ATTESTATION_BYTES, "attestationObject"),
keyId,
challenge,
)
} catch (exception: AppAttestUnavailableException) {
throw exception
} catch (exception: AppAttestRejectedException) {
throw InvalidRequestException("App Attest attestation failed")
}
private suspend fun validateAssertion(
assertionObject: String,
clientDataHash: ByteArray,
key: StoredAppAttestKey,
): Long = try {
crypto.validateAssertion(
assertionObject = decodeBase64(assertionObject, MAX_ASSERTION_BYTES, "assertion"),
clientDataHash = clientDataHash,
publicKey = key.publicKey,
lastCounter = key.counter,
)
} catch (exception: AppAttestUnavailableException) {
throw exception
} catch (exception: AppAttestRejectedException) {
throw exception
}
private suspend fun consumeChallenge(
challengeId: String,
purpose: AppAttestChallengePurpose,
keyId: String,
challenge: ByteArray,
accountId: UUID? = null,
) {
val id = runCatching { UUID.fromString(challengeId) }
.getOrElse { throw InvalidRequestException("App Attest challenge is invalid") }
when (
repository.consumeChallenge(
id = id,
purpose = purpose,
keyId = keyId,
challengeHash = sha256Hex(challenge),
accountId = accountId,
now = clock.instant(),
)
) {
ConsumedChallenge.Valid -> Unit
ConsumedChallenge.Expired ->
throw AppAttestRejectedException("App Attest challenge has expired")
ConsumedChallenge.Replayed ->
throw AppAttestRejectedException("App Attest challenge was already used")
ConsumedChallenge.MissingOrMismatched ->
throw AppAttestRejectedException("App Attest challenge is invalid")
}
}
private fun validateKeyId(keyId: String) {
val decoded = runCatching { Base64.getDecoder().decode(keyId) }
.getOrElse { throw InvalidRequestException("App Attest keyId must be Base64") }
if (decoded.size != APPLE_KEY_ID_BYTES) {
throw InvalidRequestException("App Attest keyId must encode 32 bytes")
}
}
private fun decodeBase64(value: String, maxBytes: Int, field: String): ByteArray {
val decoded = runCatching { Base64.getDecoder().decode(value) }
.getOrElse { throw AppAttestRejectedException("$field must be Base64") }
if (decoded.isEmpty() || decoded.size > maxBytes) {
throw AppAttestRejectedException("$field size is invalid")
}
return decoded
}
private fun decodeBase64Url(value: String, exactBytes: Int, field: String): ByteArray {
val decoded = runCatching { BASE64_URL_DECODER.decode(value) }
.getOrElse { throw InvalidRequestException("$field must be Base64URL") }
if (decoded.size != exactBytes) throw InvalidRequestException("$field size is invalid")
return decoded
}
private companion object {
const val CHALLENGE_BYTES = 32
const val APPLE_KEY_ID_BYTES = 32
const val SHA256_BYTES = 32
const val MAX_ATTESTATION_BYTES = 256 * 1024
const val MAX_ASSERTION_BYTES = 64 * 1024
}
}
object AppAttestCanonicalPayload {
fun appleSignIn(
challenge: ByteArray,
payload: AppleSignInIntegrityPayload,
): ByteArray = buildString {
appendLine("osg-app-attest-v1")
appendLine("purpose=apple-sign-in")
appendLine("challenge=${BASE64_URL.encodeToString(challenge)}")
appendLine("identity_token_sha256=${digest(payload.identityToken)}")
appendLine("authorization_code_sha256=${digest(payload.authorizationCode)}")
appendLine("nonce_sha256=${digest(payload.nonce)}")
}.toByteArray(Charsets.UTF_8)
private fun digest(value: String): String = BASE64_URL.encodeToString(
sha256(value.toByteArray(Charsets.UTF_8)),
)
}
class AppAttestRejectedException(message: String, cause: Throwable? = null) :
IllegalArgumentException(message, cause)
class AppAttestUnavailableException(message: String, cause: Throwable? = null) :
IllegalStateException(message, cause)
private object AppAttestChallenges : Table("app_attest_challenges") {
val id = varchar("id", 36)
val keyId = varchar("key_id", 128)
val accountId = varchar("account_id", 36).nullable()
val purpose = enumerationByName<AppAttestChallengePurpose>("purpose", 16)
val challengeHash = char("challenge_hash", 64)
val status = enumerationByName<AppAttestChallengeStatus>("status", 16)
val expiresAt = timestamp("expires_at")
val consumedAt = timestamp("consumed_at").nullable()
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object AppAttestKeys : Table("app_attest_keys") {
val keyId = varchar("key_id", 128)
val publicKey = varchar("public_key_base64", 512)
val receipt = text("receipt_base64")
val signCounter = long("sign_counter")
val accountId = varchar("account_id", 36).nullable()
val status = enumerationByName<AppAttestKeyStatus>("status", 16)
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(keyId)
}
class ExposedAppAttestRepository(
private val databaseFactory: DatabaseFactory,
private val clock: Clock = Clock.systemUTC(),
) : AppAttestRepository {
override suspend fun createChallenge(challenge: AppAttestChallenge) {
databaseFactory.query {
AppAttestChallenges.insert {
it[id] = challenge.id.toString()
it[keyId] = challenge.keyId
it[accountId] = challenge.accountId?.toString()
it[purpose] = challenge.purpose
it[challengeHash] = challenge.challengeHash
it[status] = challenge.status
it[expiresAt] = challenge.expiresAt
it[consumedAt] = challenge.consumedAt
it[createdAt] = challenge.createdAt
}
}
}
override suspend fun consumeChallenge(
id: UUID,
purpose: AppAttestChallengePurpose,
keyId: String,
challengeHash: String,
accountId: UUID?,
now: Instant,
): ConsumedChallenge = databaseFactory.query {
val row = AppAttestChallenges.selectAll()
.where { AppAttestChallenges.id eq id.toString() }
.forUpdate()
.singleOrNull()
?: return@query ConsumedChallenge.MissingOrMismatched
if (row[AppAttestChallenges.purpose] != purpose ||
row[AppAttestChallenges.keyId] != keyId ||
(accountId != null && row[AppAttestChallenges.accountId] != accountId.toString()) ||
!constantTimeHexEquals(row[AppAttestChallenges.challengeHash], challengeHash)
) {
return@query ConsumedChallenge.MissingOrMismatched
}
if (row[AppAttestChallenges.status] == AppAttestChallengeStatus.CONSUMED) {
return@query ConsumedChallenge.Replayed
}
if (!row[AppAttestChallenges.expiresAt].isAfter(now)) {
AppAttestChallenges.update({ AppAttestChallenges.id eq id.toString() }) {
it[status] = AppAttestChallengeStatus.EXPIRED
}
return@query ConsumedChallenge.Expired
}
if (row[AppAttestChallenges.status] != AppAttestChallengeStatus.ISSUED) {
return@query ConsumedChallenge.Expired
}
AppAttestChallenges.update({ AppAttestChallenges.id eq id.toString() }) {
it[status] = AppAttestChallengeStatus.CONSUMED
it[consumedAt] = now
}
ConsumedChallenge.Valid
}
override suspend fun saveKey(key: StoredAppAttestKey): Boolean = databaseFactory.query {
val now = clock.instant()
AppAttestKeys.insertIgnore {
it[keyId] = key.keyId
it[publicKey] = Base64.getEncoder().encodeToString(key.publicKey)
it[receipt] = Base64.getEncoder().encodeToString(key.receipt)
it[signCounter] = key.counter
it[accountId] = key.accountId?.toString()
it[status] = key.status
it[createdAt] = now
it[updatedAt] = now
}.insertedCount == 1
}
override suspend fun findKey(keyId: String): StoredAppAttestKey? = databaseFactory.query {
AppAttestKeys.selectAll()
.where { AppAttestKeys.keyId eq keyId }
.singleOrNull()
?.toStoredAppAttestKey()
}
override suspend fun updateCounter(
keyId: String,
expectedCounter: Long,
newCounter: Long,
now: Instant,
): Boolean {
if (newCounter <= expectedCounter) return false
return databaseFactory.query {
AppAttestKeys.update({
(AppAttestKeys.keyId eq keyId) and
(AppAttestKeys.signCounter eq expectedCounter) and
(AppAttestKeys.status eq AppAttestKeyStatus.ACTIVE)
}) {
it[signCounter] = newCounter
it[updatedAt] = now
} == 1
}
}
override suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant): Boolean =
databaseFactory.query {
val row = AppAttestKeys.selectAll()
.where { AppAttestKeys.keyId eq keyId }
.forUpdate()
.singleOrNull()
?: return@query false
if (row[AppAttestKeys.status] != AppAttestKeyStatus.ACTIVE) return@query false
val existing = row[AppAttestKeys.accountId]
if (existing != null) return@query existing == accountId.toString()
AppAttestKeys.update({ AppAttestKeys.keyId eq keyId }) {
it[AppAttestKeys.accountId] = accountId.toString()
it[updatedAt] = now
}
true
}
}
@Serializable
data class AppAttestChallengeRequest(
val purpose: String,
val keyId: String,
)
@Serializable
data class AppAttestChallengeResponse(
val challengeId: String,
val challenge: String,
val expiresAtEpochSeconds: Long,
)
@Serializable
data class AppAttestationRequest(
val challengeId: String,
val challenge: String,
val keyId: String,
val attestationObject: String,
)
@Serializable
data class AppAssertionRequest(
val challengeId: String,
val challenge: String,
val keyId: String,
val assertion: String,
val clientDataHash: String,
)
@Serializable
data class AppAssertionResponse(val counter: Long)
fun Route.integrityRoutes(service: AppAttestService) {
route("/v1/integrity") {
post("/challenges") {
val request = call.receive<AppAttestChallengeRequest>()
val purpose = runCatching {
AppAttestChallengePurpose.valueOf(request.purpose.trim().uppercase())
}.getOrElse {
throw InvalidRequestException("purpose must be attestation or assertion")
}
val challenge = service.issueChallenge(purpose, request.keyId)
call.respond(
HttpStatusCode.Created,
AppAttestChallengeResponse(
challengeId = challenge.id.toString(),
challenge = BASE64_URL.encodeToString(challenge.value),
expiresAtEpochSeconds = challenge.expiresAt.epochSecond,
),
)
}
post("/attest") {
val request = call.receive<AppAttestationRequest>()
try {
service.attest(
request.challengeId,
request.challenge,
request.keyId,
request.attestationObject,
)
} catch (_: AppAttestRejectedException) {
throw InvalidRequestException("App Attest attestation failed")
} catch (_: AppAttestUnavailableException) {
throw ExternalServiceUnavailableException("App Attest")
}
call.respond(HttpStatusCode.NoContent)
}
post("/assert") {
val request = call.receive<AppAssertionRequest>()
val counter = try {
service.assertChallenge(
request.challengeId,
request.challenge,
request.keyId,
request.assertion,
request.clientDataHash,
)
} catch (_: AppAttestRejectedException) {
throw InvalidRequestException("App Attest assertion failed")
} catch (_: AppAttestUnavailableException) {
throw ExternalServiceUnavailableException("App Attest")
}
call.respond(AppAssertionResponse(counter))
}
}
}
private fun ResultRow.toStoredAppAttestKey() = StoredAppAttestKey(
keyId = this[AppAttestKeys.keyId],
publicKey = Base64.getDecoder().decode(this[AppAttestKeys.publicKey]),
receipt = Base64.getDecoder().decode(this[AppAttestKeys.receipt]),
counter = this[AppAttestKeys.signCounter],
accountId = this[AppAttestKeys.accountId]?.let(UUID::fromString),
status = this[AppAttestKeys.status],
)
private fun sha256(value: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(value)
private fun sha256Hex(value: ByteArray): String =
sha256(value).joinToString("") { "%02x".format(it) }
private fun constantTimeHexEquals(left: String, right: String): Boolean =
MessageDigest.isEqual(
left.toByteArray(Charsets.US_ASCII),
right.toByteArray(Charsets.US_ASCII),
)
private val BASE64_URL: Base64.Encoder = Base64.getUrlEncoder().withoutPadding()
private val BASE64_URL_DECODER: Base64.Decoder = Base64.getUrlDecoder()
@@ -0,0 +1,491 @@
package com.osglab.account.features.integrity
import com.osglab.account.config.AppleServiceEnvironment
import com.osglab.account.config.IntegrityConfig
import com.upokecenter.cbor.CBORObject
import org.bouncycastle.asn1.ASN1OctetString
import org.bouncycastle.asn1.ASN1Primitive
import org.bouncycastle.asn1.ASN1Sequence
import org.bouncycastle.asn1.ASN1TaggedObject
import java.io.ByteArrayInputStream
import java.math.BigInteger
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.MessageDigest
import java.security.Signature
import java.security.cert.CertPathValidator
import java.security.cert.CertificateFactory
import java.security.cert.PKIXParameters
import java.security.cert.TrustAnchor
import java.security.cert.X509Certificate
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.ECParameterSpec
import java.security.spec.ECPoint
import java.security.spec.ECPublicKeySpec
import java.security.spec.X509EncodedKeySpec
import java.time.Clock
import java.util.Base64
import java.util.Date
/**
* Certificate verification is an explicit external boundary. Production DI
* must inject Apple App Attest root certificates obtained out-of-band.
*/
fun interface AppAttestCertificateValidator {
fun validateAndReadNonce(certificateChain: List<ByteArray>): ValidatedAppAttestCertificate
}
data class ValidatedAppAttestCertificate(
val publicKey: ECPublicKey,
val nonce: ByteArray,
)
class PkixAppAttestCertificateValidator(
appleRoots: Collection<X509Certificate>,
private val clock: Clock = Clock.systemUTC(),
) : AppAttestCertificateValidator {
private val roots: Set<TrustAnchor> = appleRoots
.map { certificate -> TrustAnchor(certificate, null) }
.toSet()
init {
require(roots.isNotEmpty()) {
"At least one Apple App Attest root certificate must be configured"
}
}
override fun validateAndReadNonce(
certificateChain: List<ByteArray>,
): ValidatedAppAttestCertificate {
if (certificateChain.size !in 2..4) {
throw AppAttestRejectedException("App Attest x5c chain length is invalid")
}
val certificates = certificateChain.map(::decodeCertificate)
if (certificates.first().basicConstraints >= 0) {
throw AppAttestRejectedException("App Attest leaf certificate is not an end-entity certificate")
}
val pathCertificates = certificates.dropLastWhile { candidate ->
roots.any { root ->
candidate.subjectX500Principal == root.trustedCert.subjectX500Principal &&
candidate.publicKey == root.trustedCert.publicKey
}
}
if (pathCertificates.isEmpty()) {
throw AppAttestRejectedException("App Attest x5c chain does not contain a leaf certificate")
}
try {
val certPath = CertificateFactory.getInstance("X.509").generateCertPath(pathCertificates)
CertPathValidator.getInstance("PKIX").validate(
certPath,
PKIXParameters(roots).apply {
isRevocationEnabled = false
date = Date.from(clock.instant())
},
)
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest certificate chain is not trusted", exception)
}
val leaf = pathCertificates.first()
val publicKey = leaf.publicKey as? ECPublicKey
?: throw AppAttestRejectedException("App Attest certificate key is not EC")
return ValidatedAppAttestCertificate(
publicKey = publicKey,
nonce = readAppleNonce(leaf),
)
}
private fun decodeCertificate(encoded: ByteArray): X509Certificate =
try {
CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(encoded)) as X509Certificate
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest x5c contains an invalid certificate", exception)
}
private fun readAppleNonce(certificate: X509Certificate): ByteArray {
val wrapped = certificate.getExtensionValue(APPLE_NONCE_EXTENSION_OID)
?: throw AppAttestRejectedException("App Attest certificate nonce extension is missing")
return try {
val extension = ASN1OctetString.getInstance(ASN1Primitive.fromByteArray(wrapped)).octets
val sequence = ASN1Sequence.getInstance(ASN1Primitive.fromByteArray(extension))
if (sequence.size() != 1) {
throw AppAttestRejectedException("App Attest certificate nonce extension is malformed")
}
val tagged = ASN1TaggedObject.getInstance(sequence.getObjectAt(0))
ASN1OctetString.getInstance(tagged, true).octets.also {
if (it.size != SHA256_BYTES) {
throw AppAttestRejectedException("App Attest certificate nonce size is invalid")
}
}
} catch (exception: AppAttestRejectedException) {
throw exception
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest certificate nonce extension is malformed", exception)
}
}
private companion object {
const val APPLE_NONCE_EXTENSION_OID = "1.2.840.113635.100.8.2"
const val SHA256_BYTES = 32
}
}
object BundledAppleAppAttestTrust {
private const val ROOT_RESOURCE = "/apple/Apple_App_Attestation_Root_CA.pem"
fun validator(): AppAttestCertificateValidator =
PkixAppAttestCertificateValidator(listOf(loadRootCertificate()))
internal fun loadRootCertificate(): X509Certificate {
val stream = BundledAppleAppAttestTrust::class.java.getResourceAsStream(ROOT_RESOURCE)
?: throw AppAttestUnavailableException(
"Bundled Apple App Attestation root certificate is missing",
)
return try {
stream.use {
CertificateFactory.getInstance("X.509").generateCertificate(it) as X509Certificate
}
} catch (exception: Exception) {
throw AppAttestUnavailableException(
"Bundled Apple App Attestation root certificate is invalid",
exception,
)
}
}
}
/**
* Strict production verifier for Apple App Attest CBOR artifacts.
*
* Application DI supplies the pinned Apple trust anchor through
* [AppAttestCertificateValidator].
*/
class LibraryAppAttestCrypto(
config: IntegrityConfig,
private val certificateValidator: AppAttestCertificateValidator,
) : AppAttestCrypto {
private val rpIdHash = sha256(
"${config.appAttestTeamId}.${config.appAttestBundleId}".toByteArray(Charsets.UTF_8),
)
private val expectedAaguid = when (config.appleEnvironment) {
AppleServiceEnvironment.DEVELOPMENT -> DEVELOPMENT_AAGUID
AppleServiceEnvironment.PRODUCTION -> PRODUCTION_AAGUID
}
override suspend fun validateAttestation(
attestationObject: ByteArray,
keyId: String,
challenge: ByteArray,
): AttestedKeyMaterial = rejectMalformed("attestation") {
val attestation = decodeMap(attestationObject, "attestationObject")
if (attestation.requiredText("fmt") != APPLE_ATTESTATION_FORMAT) {
throw AppAttestRejectedException("App Attest format is invalid")
}
val statement = attestation.requiredMap("attStmt")
val chain = statement.requiredArray("x5c").values.map { item ->
item.asByteString("x5c certificate")
}
val receipt = statement.requiredBytes("receipt")
if (receipt.isEmpty()) throw AppAttestRejectedException("App Attest receipt is empty")
val authenticatorDataBytes = attestation.requiredBytes("authData")
val authenticatorData = parseAttestationAuthenticatorData(authenticatorDataBytes)
requireRpId(authenticatorData.rpIdHash)
if (authenticatorData.signCount != 0L) {
throw AppAttestRejectedException("App Attest attestation counter must start at zero")
}
if (!MessageDigest.isEqual(authenticatorData.aaguid, expectedAaguid)) {
throw AppAttestRejectedException("App Attest AAGUID does not match the configured environment")
}
val decodedKeyId = decodeKeyId(keyId)
if (!MessageDigest.isEqual(authenticatorData.credentialId, decodedKeyId)) {
throw AppAttestRejectedException("App Attest credentialId does not match keyId")
}
val cosePublicKey = decodeCosePublicKey(authenticatorData.coseKey)
if (!MessageDigest.isEqual(sha256(uncompressedPoint(cosePublicKey)), decodedKeyId)) {
throw AppAttestRejectedException("App Attest keyId does not identify the credential public key")
}
val validatedCertificate = certificateValidator.validateAndReadNonce(chain)
if (!sameEcPoint(validatedCertificate.publicKey, cosePublicKey)) {
throw AppAttestRejectedException("App Attest certificate and credential keys differ")
}
val clientDataHash = sha256(challenge)
val expectedNonce = sha256(authenticatorDataBytes + clientDataHash)
if (!MessageDigest.isEqual(validatedCertificate.nonce, expectedNonce)) {
throw AppAttestRejectedException("App Attest certificate nonce is invalid")
}
AttestedKeyMaterial(
publicKey = cosePublicKey.encoded,
receipt = receipt,
initialCounter = authenticatorData.signCount,
)
}
override suspend fun validateAssertion(
assertionObject: ByteArray,
clientDataHash: ByteArray,
publicKey: ByteArray,
lastCounter: Long,
): Long = rejectMalformed("assertion") {
if (clientDataHash.size != SHA256_BYTES) {
throw AppAttestRejectedException("App Attest clientDataHash size is invalid")
}
if (lastCounter < 0) {
throw AppAttestRejectedException("App Attest stored counter is invalid")
}
val assertion = decodeMap(assertionObject, "assertionObject")
val authenticatorDataBytes = assertion.requiredBytes("authenticatorData")
val signatureBytes = assertion.requiredBytes("signature")
val authenticatorData = parseAssertionAuthenticatorData(authenticatorDataBytes)
requireRpId(authenticatorData.rpIdHash)
if (authenticatorData.signCount <= lastCounter) {
throw AppAttestRejectedException("App Attest assertion counter did not increase")
}
val key = try {
KeyFactory.getInstance("EC")
.generatePublic(X509EncodedKeySpec(publicKey)) as ECPublicKey
} catch (exception: Exception) {
throw AppAttestUnavailableException("Stored App Attest public key is invalid", exception)
}
val signedBytes = authenticatorDataBytes + clientDataHash
val verified = try {
Signature.getInstance("SHA256withECDSA").run {
initVerify(key)
update(signedBytes)
verify(signatureBytes)
}
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest assertion signature is malformed", exception)
}
if (!verified) throw AppAttestRejectedException("App Attest assertion signature is invalid")
authenticatorData.signCount
}
private fun parseAttestationAuthenticatorData(bytes: ByteArray): AttestationAuthenticatorData {
if (bytes.size < MIN_ATTESTATION_AUTH_DATA_BYTES) {
throw AppAttestRejectedException("App Attest authenticatorData is truncated")
}
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN)
val rpHash = ByteArray(SHA256_BYTES).also(buffer::get)
val flags = buffer.get().toInt() and 0xff
val count = buffer.int.toLong() and UINT32_MASK
if ((flags and FLAG_ATTESTED_CREDENTIAL_DATA) == 0) {
throw AppAttestRejectedException("App Attest attested credential flag is missing")
}
val aaguid = ByteArray(AAGUID_BYTES).also(buffer::get)
val credentialLength = buffer.short.toInt() and UINT16_MASK
if (credentialLength == 0 || credentialLength > buffer.remaining()) {
throw AppAttestRejectedException("App Attest credentialId length is invalid")
}
val credentialId = ByteArray(credentialLength).also(buffer::get)
if (!buffer.hasRemaining()) {
throw AppAttestRejectedException("App Attest COSE key is missing")
}
val coseKey = ByteArray(buffer.remaining()).also(buffer::get)
return AttestationAuthenticatorData(rpHash, flags, count, aaguid, credentialId, coseKey)
}
private fun parseAssertionAuthenticatorData(bytes: ByteArray): AssertionAuthenticatorData {
if (bytes.size != ASSERTION_AUTH_DATA_BYTES) {
// App Attest assertions currently contain only RP hash, flags and counter.
// Strictly reject unknown extensions until Apple documents server handling.
throw AppAttestRejectedException("App Attest assertion authenticatorData length is invalid")
}
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN)
val rpHash = ByteArray(SHA256_BYTES).also(buffer::get)
val flags = buffer.get().toInt() and 0xff
val count = buffer.int.toLong() and UINT32_MASK
if ((flags and FLAG_ATTESTED_CREDENTIAL_DATA) != 0 ||
(flags and FLAG_EXTENSION_DATA) != 0
) {
throw AppAttestRejectedException("App Attest assertion flags are invalid")
}
return AssertionAuthenticatorData(rpHash, flags, count)
}
private fun decodeCosePublicKey(encoded: ByteArray): ECPublicKey {
val cose = decodeMap(encoded, "credential public key")
if (cose.requiredInt(1) != COSE_EC2_KEY_TYPE ||
cose.requiredInt(3) != COSE_ES256_ALGORITHM ||
cose.requiredInt(-1) != COSE_P256_CURVE
) {
throw AppAttestRejectedException("App Attest credential public key parameters are invalid")
}
val x = cose.requiredBytes(-2)
val y = cose.requiredBytes(-3)
if (x.size != P256_COORDINATE_BYTES || y.size != P256_COORDINATE_BYTES) {
throw AppAttestRejectedException("App Attest credential public key size is invalid")
}
return try {
KeyFactory.getInstance("EC").generatePublic(
ECPublicKeySpec(
ECPoint(BigInteger(1, x), BigInteger(1, y)),
P256_PARAMETERS,
),
) as ECPublicKey
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest credential public key is invalid", exception)
}
}
private fun requireRpId(actual: ByteArray) {
if (!MessageDigest.isEqual(actual, rpIdHash)) {
throw AppAttestRejectedException("App Attest RP ID hash is invalid")
}
}
private data class AttestationAuthenticatorData(
val rpIdHash: ByteArray,
val flags: Int,
val signCount: Long,
val aaguid: ByteArray,
val credentialId: ByteArray,
val coseKey: ByteArray,
)
private data class AssertionAuthenticatorData(
val rpIdHash: ByteArray,
val flags: Int,
val signCount: Long,
)
private companion object {
const val APPLE_ATTESTATION_FORMAT = "apple-appattest"
const val SHA256_BYTES = 32
const val AAGUID_BYTES = 16
const val P256_COORDINATE_BYTES = 32
const val ASSERTION_AUTH_DATA_BYTES = 37
const val MIN_ATTESTATION_AUTH_DATA_BYTES = 55
const val FLAG_ATTESTED_CREDENTIAL_DATA = 0x40
const val FLAG_EXTENSION_DATA = 0x80
const val COSE_EC2_KEY_TYPE = 2
const val COSE_ES256_ALGORITHM = -7
const val COSE_P256_CURVE = 1
const val UINT16_MASK = 0xffff
const val UINT32_MASK = 0xffff_ffffL
val DEVELOPMENT_AAGUID: ByteArray = "appattestdevelop".toByteArray(Charsets.US_ASCII)
val PRODUCTION_AAGUID: ByteArray =
"appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7)
val P256_PARAMETERS: ECParameterSpec = AlgorithmParameters.getInstance("EC").run {
init(ECGenParameterSpec("secp256r1"))
getParameterSpec(ECParameterSpec::class.java)
}
}
}
private fun decodeMap(encoded: ByteArray, label: String): CBORObject {
val value = try {
CBORObject.DecodeFromBytes(encoded)
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest $label is invalid CBOR", exception)
}
if (value.type != com.upokecenter.cbor.CBORType.Map) {
throw AppAttestRejectedException("App Attest $label must be a CBOR map")
}
return value
}
private fun CBORObject.requiredMap(key: String): CBORObject =
required(key).also {
if (it.type != com.upokecenter.cbor.CBORType.Map) {
throw AppAttestRejectedException("App Attest $key must be a CBOR map")
}
}
private fun CBORObject.requiredArray(key: String): CBORObject =
required(key).also {
if (it.type != com.upokecenter.cbor.CBORType.Array) {
throw AppAttestRejectedException("App Attest $key must be a CBOR array")
}
}
private fun CBORObject.requiredText(key: String): String {
val value = required(key)
if (value.type != com.upokecenter.cbor.CBORType.TextString) {
throw AppAttestRejectedException("App Attest $key must be text")
}
return value.AsString()
}
private fun CBORObject.requiredBytes(key: String): ByteArray =
required(key).asByteString(key)
private fun CBORObject.requiredBytes(key: Int): ByteArray =
required(key).asByteString(key.toString())
private fun CBORObject.asByteString(label: String): ByteArray {
if (type != com.upokecenter.cbor.CBORType.ByteString) {
throw AppAttestRejectedException("App Attest $label must be bytes")
}
return GetByteString()
}
private fun CBORObject.requiredInt(key: Int): Int {
val value = required(key)
if (!value.isNumber || !value.AsNumber().IsInteger()) {
throw AppAttestRejectedException("App Attest COSE parameter $key must be an integer")
}
return try {
value.AsInt32()
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest COSE parameter $key is out of range", exception)
}
}
private fun CBORObject.required(key: String): CBORObject =
this[CBORObject.FromObject(key)]
?: throw AppAttestRejectedException("App Attest CBOR field $key is missing")
private fun CBORObject.required(key: Int): CBORObject =
this[CBORObject.FromObject(key)]
?: throw AppAttestRejectedException("App Attest COSE parameter $key is missing")
private inline fun <T> rejectMalformed(label: String, block: () -> T): T =
try {
block()
} catch (exception: AppAttestRejectedException) {
throw exception
} catch (exception: AppAttestUnavailableException) {
throw exception
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest $label is malformed", exception)
}
private fun decodeKeyId(keyId: String): ByteArray =
try {
Base64.getDecoder().decode(keyId).also {
if (it.size != 32) throw AppAttestRejectedException("App Attest keyId size is invalid")
}
} catch (exception: AppAttestRejectedException) {
throw exception
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest keyId is invalid", exception)
}
private fun sameEcPoint(left: ECPublicKey, right: ECPublicKey): Boolean =
MessageDigest.isEqual(uncompressedPoint(left), uncompressedPoint(right))
private fun uncompressedPoint(key: ECPublicKey): ByteArray =
byteArrayOf(0x04) +
key.w.affineX.toUnsignedFixed(32) +
key.w.affineY.toUnsignedFixed(32)
private fun BigInteger.toUnsignedFixed(size: Int): ByteArray {
val raw = toByteArray()
val unsigned = if (raw.size == size + 1 && raw.first() == 0.toByte()) {
raw.copyOfRange(1, raw.size)
} else {
raw
}
if (unsigned.size > size) {
throw AppAttestRejectedException("App Attest EC coordinate is too large")
}
return ByteArray(size - unsigned.size) + unsigned
}
private fun sha256(value: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(value)
@@ -0,0 +1,499 @@
package com.osglab.account.features.integrity
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.ECDSASigner
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.config.AppleConfig
import com.osglab.account.config.AppleServiceEnvironment
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.config.IntegrityPolicy
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpRequestTimeoutException
import io.ktor.client.plugins.timeout
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.coroutines.CancellationException
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.security.KeyFactory
import java.security.interfaces.ECPrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.Date
import java.util.UUID
data class DeviceCheckState(
val bit0: Boolean,
val bit1: Boolean,
val lastUpdateTime: String?,
)
/**
* Apple persists two bits per physical device:
* - bit0: this device has consumed the one-time signup trial.
* - bit1: this server has marked the device as elevated risk.
*
* A risk bit is never cleared automatically and neither bit is interpreted as
* proof of identity.
*/
object DeviceCheckBitSemantics {
const val SIGNUP_TRIAL_CLAIMED_BIT = 0
const val ELEVATED_RISK_BIT = 1
}
sealed interface DeviceCheckQuery {
data class Found(val state: DeviceCheckState) : DeviceCheckQuery
data object NotFound : DeviceCheckQuery
}
interface AppleDeviceCheckClient {
suspend fun query(deviceToken: String): DeviceCheckQuery
suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean)
}
class UnavailableAppleDeviceCheckClient : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
throw DeviceCheckUnavailableException("DeviceCheck credentials are not configured")
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean): Unit =
throw DeviceCheckUnavailableException("DeviceCheck credentials are not configured")
}
class DeviceCheckJwtGenerator(
private val teamId: String,
private val keyId: String,
privateKeyPem: String,
private val clock: Clock = Clock.systemUTC(),
) {
private val privateKey = loadPrivateKey(privateKeyPem)
init {
require(teamId.isNotBlank()) { "DeviceCheck team ID is required" }
require(keyId.isNotBlank()) { "DeviceCheck key ID is required" }
}
fun create(): String {
val now = clock.instant()
val claims = JWTClaimsSet.Builder()
.issuer(teamId)
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plus(JWT_LIFETIME)))
.build()
return SignedJWT(
JWSHeader.Builder(JWSAlgorithm.ES256).keyID(keyId).build(),
claims,
).apply {
sign(ECDSASigner(privateKey))
}.serialize()
}
private fun loadPrivateKey(pem: String): ECPrivateKey {
val encoded = pem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(Regex("\\s"), "")
return runCatching {
KeyFactory.getInstance("EC")
.generatePrivate(PKCS8EncodedKeySpec(Base64.getDecoder().decode(encoded))) as ECPrivateKey
}.getOrElse {
throw DeviceCheckUnavailableException("DeviceCheck private key is invalid", it)
}
}
private companion object {
val JWT_LIFETIME: Duration = Duration.ofMinutes(55)
}
}
class KtorAppleDeviceCheckClient(
private val httpClient: HttpClient,
private val jwtGenerator: DeviceCheckJwtGenerator,
environment: AppleServiceEnvironment,
private val clock: Clock = Clock.systemUTC(),
private val newTransactionId: () -> UUID = UUID::randomUUID,
private val timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS,
) : AppleDeviceCheckClient {
private val baseUrl = when (environment) {
AppleServiceEnvironment.DEVELOPMENT -> "https://api.development.devicecheck.apple.com"
AppleServiceEnvironment.PRODUCTION -> "https://api.devicecheck.apple.com"
}
override suspend fun query(deviceToken: String): DeviceCheckQuery {
validateToken(deviceToken)
val response = execute(
path = "/v1/query_two_bits",
body = DeviceCheckRequest(
deviceToken = deviceToken,
transactionId = newTransactionId().toString(),
timestamp = clock.millis(),
),
)
if (!response.status.isSuccess()) {
throwForStatus(response.status.value)
}
if (response.body.trim() == BIT_STATE_NOT_FOUND_RESPONSE) {
return DeviceCheckQuery.NotFound
}
val parsed = runCatching {
JSON.decodeFromString<DeviceCheckResponse>(response.body)
}.getOrElse {
throw DeviceCheckUnavailableException("DeviceCheck query returned an invalid response", it)
}
return DeviceCheckQuery.Found(
DeviceCheckState(parsed.bit0, parsed.bit1, parsed.lastUpdateTime),
)
}
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) {
validateToken(deviceToken)
val response = execute(
path = "/v1/update_two_bits",
body = DeviceCheckRequest(
deviceToken = deviceToken,
transactionId = newTransactionId().toString(),
timestamp = clock.millis(),
bit0 = bit0,
bit1 = bit1,
),
)
if (!response.status.isSuccess()) {
throwForStatus(response.status.value)
}
}
private suspend fun execute(path: String, body: DeviceCheckRequest): DeviceCheckHttpResponse =
runCatching {
httpClient.post("$baseUrl$path") {
timeout {
connectTimeoutMillis = timeoutMillis
socketTimeoutMillis = timeoutMillis
requestTimeoutMillis = timeoutMillis
}
contentType(ContentType.Application.Json)
header(HttpHeaders.Authorization, "Bearer ${jwtGenerator.create()}")
setBody(JSON.encodeToString(DeviceCheckRequest.serializer(), body))
}.let { DeviceCheckHttpResponse(it.status, it.bodyAsText()) }
}.getOrElse {
if (it is DeviceCheckException) throw it
if (it is CancellationException) throw it
if (it is HttpRequestTimeoutException) {
throw DeviceCheckUnavailableException("DeviceCheck request timed out", it)
}
throw DeviceCheckUnavailableException("DeviceCheck request failed", it)
}
private fun throwForStatus(status: Int): Nothing {
when (status) {
400, 422 ->
throw DeviceCheckRejectedException("DeviceCheck rejected the device token")
401, 403 ->
throw DeviceCheckUnavailableException("DeviceCheck credentials were rejected by Apple")
408, 409, 425, 429 ->
throw DeviceCheckUnavailableException("DeviceCheck request can be retried")
in 500..599 ->
throw DeviceCheckUnavailableException("DeviceCheck is temporarily unavailable")
else ->
throw DeviceCheckUnavailableException("DeviceCheck returned an unexpected HTTP status")
}
}
private fun validateToken(deviceToken: String) {
if (deviceToken.isBlank() || deviceToken.length > MAX_DEVICE_TOKEN_LENGTH) {
throw DeviceCheckRejectedException("DeviceCheck token format is invalid")
}
runCatching { Base64.getDecoder().decode(deviceToken) }.getOrElse {
throw DeviceCheckRejectedException("DeviceCheck token format is invalid")
}
}
private data class DeviceCheckHttpResponse(
val status: io.ktor.http.HttpStatusCode,
val body: String,
)
private companion object {
const val MAX_DEVICE_TOKEN_LENGTH = 8_192
const val DEFAULT_TIMEOUT_MILLIS = 5_000L
const val BIT_STATE_NOT_FOUND_RESPONSE = "Failed to find bit state"
val JSON = Json { ignoreUnknownKeys = true }
}
}
class DeviceCheckRiskService(
private val client: AppleDeviceCheckClient,
) {
suspend fun markElevatedRisk(deviceToken: String) {
val current = when (val query = client.query(deviceToken)) {
is DeviceCheckQuery.Found -> query.state
DeviceCheckQuery.NotFound -> DeviceCheckState(false, false, null)
}
if (!current.bit1) {
client.update(deviceToken, bit0 = current.bit0, bit1 = true)
}
}
}
class RemoteDeviceCheckVerifier(
private val client: AppleDeviceCheckClient,
) : DeviceCheckVerifier {
override suspend fun verify(deviceToken: String): IntegrityVerification =
try {
client.query(deviceToken)
IntegrityVerification.Verified
} catch (exception: DeviceCheckRejectedException) {
IntegrityVerification.Rejected(exception.message ?: "DeviceCheck rejected the token")
} catch (exception: DeviceCheckUnavailableException) {
IntegrityVerification.Unavailable(exception.message ?: "DeviceCheck is unavailable")
}
}
open class DeviceCheckException(message: String, cause: Throwable? = null) :
IllegalStateException(message, cause)
class DeviceCheckRejectedException(message: String, cause: Throwable? = null) :
DeviceCheckException(message, cause)
class DeviceCheckUnavailableException(message: String, cause: Throwable? = null) :
DeviceCheckException(message, cause)
enum class TrialClaimStatus {
RESERVED,
APPLE_MARKED,
COMPLETED,
REJECTED,
}
data class TrialClaim(
val tokenHash: String,
val accountId: UUID,
val status: TrialClaimStatus,
)
sealed interface BeginTrialClaim {
data class Owned(val claim: TrialClaim) : BeginTrialClaim
data object ClaimedByAnotherAccount : BeginTrialClaim
}
interface DeviceCheckTrialClaimRepository {
suspend fun begin(tokenHash: String, accountId: UUID, now: Instant): BeginTrialClaim
suspend fun transition(tokenHash: String, status: TrialClaimStatus, now: Instant)
}
fun interface TrialCreditGranter {
suspend fun grant(accountId: UUID)
}
interface DeviceCheckTrialMutex {
suspend fun <T> withLock(block: suspend () -> T): T
}
class MysqlDeviceCheckTrialMutex(
private val databaseFactory: DatabaseFactory,
) : DeviceCheckTrialMutex {
override suspend fun <T> withLock(block: suspend () -> T): T =
try {
databaseFactory.withMysqlNamedLock(
name = GLOBAL_TRIAL_LOCK_NAME,
timeoutSeconds = LOCK_TIMEOUT_SECONDS,
block = block,
)
} catch (exception: DeviceCheckException) {
throw exception
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
throw DeviceCheckUnavailableException("DeviceCheck trial lock is unavailable", exception)
}
private companion object {
const val GLOBAL_TRIAL_LOCK_NAME = "osg-devicecheck-trial-v1"
const val LOCK_TIMEOUT_SECONDS = 15
}
}
private object LocalDeviceCheckTrialMutex : DeviceCheckTrialMutex {
override suspend fun <T> withLock(block: suspend () -> T): T = block()
}
fun interface SignupTrialClaimService {
suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): Boolean
}
class DeviceCheckTrialService(
private val repository: DeviceCheckTrialClaimRepository,
private val client: AppleDeviceCheckClient,
private val creditGranter: TrialCreditGranter,
private val policy: IntegrityPolicy,
private val mutex: DeviceCheckTrialMutex = LocalDeviceCheckTrialMutex,
private val clock: Clock = Clock.systemUTC(),
) : SignupTrialClaimService {
override suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): Boolean {
if (deviceToken.isNullOrBlank()) return false
val tokenHash = sha256Hex(deviceToken)
val owned = when (val result = repository.begin(tokenHash, accountId, clock.instant())) {
is BeginTrialClaim.Owned -> result.claim
BeginTrialClaim.ClaimedByAnotherAccount -> return false
}
return try {
mutex.withLock {
completeOwnedClaim(owned, deviceToken)
}
} catch (exception: DeviceCheckRejectedException) {
repository.transition(tokenHash, TrialClaimStatus.REJECTED, clock.instant())
false
} catch (exception: DeviceCheckUnavailableException) {
if (policy == IntegrityPolicy.ENFORCE) {
throw ExternalServiceUnavailableException("DeviceCheck")
}
false
}
}
private suspend fun completeOwnedClaim(claim: TrialClaim, deviceToken: String): Boolean {
when (claim.status) {
TrialClaimStatus.COMPLETED -> return true
TrialClaimStatus.REJECTED -> return false
TrialClaimStatus.APPLE_MARKED -> {
grantAndComplete(claim)
return true
}
TrialClaimStatus.RESERVED -> Unit
}
val state = when (val query = client.query(deviceToken)) {
is DeviceCheckQuery.Found -> query.state
DeviceCheckQuery.NotFound -> DeviceCheckState(false, false, null)
}
if (state.bit0) {
repository.transition(claim.tokenHash, TrialClaimStatus.REJECTED, clock.instant())
return false
}
// Apple has no compare-and-set API. Marking first is intentionally conservative:
// a crash can forfeit a trial, but can never issue credits before the global bit is set.
client.update(deviceToken, bit0 = true, bit1 = state.bit1)
val confirmed = when (val confirmation = client.query(deviceToken)) {
is DeviceCheckQuery.Found -> confirmation.state.bit0
DeviceCheckQuery.NotFound -> false
}
if (!confirmed) {
throw DeviceCheckUnavailableException("DeviceCheck trial mark could not be confirmed")
}
repository.transition(claim.tokenHash, TrialClaimStatus.APPLE_MARKED, clock.instant())
grantAndComplete(claim)
return true
}
private suspend fun grantAndComplete(claim: TrialClaim) {
creditGranter.grant(claim.accountId)
repository.transition(claim.tokenHash, TrialClaimStatus.COMPLETED, clock.instant())
}
}
private object DeviceCheckTrialClaims : Table("devicecheck_trial_claims") {
val tokenHash = char("device_token_hash", 64)
val accountId = varchar("account_id", 36)
val status = enumerationByName<TrialClaimStatus>("status", 16)
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(tokenHash)
}
class ExposedDeviceCheckTrialClaimRepository(
private val databaseFactory: DatabaseFactory,
) : DeviceCheckTrialClaimRepository {
override suspend fun begin(
tokenHash: String,
accountId: UUID,
now: Instant,
): BeginTrialClaim = databaseFactory.query {
DeviceCheckTrialClaims.insertIgnore {
it[DeviceCheckTrialClaims.tokenHash] = tokenHash
it[DeviceCheckTrialClaims.accountId] = accountId.toString()
it[status] = TrialClaimStatus.RESERVED
it[createdAt] = now
it[updatedAt] = now
}
val claim = DeviceCheckTrialClaims.selectAll()
.where { DeviceCheckTrialClaims.tokenHash eq tokenHash }
.forUpdate()
.single()
.toTrialClaim()
if (claim.accountId == accountId) {
BeginTrialClaim.Owned(claim)
} else {
BeginTrialClaim.ClaimedByAnotherAccount
}
}
override suspend fun transition(tokenHash: String, status: TrialClaimStatus, now: Instant) {
databaseFactory.query {
DeviceCheckTrialClaims.update({ DeviceCheckTrialClaims.tokenHash eq tokenHash }) {
it[DeviceCheckTrialClaims.status] = status
it[updatedAt] = now
}
}
}
}
fun createDeviceCheckClient(
httpClient: HttpClient,
appleConfig: AppleConfig,
environment: AppleServiceEnvironment,
): AppleDeviceCheckClient? {
val teamId = appleConfig.teamId ?: return null
val keyId = appleConfig.keyId ?: return null
val privateKey = appleConfig.privateKeyPem ?: return null
return KtorAppleDeviceCheckClient(
httpClient,
DeviceCheckJwtGenerator(teamId, keyId, privateKey),
environment,
)
}
private fun ResultRow.toTrialClaim() = TrialClaim(
tokenHash = this[DeviceCheckTrialClaims.tokenHash],
accountId = UUID.fromString(this[DeviceCheckTrialClaims.accountId]),
status = this[DeviceCheckTrialClaims.status],
)
private fun sha256Hex(value: String): String =
java.security.MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
@Serializable
private data class DeviceCheckRequest(
@SerialName("device_token") val deviceToken: String,
@SerialName("transaction_id") val transactionId: String,
val timestamp: Long,
val bit0: Boolean? = null,
val bit1: Boolean? = null,
)
@Serializable
private data class DeviceCheckResponse(
val bit0: Boolean,
val bit1: Boolean,
@SerialName("last_update_time") val lastUpdateTime: String? = null,
)
@@ -0,0 +1,188 @@
package com.osglab.account.features.integrity
import kotlinx.coroutines.CancellationException
import java.util.Base64
import java.util.UUID
enum class IntegrityEvidenceState {
VERIFIED,
REJECTED,
UNSUPPORTED,
TEMPORARILY_UNAVAILABLE,
}
enum class IntegrityEligibility {
ELIGIBLE,
INELIGIBLE,
RETRY_LATER,
}
enum class IntegrityRiskUseCase {
SIGNUP_TRIAL,
REFERRAL_REWARD,
}
data class IntegrityRiskRequest(
val accountId: UUID,
val useCase: IntegrityRiskUseCase,
val deviceCheckToken: String? = null,
val appAttestKeyId: String? = null,
)
data class IntegrityRiskDecision(
val eligibility: IntegrityEligibility,
val evidenceState: IntegrityEvidenceState,
)
/**
* Port consumed by credits and referrals. Unsupported devices are explicitly
* ineligible for promotional value; transient provider failures ask callers
* to retry and never silently grant a reward.
*/
fun interface IntegrityRiskPort {
suspend fun assess(request: IntegrityRiskRequest): IntegrityRiskDecision
}
class DefaultIntegrityRiskPort(
private val deviceCheckClient: AppleDeviceCheckClient,
private val appAttestRepository: AppAttestRepository,
) : IntegrityRiskPort {
override suspend fun assess(request: IntegrityRiskRequest): IntegrityRiskDecision =
when (request.useCase) {
IntegrityRiskUseCase.SIGNUP_TRIAL -> assessTrial(request.deviceCheckToken)
IntegrityRiskUseCase.REFERRAL_REWARD ->
assessReferral(
request.accountId,
request.appAttestKeyId,
request.deviceCheckToken,
)
}
private suspend fun assessTrial(token: String?): IntegrityRiskDecision {
if (token.isNullOrBlank()) return unsupported()
return try {
when (val query = deviceCheckClient.query(token)) {
DeviceCheckQuery.NotFound -> eligible()
is DeviceCheckQuery.Found -> {
// bit0 = signup trial already consumed; bit1 = server risk flag.
if (query.state.bit0 || query.state.bit1) rejected() else eligible()
}
}
} catch (_: DeviceCheckRejectedException) {
rejected()
} catch (_: DeviceCheckUnavailableException) {
retryLater()
} catch (exception: CancellationException) {
throw exception
}
}
private suspend fun assessReferral(
accountId: UUID,
keyId: String?,
deviceCheckToken: String?,
): IntegrityRiskDecision {
if (keyId.isNullOrBlank() || deviceCheckToken.isNullOrBlank()) return unsupported()
return try {
val key = appAttestRepository.findKey(keyId) ?: return rejected()
if (key.status != AppAttestKeyStatus.ACTIVE || key.accountId != accountId) {
return rejected()
}
when (val query = deviceCheckClient.query(deviceCheckToken)) {
DeviceCheckQuery.NotFound -> eligible()
is DeviceCheckQuery.Found -> {
// A consumed trial is valid for referrals; elevated risk is not.
if (query.state.bit1) rejected() else eligible()
}
}
} catch (_: DeviceCheckRejectedException) {
rejected()
} catch (_: DeviceCheckUnavailableException) {
retryLater()
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
retryLater()
}
}
private fun eligible() =
IntegrityRiskDecision(IntegrityEligibility.ELIGIBLE, IntegrityEvidenceState.VERIFIED)
private fun rejected() =
IntegrityRiskDecision(IntegrityEligibility.INELIGIBLE, IntegrityEvidenceState.REJECTED)
private fun unsupported() =
IntegrityRiskDecision(IntegrityEligibility.INELIGIBLE, IntegrityEvidenceState.UNSUPPORTED)
private fun retryLater() =
IntegrityRiskDecision(IntegrityEligibility.RETRY_LATER, IntegrityEvidenceState.TEMPORARILY_UNAVAILABLE)
}
enum class GatewayIntegrityDecision {
ALLOW,
DENY,
RETRY_LATER,
}
data class GatewayCostIntegrityRequest(
val accountId: UUID,
val keyId: String,
val challengeId: String,
val challengeBase64Url: String,
val assertionBase64: String,
/**
* Server-computed hash of canonical cost request metadata. Prompt, audio
* and model response bodies must never be passed through this port.
*/
val expectedClientDataHash: ByteArray,
)
/**
* Port consumed by gateway before an upstream cost is incurred.
*/
fun interface GatewayIntegrityPort {
suspend fun authorize(request: GatewayCostIntegrityRequest): GatewayIntegrityDecision
}
class AppAttestGatewayIntegrityPort(
private val appAttestService: AppAttestService,
private val appAttestRepository: AppAttestRepository,
) : GatewayIntegrityPort {
override suspend fun authorize(
request: GatewayCostIntegrityRequest,
): GatewayIntegrityDecision = try {
val key = appAttestRepository.findKey(request.keyId)
?: return GatewayIntegrityDecision.DENY
if (key.status != AppAttestKeyStatus.ACTIVE || key.accountId != request.accountId) {
return GatewayIntegrityDecision.DENY
}
val challenge = try {
Base64.getUrlDecoder().decode(request.challengeBase64Url)
} catch (_: IllegalArgumentException) {
return GatewayIntegrityDecision.DENY
}
if (challenge.size != 32 || request.expectedClientDataHash.size != 32) {
return GatewayIntegrityDecision.DENY
}
appAttestService.verifyBoundAssertion(
challengeId = request.challengeId,
challenge = challenge,
keyId = request.keyId,
assertionObject = request.assertionBase64,
expectedClientDataHash = request.expectedClientDataHash,
expectedAccountId = request.accountId,
)
GatewayIntegrityDecision.ALLOW
} catch (_: AppAttestRejectedException) {
GatewayIntegrityDecision.DENY
} catch (_: com.osglab.account.common.errors.InvalidRequestException) {
GatewayIntegrityDecision.DENY
} catch (_: AppAttestUnavailableException) {
GatewayIntegrityDecision.RETRY_LATER
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
GatewayIntegrityDecision.RETRY_LATER
}
}
@@ -0,0 +1,120 @@
package com.osglab.account.features.integrity
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.config.IntegrityConfig
import com.osglab.account.config.IntegrityPolicy
data class IntegrityEvidence(
val deviceCheckToken: String? = null,
val appAttest: AppAttestEvidence? = null,
)
data class AppAttestEvidence(
val keyId: String,
val challengeId: String,
val assertion: String,
/**
* Base64URL challenge returned by /v1/integrity/challenges. New clients
* must echo it because persistence intentionally stores only its hash.
*/
val challenge: String? = null,
)
data class AppleSignInIntegrityPayload(
val identityToken: String,
val authorizationCode: String,
val nonce: String,
)
data class VerifiedIntegrityEvidence(
val deviceCheckTokenForTrial: String?,
val appAttestKeyId: String?,
)
sealed interface IntegrityVerification {
data object Verified : IntegrityVerification
data class Rejected(val reason: String) : IntegrityVerification
data class Unavailable(val reason: String) : IntegrityVerification
}
interface DeviceCheckVerifier {
suspend fun verify(deviceToken: String): IntegrityVerification
}
interface AppAttestVerifier {
suspend fun verify(
evidence: AppAttestEvidence,
payload: AppleSignInIntegrityPayload,
): IntegrityVerification
suspend fun bindKeyToAccount(keyId: String, accountId: java.util.UUID) = Unit
}
class IntegrityService(
private val config: IntegrityConfig,
private val deviceCheckVerifier: DeviceCheckVerifier,
private val appAttestVerifier: AppAttestVerifier,
) {
suspend fun verifyAppleSignIn(
evidence: IntegrityEvidence,
payload: AppleSignInIntegrityPayload,
): VerifiedIntegrityEvidence {
val suppliedDeviceToken = evidence.deviceCheckToken?.takeIf(String::isNotBlank)
val deviceCheck = suppliedDeviceToken
?.let { deviceCheckVerifier.verify(it) }
?: IntegrityVerification.Unavailable("DeviceCheck evidence was not supplied")
enforce("DeviceCheck", config.deviceCheckPolicy, deviceCheck)
val appAttest = evidence.appAttest
?.let { appAttestVerifier.verify(it, payload) }
?: IntegrityVerification.Unavailable("App Attest evidence was not supplied")
enforce("App Attest", config.appAttestPolicy, appAttest)
return VerifiedIntegrityEvidence(
// A fail-open MONITOR result permits login, never a credit grant.
deviceCheckTokenForTrial = suppliedDeviceToken
?.takeIf { deviceCheck == IntegrityVerification.Verified },
appAttestKeyId = evidence.appAttest?.keyId
?.takeIf { appAttest == IntegrityVerification.Verified },
)
}
suspend fun bindVerifiedKey(keyId: String?, accountId: java.util.UUID) {
keyId?.let { appAttestVerifier.bindKeyToAccount(it, accountId) }
}
private fun enforce(
name: String,
policy: IntegrityPolicy,
result: IntegrityVerification,
) {
when (result) {
IntegrityVerification.Verified -> Unit
is IntegrityVerification.Rejected ->
throw InvalidRequestException("$name verification failed")
is IntegrityVerification.Unavailable -> {
if (policy == IntegrityPolicy.ENFORCE) {
throw ExternalServiceUnavailableException(name)
}
// MONITOR is deliberately fail-open only for missing/unavailable verification.
}
}
}
}
class UnavailableDeviceCheckVerifier(
private val reason: String = "DeviceCheck HTTP verifier credentials are not configured",
) : DeviceCheckVerifier {
override suspend fun verify(deviceToken: String): IntegrityVerification =
IntegrityVerification.Unavailable(reason)
}
class UnavailableAppAttestVerifier(
private val reason: String = "App Attest verifier is unavailable",
) : AppAttestVerifier {
override suspend fun verify(
evidence: AppAttestEvidence,
payload: AppleSignInIntegrityPayload,
): IntegrityVerification = IntegrityVerification.Unavailable(reason)
}
@@ -0,0 +1,293 @@
package com.osglab.account.features.inviteweb
import com.osglab.account.config.AppConfig
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.withCharset
import io.ktor.server.application.ApplicationCall
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import org.koin.ktor.ext.getKoin
import java.net.URI
import java.security.SecureRandom
import java.util.Base64
/**
* Read-only boundary used by the public page to verify a referral code.
*
* Implementations must preserve case, apply campaign validity rules, use a bounded database query,
* and never log the code.
*/
fun interface ReferralLookupPort {
suspend fun isValid(code: String): Boolean
}
data class InviteWebConfig(
val appStoreUrl: String,
val appleAppId: String,
val universalLinkBaseUrl: String = "https://osglab.com/i",
val lookupTimeoutMillis: Long = 1_500,
) {
init {
val appStore = validateHttpsUrl(appStoreUrl, "APP_STORE_URL", allowQuery = true)
require(
appStore.host.equals("apps.apple.com", ignoreCase = true) &&
(appStore.port == -1 || appStore.port == 443) &&
APP_STORE_PATH.matches(appStore.path)
) {
"APP_STORE_URL must be an official apps.apple.com app URL ending in a numeric App ID"
}
val universalLink = validateHttpsUrl(
universalLinkBaseUrl,
"INVITE_BASE_URL",
allowQuery = false,
)
require(
universalLink.host.equals("osglab.com", ignoreCase = true) &&
(universalLink.port == -1 || universalLink.port == 443) &&
universalLink.path.trimEnd('/') == "/i"
) {
"INVITE_BASE_URL must be https://osglab.com/i"
}
require(APPLE_APP_ID.matches(appleAppId)) {
"APPLE_APP_ID must be a Team ID followed by a bundle ID"
}
require(lookupTimeoutMillis in 100..10_000) {
"Invitation lookup timeout must be between 100 and 10000 milliseconds"
}
}
}
sealed interface InvitePageResult {
data class Found(val html: String, val cspNonce: String) : InvitePageResult
data object Invalid : InvitePageResult
data object TemporarilyUnavailable : InvitePageResult
}
/**
* Owns invitation validation and rendering so the Ktor route remains a transport adapter.
*/
class InvitePageService(
private val referralLookup: ReferralLookupPort,
private val config: InviteWebConfig,
) {
private val appStoreUrl = validateHttpsUrl(config.appStoreUrl, "APP_STORE_URL", allowQuery = true)
.toASCIIString()
.escapeHtml()
private val universalLinkBaseUrl = validateHttpsUrl(
config.universalLinkBaseUrl,
"INVITE_BASE_URL",
allowQuery = false,
).toASCIIString().trimEnd('/')
val aasaJson: String = AASA_TEMPLATE.replace(APPLE_APP_ID_TOKEN, config.appleAppId)
suspend fun render(code: String?): InvitePageResult {
val validCode = code?.takeIf(INVITE_CODE::matches) ?: return InvitePageResult.Invalid
val valid = try {
withTimeout(config.lookupTimeoutMillis) {
referralLookup.isValid(validCode)
}
} catch (_: TimeoutCancellationException) {
return InvitePageResult.TemporarilyUnavailable
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
return InvitePageResult.TemporarilyUnavailable
}
if (!valid) return InvitePageResult.Invalid
val nonce = createNonce()
val universalLink = "$universalLinkBaseUrl/$validCode".escapeHtml()
return InvitePageResult.Found(
html = INVITE_TEMPLATE
.replace(CODE_TOKEN, validCode.escapeHtml())
.replace(APP_STORE_URL_TOKEN, appStoreUrl)
.replace(UNIVERSAL_LINK_TOKEN, universalLink)
.replace(NONCE_TOKEN, nonce),
cspNonce = nonce,
)
}
}
/**
* Public composition point for the first-party invitation page and AASA document.
*
* Invitation codes are 16 random bytes encoded as unpadded Base64URL: exactly 22
* case-sensitive characters from A-Z, a-z, 0-9, "_" and "-".
*/
fun Route.configureInviteWebRoutes() {
val koin = getKoin()
val appConfig = koin.get<AppConfig>()
val teamId = requireNotNull(appConfig.apple.teamId) {
"APPLE_TEAM_ID is required to publish the AASA document"
}
configureInviteWebRoutes(
referralLookup = koin.get<ReferralLookupPort>(),
config = InviteWebConfig(
appStoreUrl = appConfig.appStoreUrl,
appleAppId = "$teamId.${appConfig.apple.clientId}",
universalLinkBaseUrl = appConfig.inviteBaseUrl,
),
)
}
fun Route.configureInviteWebRoutes(
referralLookup: ReferralLookupPort,
config: InviteWebConfig,
) {
val service = InvitePageService(referralLookup, config)
get("/i/{code}") {
when (val result = service.render(call.parameters["code"])) {
is InvitePageResult.Found -> {
call.setPublicAssetHeaders(inviteNonce = result.cspNonce)
call.respondText(
text = result.html,
contentType = ContentType.Text.Html.withCharset(Charsets.UTF_8),
status = HttpStatusCode.OK,
)
}
InvitePageResult.Invalid -> {
call.setPublicAssetHeaders()
call.respondInvalidInvitation()
}
InvitePageResult.TemporarilyUnavailable -> {
call.setPublicAssetHeaders()
call.respondLookupUnavailable()
}
}
}
get("/.well-known/apple-app-site-association") {
call.respondAasa(service.aasaJson)
}
get("/apple-app-site-association") {
call.respondAasa(service.aasaJson)
}
}
private suspend fun ApplicationCall.respondAasa(aasa: String) {
setPublicAssetHeaders()
respondText(aasa, ContentType.Application.Json, HttpStatusCode.OK)
}
private suspend fun ApplicationCall.respondInvalidInvitation() {
respondText(
text = "邀请链接无效或已失效 / This invitation link is invalid or expired",
contentType = ContentType.Text.Plain.withCharset(Charsets.UTF_8),
status = HttpStatusCode.NotFound,
)
}
private suspend fun ApplicationCall.respondLookupUnavailable() {
response.headers.append(HttpHeaders.RetryAfter, "30")
respondText(
text = "邀请服务暂时不可用 / Invitation service is temporarily unavailable",
contentType = ContentType.Text.Plain.withCharset(Charsets.UTF_8),
status = HttpStatusCode.ServiceUnavailable,
)
}
private fun ApplicationCall.setPublicAssetHeaders(inviteNonce: String? = null) {
val contentSecurityPolicy = if (inviteNonce == null) {
"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
} else {
"default-src 'none'; " +
"script-src 'nonce-$inviteNonce'; script-src-attr 'none'; " +
"style-src 'nonce-$inviteNonce'; style-src-attr 'none'; " +
"img-src 'none'; font-src 'none'; connect-src 'none'; media-src 'none'; " +
"object-src 'none'; frame-src 'none'; worker-src 'none'; manifest-src 'none'; " +
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests"
}
response.headers.append("Content-Security-Policy", contentSecurityPolicy)
response.headers.append(HttpHeaders.CacheControl, "no-store, max-age=0")
response.headers.append(HttpHeaders.Pragma, "no-cache")
response.headers.append(HttpHeaders.Expires, "0")
response.headers.append(HttpHeaders.ContentLanguage, "zh-CN, en")
response.headers.append("Referrer-Policy", "no-referrer")
response.headers.append(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), clipboard-write=(self)",
)
response.headers.append("Cross-Origin-Opener-Policy", "same-origin")
response.headers.append("Cross-Origin-Resource-Policy", "same-origin")
response.headers.append("X-Content-Type-Options", "nosniff")
response.headers.append("X-Frame-Options", "DENY")
response.headers.append("X-Permitted-Cross-Domain-Policies", "none")
response.headers.append("X-Robots-Tag", "noindex, nofollow, noarchive")
}
private fun validateHttpsUrl(value: String, name: String, allowQuery: Boolean): URI {
val uri = runCatching { URI(value.trim()) }
.getOrElse { throw IllegalArgumentException("$name must be a valid HTTPS URL", it) }
require(
uri.scheme.equals("https", ignoreCase = true) &&
!uri.host.isNullOrBlank() &&
uri.userInfo == null &&
uri.fragment == null &&
(allowQuery || uri.query == null)
) {
"$name must be an absolute HTTPS URL without user information or a fragment"
}
return uri
}
private fun String.escapeHtml(): String =
buildString(length) {
this@escapeHtml.forEach { character ->
append(
when (character) {
'&' -> "&amp;"
'<' -> "&lt;"
'>' -> "&gt;"
'"' -> "&quot;"
'\'' -> "&#39;"
else -> character
},
)
}
}
private fun createNonce(): String =
ByteArray(NONCE_BYTES)
.also(SECURE_RANDOM::nextBytes)
.let { Base64.getUrlEncoder().withoutPadding().encodeToString(it) }
private fun loadTemplate(resource: String, requiredTokens: Set<String>): String {
val stream = object {}.javaClass.getResourceAsStream(resource)
?: error("Missing classpath resource: $resource")
return stream.bufferedReader(Charsets.UTF_8).use { it.readText() }.also { template ->
require(requiredTokens.all(template::contains)) {
"$resource is missing a required placeholder"
}
}
}
private const val INVITE_TEMPLATE_RESOURCE = "/invite/index.html"
private const val AASA_TEMPLATE_RESOURCE = "/invite/apple-app-site-association.json"
private const val CODE_TOKEN = "{{INVITE_CODE}}"
private const val APP_STORE_URL_TOKEN = "{{APP_STORE_URL}}"
private const val UNIVERSAL_LINK_TOKEN = "{{UNIVERSAL_LINK}}"
private const val APPLE_APP_ID_TOKEN = "{{APPLE_APP_ID}}"
private const val NONCE_TOKEN = "{{CSP_NONCE}}"
private const val NONCE_BYTES = 18
private val INVITE_CODE = Regex("[A-Za-z0-9_-]{22}")
private val APPLE_APP_ID = Regex("[A-Z0-9]{10}\\.[A-Za-z0-9.-]+")
private val APP_STORE_PATH = Regex("/.+/id[0-9]+")
private val SECURE_RANDOM = SecureRandom()
private val INVITE_TEMPLATE: String by lazy {
loadTemplate(
INVITE_TEMPLATE_RESOURCE,
setOf(CODE_TOKEN, APP_STORE_URL_TOKEN, UNIVERSAL_LINK_TOKEN, NONCE_TOKEN),
)
}
private val AASA_TEMPLATE: String by lazy {
loadTemplate(AASA_TEMPLATE_RESOURCE, setOf(APPLE_APP_ID_TOKEN))
}
@@ -0,0 +1,147 @@
package com.osglab.account.features.referrals.domain
import java.security.SecureRandom
import java.time.DateTimeException
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.UUID
val DEFAULT_REFERRAL_CAMPAIGN_ID: UUID =
UUID.fromString("00000000-0000-0000-0000-000000000001")
data class ReferralCampaign(
val id: UUID,
val name: String,
val startsAt: Instant,
val endsAt: Instant?,
val bindingWindowSeconds: Long,
val inviterRewardCredits: Long,
val inviteeRewardCredits: Long,
val maxRewardedBindings: Long?,
val budgetCredits: Long?,
val enabled: Boolean,
) {
init {
require(name.isNotBlank()) { "Campaign name must not be blank" }
require(endsAt == null || endsAt > startsAt) { "Campaign interval is invalid" }
require(bindingWindowSeconds > 0) { "Binding window must be positive" }
require(inviterRewardCredits > 0 && inviteeRewardCredits > 0) {
"Referral rewards must be positive"
}
require(inviterRewardCredits <= Long.MAX_VALUE - inviteeRewardCredits) {
"Combined referral reward exceeds the supported integer range"
}
require(maxRewardedBindings == null || maxRewardedBindings > 0) {
"Campaign reward cap must be positive"
}
require(budgetCredits == null || budgetCredits >= rewardCost) {
"Campaign budget must fund at least one bilateral reward"
}
}
fun isActive(at: Instant): Boolean =
enabled && !at.isBefore(startsAt) && (endsAt == null || at < endsAt)
val rewardCost: Long
get() = Math.addExact(inviterRewardCredits, inviteeRewardCredits)
}
data class ReferralCampaignBudget(
val campaignId: UUID,
val rewardedBindings: Long,
val spentCredits: Long,
val updatedAt: Instant,
) {
init {
require(rewardedBindings >= 0) { "Rewarded binding count must not be negative" }
require(spentCredits >= 0) { "Spent campaign credits must not be negative" }
}
}
enum class ReferralRewardStatus {
PENDING,
REWARDED,
INELIGIBLE_BUDGET,
}
data class ReferralCode(
val id: UUID,
val ownerUserId: UUID,
val ownerIdentityFingerprint: String?,
val code: String,
val createdAt: Instant,
val campaignId: UUID? = null,
)
data class ReferralBinding(
val id: UUID,
val inviterUserId: UUID,
val inviteeUserId: UUID,
val codeId: UUID,
val boundAt: Instant,
val rewardedAt: Instant?,
val rewardSettlementId: UUID?,
val campaignId: UUID? = null,
val rewardStatus: ReferralRewardStatus = if (rewardedAt == null) {
ReferralRewardStatus.PENDING
} else {
ReferralRewardStatus.REWARDED
},
)
object ReferralBindingRules {
fun isWithinWindow(
registeredAt: Instant,
attemptedAt: Instant,
bindingWindow: Duration,
): Boolean {
require(!bindingWindow.isNegative && !bindingWindow.isZero) {
"Referral binding window must be positive"
}
if (attemptedAt.isBefore(registeredAt)) return false
val deadline = try {
registeredAt.plus(bindingWindow)
} catch (_: DateTimeException) {
Instant.MAX
} catch (_: ArithmeticException) {
Instant.MAX
}
return !attemptedAt.isAfter(deadline)
}
fun isSelfReferral(
inviteeUserId: UUID,
inviteeIdentityFingerprint: String,
code: ReferralCode,
): Boolean =
code.ownerUserId == inviteeUserId ||
code.ownerIdentityFingerprint?.equals(
inviteeIdentityFingerprint,
ignoreCase = true,
) == true
}
fun interface InviteCodeGenerator {
fun generate(): String
}
class SecureInviteCodeGenerator(
private val secureRandom: SecureRandom = SecureRandom(),
) : InviteCodeGenerator {
override fun generate(): String {
val entropy = ByteArray(16)
secureRandom.nextBytes(entropy)
return Base64.getUrlEncoder().withoutPadding().encodeToString(entropy)
}
}
open class ReferralException(message: String) : RuntimeException(message)
class ReferralConflict(message: String) : ReferralException(message)
class InvalidReferralRequest(message: String) : ReferralException(message)
class ReferralNotFound(message: String) : ReferralException(message)
class ReferralWindowExpired : ReferralException("Referral binding window has expired")
@@ -0,0 +1,83 @@
package com.osglab.account.features.referrals.models
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.services.ReferralProfile
import kotlinx.serialization.Serializable
@Serializable
data class BindReferralRequest(
val code: String,
)
@Serializable
data class ReferralCodeDto(
val code: String,
val campaignId: String?,
val createdAt: String,
) {
companion object {
fun fromDomain(value: ReferralCode) = ReferralCodeDto(
code = value.code,
campaignId = value.campaignId?.toString(),
createdAt = value.createdAt.toString(),
)
}
}
@Serializable
data class ReferralBindingDto(
val boundAt: String,
val rewarded: Boolean,
val rewardStatus: String,
val campaignId: String?,
) {
companion object {
fun fromDomain(value: ReferralBinding) = ReferralBindingDto(
boundAt = value.boundAt.toString(),
rewarded = value.rewardedAt != null,
rewardStatus = value.rewardStatus.name,
campaignId = value.campaignId?.toString(),
)
}
}
@Serializable
data class ReferralCampaignDto(
val id: String,
val name: String,
val startsAt: String,
val endsAt: String?,
val inviterRewardCredits: Long,
val inviteeRewardCredits: Long,
) {
companion object {
fun fromDomain(value: ReferralCampaign) = ReferralCampaignDto(
id = value.id.toString(),
name = value.name,
startsAt = value.startsAt.toString(),
endsAt = value.endsAt?.toString(),
inviterRewardCredits = value.inviterRewardCredits,
inviteeRewardCredits = value.inviteeRewardCredits,
)
}
}
@Serializable
data class ReferralProfileDto(
val code: ReferralCodeDto?,
val binding: ReferralBindingDto?,
) {
companion object {
fun fromDomain(value: ReferralProfile) = ReferralProfileDto(
code = value.code?.let(ReferralCodeDto::fromDomain),
binding = value.binding?.let(ReferralBindingDto::fromDomain),
)
}
}
@Serializable
data class ReferralErrorDto(
val error: String,
)
@@ -0,0 +1,42 @@
package com.osglab.account.features.referrals.repositories
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
import com.osglab.account.features.referrals.domain.ReferralCode
import java.time.Instant
import java.util.UUID
interface ReferralsRepository {
fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID? = null): ReferralCode?
fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode?
fun findCode(code: String): ReferralCode?
fun insertCodeIfAbsent(code: ReferralCode): Boolean
fun findCampaign(id: UUID): ReferralCampaign?
fun listActiveCampaigns(at: Instant): List<ReferralCampaign>
fun lockCampaignBudget(campaignId: UUID): ReferralCampaignBudget
fun updateCampaignBudget(budget: ReferralCampaignBudget)
fun findBinding(inviteeUserId: UUID): ReferralBinding?
fun listBindingsByInviter(inviterUserId: UUID, limit: Int): List<ReferralBinding>
fun lockBinding(inviteeUserId: UUID): ReferralBinding?
fun insertBindingIfAbsent(binding: ReferralBinding): Boolean
fun markRewarded(
bindingId: UUID,
settlementId: UUID,
rewardedAt: Instant,
)
fun markRewardIneligible(bindingId: UUID)
}
@@ -0,0 +1,103 @@
package com.osglab.account.features.referrals.routes
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor
import com.osglab.account.features.referrals.domain.ReferralConflict
import com.osglab.account.features.referrals.domain.ReferralException
import com.osglab.account.features.referrals.domain.InvalidReferralRequest
import com.osglab.account.features.referrals.domain.ReferralNotFound
import com.osglab.account.features.referrals.domain.ReferralWindowExpired
import com.osglab.account.features.referrals.models.BindReferralRequest
import com.osglab.account.features.referrals.models.ReferralBindingDto
import com.osglab.account.features.referrals.models.ReferralCampaignDto
import com.osglab.account.features.referrals.models.ReferralCodeDto
import com.osglab.account.features.referrals.models.ReferralErrorDto
import com.osglab.account.features.referrals.models.ReferralProfileDto
import com.osglab.account.features.referrals.services.ReferralOperations
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import java.util.UUID
class ReferralRouteInstaller(
private val service: ReferralOperations,
private val authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
fun install(parent: Route) {
parent.route("/v1/referrals") {
get("/me") {
call.referralCall(authenticatedUser) { userId ->
ReferralProfileDto.fromDomain(service.getProfile(userId))
}
}
get {
call.referralCall(authenticatedUser) { userId ->
val rawLimit = call.request.queryParameters["limit"]
val limit = rawLimit?.toIntOrNull()
?: if (rawLimit == null) 50 else {
throw InvalidReferralRequest("Referral limit must be an integer")
}
service.listInvited(userId, limit).map(ReferralBindingDto::fromDomain)
}
}
get("/campaigns") {
call.referralCall(authenticatedUser) {
service.listActiveCampaigns().map(ReferralCampaignDto::fromDomain)
}
}
post("/redeem") {
call.referralCall(authenticatedUser) { userId ->
val request = call.receive<BindReferralRequest>()
ReferralBindingDto.fromDomain(service.bind(userId, request.code))
}
}
post("/code") {
call.referralCall(authenticatedUser) { userId ->
ReferralCodeDto.fromDomain(service.getOrCreateCode(userId))
}
}
post("/bind") {
call.referralCall(authenticatedUser) { userId ->
val request = call.receive<BindReferralRequest>()
ReferralBindingDto.fromDomain(service.bind(userId, request.code))
}
}
}
}
}
fun Route.referralRoutes(
service: ReferralOperations,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
ReferralRouteInstaller(service, authenticatedUser).install(this)
}
private suspend fun ApplicationCall.referralCall(
authenticatedUser: AuthenticatedUserExtractor,
block: suspend (UUID) -> Any,
) {
val userId = authenticatedUser.extract(this)
if (userId == null) {
respond(HttpStatusCode.Unauthorized, ReferralErrorDto("Authentication required"))
return
}
try {
respond(block(userId))
} catch (exception: InvalidReferralRequest) {
respond(HttpStatusCode.BadRequest, ReferralErrorDto(exception.message.orEmpty()))
} catch (exception: ReferralNotFound) {
respond(HttpStatusCode.NotFound, ReferralErrorDto(exception.message.orEmpty()))
} catch (exception: ReferralWindowExpired) {
respond(HttpStatusCode.UnprocessableEntity, ReferralErrorDto(exception.message.orEmpty()))
} catch (exception: ReferralConflict) {
respond(HttpStatusCode.Conflict, ReferralErrorDto(exception.message.orEmpty()))
} catch (exception: ReferralException) {
respond(HttpStatusCode.UnprocessableEntity, ReferralErrorDto(exception.message.orEmpty()))
}
}
@@ -0,0 +1,231 @@
package com.osglab.account.features.referrals.services
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.referrals.domain.InviteCodeGenerator
import com.osglab.account.features.referrals.domain.InvalidReferralRequest
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralBindingRules
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.domain.ReferralConflict
import com.osglab.account.features.referrals.domain.ReferralNotFound
import com.osglab.account.features.referrals.domain.ReferralWindowExpired
import com.osglab.account.features.referrals.domain.SecureInviteCodeGenerator
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Locale
import java.util.UUID
fun interface UserRegistrationTimeProvider {
suspend fun registeredAt(userId: UUID): Instant?
}
data class ReferralRiskAssessment(
val identityFingerprint: String,
val restricted: Boolean,
)
fun interface ReferralRiskPort {
suspend fun assess(userId: UUID): ReferralRiskAssessment?
}
typealias ReferralRiskIdentity = ReferralRiskAssessment
typealias ReferralRiskProvider = ReferralRiskPort
data class ReferralProfile(
val code: ReferralCode?,
val binding: ReferralBinding?,
)
/**
* Public referral boundary. Binding and code creation remain transactionally
* consistent even when callers retry after a timeout.
*/
interface ReferralOperations {
suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode
suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode
suspend fun bind(inviteeUserId: UUID, rawCode: String): ReferralBinding
suspend fun getProfile(userId: UUID): ReferralProfile
suspend fun listActiveCampaigns(): List<ReferralCampaign>
suspend fun listInvited(userId: UUID, limit: Int = 50): List<ReferralBinding>
}
class ReferralService(
private val transactions: BillingTransactionRunner,
private val registrationTimeProvider: UserRegistrationTimeProvider,
private val riskProvider: ReferralRiskProvider,
private val bindingWindow: Duration,
private val codeGenerator: InviteCodeGenerator = SecureInviteCodeGenerator(),
private val clock: Clock = Clock.systemUTC(),
private val newId: () -> UUID = UUID::randomUUID,
) : ReferralOperations {
init {
require(!bindingWindow.isNegative && !bindingWindow.isZero) {
"Referral binding window must be positive"
}
}
override suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode {
return getOrCreateCode(ownerUserId, campaignId = null)
}
override suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode {
val ownerIdentity = requireEligibleIdentity(ownerUserId)
return transactions.inTransaction { unit ->
val now = clock.instant()
val campaign = if (campaignId == null) {
unit.referrals.listActiveCampaigns(now).firstOrNull()
?: throw ReferralNotFound("No active referral campaign exists")
} else {
unit.referrals.findCampaign(campaignId)
?: throw ReferralNotFound("Referral campaign does not exist")
}
if (!campaign.isActive(now)) throw ReferralNotFound("Referral campaign is not active")
unit.referrals.findCodeByOwner(ownerUserId, campaign.id)?.let {
return@inTransaction it
}
repeat(MAX_CODE_ATTEMPTS) {
val candidate = ReferralCode(
id = newId(),
ownerUserId = ownerUserId,
ownerIdentityFingerprint = ownerIdentity.identityFingerprint,
code = codeGenerator.generate(),
createdAt = now,
campaignId = campaign.id,
)
if (candidate.code.length < 20) {
throw IllegalStateException("Invite code generator must provide at least 120 bits")
}
if (unit.referrals.insertCodeIfAbsent(candidate)) {
return@inTransaction candidate
}
unit.referrals.lockCodeByOwner(ownerUserId, campaign.id)?.let {
return@inTransaction it
}
}
throw IllegalStateException("Unable to allocate a unique referral code")
}
}
override suspend fun bind(inviteeUserId: UUID, rawCode: String): ReferralBinding {
val code = normalizeCode(rawCode)
val existing = transactions.inTransaction { unit ->
val binding = unit.referrals.findBinding(inviteeUserId)
?: return@inTransaction null
val existingCode = unit.referrals.findCode(code)
if (existingCode?.id == binding.codeId) binding
else throw ReferralConflict("This account is already bound to another inviter")
}
if (existing != null) return existing
val inviteeIdentity = requireEligibleIdentity(inviteeUserId)
val registeredAt = registrationTimeProvider.registeredAt(inviteeUserId)
?: throw ReferralNotFound("Registration time is unavailable")
val now = clock.instant()
return transactions.inTransaction { unit ->
unit.referrals.findBinding(inviteeUserId)?.let { existing ->
val existingCode = unit.referrals.findCode(code)
if (existingCode?.id == existing.codeId) return@inTransaction existing
throw ReferralConflict("This account is already bound to another inviter")
}
val referralCode = unit.referrals.findCode(code)
?: throw ReferralNotFound("Referral code does not exist")
val campaign = referralCode.campaignId
?.let(unit.referrals::findCampaign)
if (campaign != null && !campaign.isActive(now)) {
throw ReferralNotFound("Referral campaign is not active")
}
val effectiveWindow = campaign
?.bindingWindowSeconds
?.let(Duration::ofSeconds)
?: bindingWindow
if (!ReferralBindingRules.isWithinWindow(registeredAt, now, effectiveWindow)) {
throw ReferralWindowExpired()
}
if (ReferralBindingRules.isSelfReferral(
inviteeUserId,
inviteeIdentity.identityFingerprint,
referralCode,
)
) {
throw ReferralConflict("Self-referral is not allowed")
}
val binding = ReferralBinding(
id = newId(),
inviterUserId = referralCode.ownerUserId,
inviteeUserId = inviteeUserId,
codeId = referralCode.id,
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
campaignId = referralCode.campaignId,
)
if (unit.referrals.insertBindingIfAbsent(binding)) {
binding
} else {
val concurrent = unit.referrals.lockBinding(inviteeUserId)
?: throw ReferralConflict("Referral binding changed concurrently")
if (concurrent.codeId == referralCode.id) concurrent
else throw ReferralConflict("This account is already bound to another inviter")
}
}
}
override suspend fun getProfile(userId: UUID): ReferralProfile =
transactions.inTransaction { unit ->
ReferralProfile(
code = unit.referrals.findCodeByOwner(userId),
binding = unit.referrals.findBinding(userId),
)
}
override suspend fun listActiveCampaigns(): List<ReferralCampaign> =
transactions.inTransaction { it.referrals.listActiveCampaigns(clock.instant()) }
override suspend fun listInvited(userId: UUID, limit: Int): List<ReferralBinding> {
if (limit !in 1..100) {
throw InvalidReferralRequest("Referral limit must be between 1 and 100")
}
return transactions.inTransaction {
it.referrals.listBindingsByInviter(userId, limit)
}
}
private suspend fun requireEligibleIdentity(userId: UUID): ReferralRiskAssessment {
val identity = riskProvider.assess(userId)
?: throw ReferralNotFound("Account identity is unavailable")
if (identity.restricted) {
throw ReferralConflict("This account is not eligible for referral rewards")
}
val normalizedFingerprint = identity.identityFingerprint
.trim()
.lowercase(Locale.ROOT)
if (normalizedFingerprint.length != IDENTITY_FINGERPRINT_LENGTH ||
normalizedFingerprint.any { it !in '0'..'9' && it !in 'a'..'f' }
) {
throw ReferralNotFound("Account identity is unavailable")
}
return identity.copy(identityFingerprint = normalizedFingerprint)
}
private fun normalizeCode(value: String): String {
val normalized = value.trim()
if (normalized.length !in 20..32 ||
normalized.any { !it.isLetterOrDigit() && it != '-' && it != '_' }
) {
throw ReferralNotFound("Referral code does not exist")
}
return normalized
}
private companion object {
const val MAX_CODE_ATTEMPTS = 8
const val IDENTITY_FINGERPRINT_LENGTH = 64
}
}
@@ -0,0 +1,14 @@
-----BEGIN CERTIFICATE-----
MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
oyFraWVIyd/dganmrduC1bmTBGwD
-----END CERTIFICATE-----
+61
View File
@@ -0,0 +1,61 @@
ktor:
application:
modules:
- com.osglab.account.ApplicationKt.module
deployment:
host: 0.0.0.0
port: "$PORT:8080"
app:
environment: "$APP_ENV:development"
publicBaseUrl: "$PUBLIC_BASE_URL:https://account.osglab.com"
inviteBaseUrl: "$INVITE_BASE_URL:https://osglab.com/i"
appStoreUrl: "$APP_STORE_URL:https://apps.apple.com/app/id0000000000"
database:
jdbcUrl: "$DATABASE_URL:jdbc:mysql://localhost:3306/osg_account?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true"
username: "$DATABASE_USER:osg_account"
password: "$DATABASE_PASSWORD"
migrationUsername: "$DATABASE_MIGRATION_USER:"
migrationPassword: "$DATABASE_MIGRATION_PASSWORD:"
maximumPoolSize: "$DATABASE_POOL_SIZE:10"
session:
issuer: "$JWT_ISSUER:https://account.osglab.com"
audience: "$JWT_AUDIENCE:osgkeyboard-ios"
secret: "$JWT_SECRET"
accessMinutes: "$ACCESS_TOKEN_MINUTES:15"
refreshDays: "$REFRESH_TOKEN_DAYS:30"
gatewayGrantDays: "$GATEWAY_GRANT_DAYS:30"
encryption:
keyBase64: "$FIELD_ENCRYPTION_KEY"
antiAbuse:
identityHmacKeyBase64: "$IDENTITY_HMAC_KEY"
tombstoneRetentionDays: "$IDENTITY_TOMBSTONE_RETENTION_DAYS:365"
apple:
teamId: "$APPLE_TEAM_ID:"
keyId: "$APPLE_KEY_ID:"
clientId: "$APPLE_CLIENT_ID:com.osgkeyboard.ios"
privateKeyPem: "$APPLE_PRIVATE_KEY_PEM:"
jwksUrl: "$APPLE_JWKS_URL:https://appleid.apple.com/auth/keys"
tokenUrl: "$APPLE_TOKEN_URL:https://appleid.apple.com/auth/token"
revokeUrl: "$APPLE_REVOKE_URL:https://appleid.apple.com/auth/revoke"
credits:
signupTrial: "$SIGNUP_TRIAL_CREDITS:1000"
referralInviter: "$REFERRAL_INVITER_CREDITS:3000"
referralInvitee: "$REFERRAL_INVITEE_CREDITS:3000"
referralBindingDays: "$REFERRAL_BINDING_DAYS:7"
providers:
volcengine:
endpoint: "$VOLCENGINE_ASR_ENDPOINT:wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"
appId: "$VOLCENGINE_APP_ID:"
accessToken: "$VOLCENGINE_ACCESS_TOKEN:"
apiKey: "$VOLCENGINE_API_KEY:"
resourceId: "$VOLCENGINE_RESOURCE_ID:volc.seedasr.sauc.duration"
deepseek:
endpoint: "$DEEPSEEK_ENDPOINT:https://api.deepseek.com/v1"
apiKey: "$DEEPSEEK_API_KEY:"
model: "$DEEPSEEK_MODEL:deepseek-v4-flash"
integrity:
enforceDeviceCheck: "$ENFORCE_DEVICE_CHECK:false"
enforceAppAttest: "$ENFORCE_APP_ATTEST:false"
appleEnvironment: "$APPLE_INTEGRITY_ENVIRONMENT:development"
challengeLifetimeSeconds: "$APP_ATTEST_CHALLENGE_TTL_SECONDS:300"
@@ -0,0 +1,47 @@
CREATE TABLE accounts (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
apple_sub VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at TIMESTAMP(6) NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_accounts_apple_sub (apple_sub)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE apple_credentials (
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
encrypted_refresh_token MEDIUMTEXT NOT NULL,
created_at TIMESTAMP(6) NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (account_id),
CONSTRAINT fk_apple_credentials_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE sessions (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
family_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
refresh_token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
replaced_by_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
created_at TIMESTAMP(6) NOT NULL,
expires_at TIMESTAMP(6) NOT NULL,
revoked_at TIMESTAMP(6) NULL,
reuse_detected_at TIMESTAMP(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_sessions_refresh_token_hash (refresh_token_hash),
KEY ix_sessions_account_id (account_id),
KEY ix_sessions_family_id (family_id),
KEY ix_sessions_account_active (account_id, revoked_at, expires_at),
CONSTRAINT fk_sessions_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE,
CONSTRAINT fk_sessions_replaced_by
FOREIGN KEY (replaced_by_id) REFERENCES sessions (id) ON DELETE SET NULL,
CONSTRAINT chk_sessions_expiry CHECK (expires_at > created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE apple_event_receipts (
event_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
event_type VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
received_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,352 @@
CREATE TABLE credit_accounts (
user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
balance BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (user_id),
CONSTRAINT chk_credit_accounts_non_negative CHECK (balance >= 0)
) ENGINE = InnoDB;
CREATE TABLE credit_rate_versions (
id CHAR(36) NOT NULL,
kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
provider VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
model VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
effective_from DATETIME(6) NOT NULL,
effective_until DATETIME(6) NULL,
asr_credits_numerator BIGINT NULL,
asr_millis_denominator BIGINT NULL,
input_credits_numerator BIGINT NULL,
input_tokens_denominator BIGINT NULL,
output_credits_numerator BIGINT NULL,
output_tokens_denominator BIGINT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_credit_rates_effective (
kind, provider, model, effective_from
),
INDEX idx_credit_rates_lookup (kind, provider, model, effective_from),
CONSTRAINT chk_credit_rates_interval
CHECK (effective_until IS NULL OR effective_until > effective_from),
CONSTRAINT chk_credit_rates_shape CHECK (
(
kind = 'ASR'
AND asr_credits_numerator > 0
AND asr_millis_denominator > 0
AND input_credits_numerator IS NULL
AND input_tokens_denominator IS NULL
AND output_credits_numerator IS NULL
AND output_tokens_denominator IS NULL
)
OR
(
kind = 'LLM'
AND asr_credits_numerator IS NULL
AND asr_millis_denominator IS NULL
AND input_credits_numerator > 0
AND input_tokens_denominator > 0
AND output_credits_numerator > 0
AND output_tokens_denominator > 0
)
)
) ENGINE = InnoDB;
CREATE TABLE credit_reservations (
id CHAR(36) NOT NULL,
user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
rate_version_id CHAR(36) NOT NULL,
provider VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
model VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
usage_kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
estimated_asr_millis BIGINT NULL,
estimated_input_tokens BIGINT NULL,
estimated_output_tokens BIGINT NULL,
actual_asr_millis BIGINT NULL,
actual_input_tokens BIGINT NULL,
actual_output_tokens BIGINT NULL,
reserved_credits BIGINT NOT NULL,
settled_credits BIGINT NULL,
status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
managed_call BOOLEAN NOT NULL,
reserve_idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
settle_idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
release_idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
refund_idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_credit_reservation_reserve (user_id, reserve_idempotency_key),
UNIQUE KEY uk_credit_reservation_settle (user_id, settle_idempotency_key),
UNIQUE KEY uk_credit_reservation_release (user_id, release_idempotency_key),
UNIQUE KEY uk_credit_reservation_refund (user_id, refund_idempotency_key),
INDEX idx_credit_reservations_user_status (user_id, status),
CONSTRAINT fk_credit_reservations_rate
FOREIGN KEY (rate_version_id) REFERENCES credit_rate_versions (id) ON DELETE RESTRICT,
CONSTRAINT chk_credit_reservations_positive CHECK (reserved_credits > 0),
CONSTRAINT chk_credit_reservations_estimated_usage CHECK (
(
usage_kind = 'ASR'
AND estimated_asr_millis >= 0
AND estimated_input_tokens IS NULL
AND estimated_output_tokens IS NULL
)
OR
(
usage_kind = 'LLM'
AND estimated_asr_millis IS NULL
AND estimated_input_tokens >= 0
AND estimated_output_tokens >= 0
)
),
CONSTRAINT chk_credit_reservations_state CHECK (
(
status = 'RESERVED'
AND actual_asr_millis IS NULL
AND actual_input_tokens IS NULL
AND actual_output_tokens IS NULL
AND settled_credits IS NULL
AND settle_idempotency_key IS NULL
AND release_idempotency_key IS NULL
AND refund_idempotency_key IS NULL
)
OR
(
status = 'SETTLED'
AND settled_credits >= 0
AND settle_idempotency_key IS NOT NULL
AND release_idempotency_key IS NULL
AND refund_idempotency_key IS NULL
AND (
(
usage_kind = 'ASR'
AND actual_asr_millis >= 0
AND actual_input_tokens IS NULL
AND actual_output_tokens IS NULL
)
OR
(
usage_kind = 'LLM'
AND actual_asr_millis IS NULL
AND actual_input_tokens >= 0
AND actual_output_tokens >= 0
)
)
)
OR
(
status = 'RELEASED'
AND actual_asr_millis IS NULL
AND actual_input_tokens IS NULL
AND actual_output_tokens IS NULL
AND settled_credits IS NULL
AND settle_idempotency_key IS NULL
AND release_idempotency_key IS NOT NULL
AND refund_idempotency_key IS NULL
)
OR
(
status = 'REFUNDED'
AND settled_credits >= 0
AND settle_idempotency_key IS NOT NULL
AND release_idempotency_key IS NULL
AND refund_idempotency_key IS NOT NULL
AND (
(
usage_kind = 'ASR'
AND actual_asr_millis >= 0
AND actual_input_tokens IS NULL
AND actual_output_tokens IS NULL
)
OR
(
usage_kind = 'LLM'
AND actual_asr_millis IS NULL
AND actual_input_tokens >= 0
AND actual_output_tokens >= 0
)
)
)
)
) ENGINE = InnoDB;
CREATE TABLE referral_campaigns (
id CHAR(36) NOT NULL,
name VARCHAR(100) NOT NULL,
starts_at DATETIME(6) NOT NULL,
ends_at DATETIME(6) NULL,
binding_window_seconds BIGINT NOT NULL,
inviter_reward_credits BIGINT NOT NULL,
invitee_reward_credits BIGINT NOT NULL,
max_rewarded_bindings BIGINT NULL,
budget_credits BIGINT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
INDEX idx_referral_campaigns_active (enabled, starts_at, ends_at),
CONSTRAINT chk_referral_campaign_interval
CHECK (ends_at IS NULL OR ends_at > starts_at),
CONSTRAINT chk_referral_campaign_values CHECK (
binding_window_seconds > 0
AND inviter_reward_credits > 0
AND invitee_reward_credits > 0
AND inviter_reward_credits <= 9223372036854775807 - invitee_reward_credits
AND (max_rewarded_bindings IS NULL OR max_rewarded_bindings > 0)
AND (
budget_credits IS NULL
OR (
budget_credits >= inviter_reward_credits
AND budget_credits - inviter_reward_credits >= invitee_reward_credits
)
)
)
) ENGINE = InnoDB;
CREATE TABLE referral_campaign_budgets (
campaign_id CHAR(36) NOT NULL,
rewarded_bindings BIGINT NOT NULL DEFAULT 0,
spent_credits BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (campaign_id),
CONSTRAINT fk_referral_campaign_budget_campaign
FOREIGN KEY (campaign_id) REFERENCES referral_campaigns (id) ON DELETE CASCADE,
CONSTRAINT chk_referral_campaign_budget_non_negative
CHECK (rewarded_bindings >= 0 AND spent_credits >= 0)
) ENGINE = InnoDB;
INSERT INTO referral_campaigns (
id, name, starts_at, binding_window_seconds,
inviter_reward_credits, invitee_reward_credits, enabled
) VALUES (
'00000000-0000-0000-0000-000000000001',
'Default referral campaign',
'1970-01-01 00:00:00.000000',
604800,
3000,
3000,
TRUE
);
INSERT INTO referral_campaign_budgets (campaign_id)
VALUES ('00000000-0000-0000-0000-000000000001');
CREATE TABLE referral_codes (
id CHAR(36) NOT NULL,
owner_user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
campaign_id CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
code VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_referral_codes_owner_campaign (owner_user_id, campaign_id),
UNIQUE KEY uk_referral_codes_code (code),
CONSTRAINT fk_referral_codes_campaign
FOREIGN KEY (campaign_id) REFERENCES referral_campaigns (id) ON DELETE RESTRICT
) ENGINE = InnoDB;
CREATE TABLE referral_bindings (
id CHAR(36) NOT NULL,
inviter_user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
invitee_user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
code_id CHAR(36) NOT NULL,
campaign_id CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
bound_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
rewarded_at DATETIME(6) NULL,
reward_settlement_id CHAR(36) NULL,
reward_status VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin
NOT NULL DEFAULT 'PENDING',
PRIMARY KEY (id),
UNIQUE KEY uk_referral_bindings_invitee (invitee_user_id),
UNIQUE KEY uk_referral_bindings_settlement (reward_settlement_id),
INDEX idx_referral_bindings_inviter (inviter_user_id),
CONSTRAINT fk_referral_bindings_code
FOREIGN KEY (code_id) REFERENCES referral_codes (id) ON DELETE RESTRICT,
CONSTRAINT fk_referral_bindings_campaign
FOREIGN KEY (campaign_id) REFERENCES referral_campaigns (id) ON DELETE RESTRICT,
CONSTRAINT chk_referral_bindings_no_self CHECK (inviter_user_id <> invitee_user_id),
CONSTRAINT chk_referral_bindings_reward_pair CHECK (
(
reward_status IN ('PENDING', 'INELIGIBLE_BUDGET')
AND rewarded_at IS NULL
AND reward_settlement_id IS NULL
)
OR
(
reward_status = 'REWARDED'
AND rewarded_at IS NOT NULL
AND reward_settlement_id IS NOT NULL
)
)
) ENGINE = InnoDB;
CREATE TABLE credit_usage_records (
id CHAR(36) NOT NULL,
reservation_id CHAR(36) NOT NULL,
user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
rate_version_id CHAR(36) NOT NULL,
usage_kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
asr_millis BIGINT NULL,
input_tokens BIGINT NULL,
output_tokens BIGINT NULL,
charged_credits BIGINT NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_credit_usage_reservation (reservation_id),
INDEX idx_credit_usage_user_created (user_id, created_at),
CONSTRAINT fk_credit_usage_reservation
FOREIGN KEY (reservation_id) REFERENCES credit_reservations (id) ON DELETE CASCADE,
CONSTRAINT fk_credit_usage_rate
FOREIGN KEY (rate_version_id) REFERENCES credit_rate_versions (id) ON DELETE RESTRICT,
CONSTRAINT fk_credit_usage_account
FOREIGN KEY (user_id) REFERENCES credit_accounts (user_id) ON DELETE CASCADE,
CONSTRAINT chk_credit_usage_credits CHECK (charged_credits >= 0),
CONSTRAINT chk_credit_usage_shape CHECK (
(
usage_kind = 'ASR'
AND asr_millis >= 0
AND input_tokens IS NULL
AND output_tokens IS NULL
)
OR
(
usage_kind = 'LLM'
AND asr_millis IS NULL
AND input_tokens >= 0
AND output_tokens >= 0
)
)
) ENGINE = InnoDB;
CREATE TABLE credit_ledger (
id CHAR(36) NOT NULL,
user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
entry_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
amount_delta BIGINT NOT NULL,
balance_after BIGINT NOT NULL,
idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
reference_id CHAR(36) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_credit_ledger_idempotency (user_id, idempotency_key),
INDEX idx_credit_ledger_user_created (user_id, created_at, id),
INDEX idx_credit_ledger_reference (reference_id),
CONSTRAINT chk_credit_ledger_balance CHECK (balance_after >= 0),
CONSTRAINT chk_credit_ledger_key_length
CHECK (CHAR_LENGTH(idempotency_key) BETWEEN 8 AND 128),
CONSTRAINT chk_credit_ledger_type CHECK (
entry_type IN (
'SIGNUP_TRIAL',
'MANUAL_GRANT',
'USAGE_RESERVE',
'USAGE_SETTLE',
'USAGE_RELEASE',
'USAGE_REFUND',
'REFERRAL_INVITER',
'REFERRAL_INVITEE',
'STOREKIT_PURCHASE',
'SUBSCRIPTION_GRANT'
)
)
) ENGINE = InnoDB;
-- The application repository exposes append-only operations for ledger, rate,
-- and usage tables. Production additionally grants the runtime user SELECT and
-- INSERT only on these tables. Avoiding stored triggers keeps migrations
-- compatible with binary-logged MySQL without granting global SUPER privilege.
@@ -0,0 +1,76 @@
CREATE TABLE provider_requests (
request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
provider_id VARCHAR(64) NOT NULL,
capability VARCHAR(32) NOT NULL,
status VARCHAR(24) NOT NULL,
provider_request_id VARCHAR(128) NULL,
server_duration_millis BIGINT UNSIGNED NULL,
error_code VARCHAR(96) NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
completed_at TIMESTAMP(6) NULL,
PRIMARY KEY (request_id),
INDEX idx_provider_requests_account_created (account_id, created_at),
INDEX idx_provider_requests_provider_created (provider_id, created_at),
CONSTRAINT fk_provider_requests_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE usage_records (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
meter VARCHAR(32) NOT NULL,
units BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uq_usage_records_request_meter (request_id, meter),
CONSTRAINT fk_usage_records_provider_request
FOREIGN KEY (request_id) REFERENCES provider_requests (request_id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE gateway_grants (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
idempotency_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
expires_at TIMESTAMP(6) NOT NULL,
revoked_at TIMESTAMP(6) NULL,
created_at TIMESTAMP(6) NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_gateway_grants_account_idempotency (account_id, idempotency_key),
INDEX idx_gateway_grants_account_active (account_id, revoked_at, expires_at),
CONSTRAINT fk_gateway_grants_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE gateway_grant_scopes (
grant_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
capability VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
PRIMARY KEY (grant_id, capability),
CONSTRAINT fk_gateway_grant_scopes_grant
FOREIGN KEY (grant_id) REFERENCES gateway_grants (id) ON DELETE CASCADE,
CONSTRAINT chk_gateway_grant_scope
CHECK (capability IN ('POLISH', 'AI', 'AGENT', 'ASR'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE gateway_refresh_tokens (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
grant_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
family_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
replaced_by_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
rotation_idempotency_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
expires_at TIMESTAMP(6) NOT NULL,
revoked_at TIMESTAMP(6) NULL,
reuse_detected_at TIMESTAMP(6) NULL,
created_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_gateway_refresh_token_hash (token_hash),
INDEX idx_gateway_refresh_grant (grant_id),
INDEX idx_gateway_refresh_family (family_id),
CONSTRAINT fk_gateway_refresh_grant
FOREIGN KEY (grant_id) REFERENCES gateway_grants (id) ON DELETE CASCADE,
CONSTRAINT fk_gateway_refresh_replacement
FOREIGN KEY (replaced_by_id) REFERENCES gateway_refresh_tokens (id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,51 @@
CREATE TABLE devicecheck_trial_claims (
device_token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (device_token_hash),
KEY ix_devicecheck_trial_account (account_id),
CONSTRAINT fk_devicecheck_trial_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE,
CONSTRAINT chk_devicecheck_trial_status
CHECK (status IN ('RESERVED', 'APPLE_MARKED', 'COMPLETED', 'REJECTED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE app_attest_challenges (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
key_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
purpose VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
challenge_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
expires_at DATETIME(6) NOT NULL,
consumed_at DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
KEY ix_app_attest_challenge_expiry (expires_at),
KEY ix_app_attest_challenge_account (account_id),
CONSTRAINT fk_app_attest_challenge_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE,
CONSTRAINT chk_app_attest_challenge_purpose
CHECK (purpose IN ('ATTESTATION', 'ASSERTION')),
CONSTRAINT chk_app_attest_challenge_status
CHECK (status IN ('ISSUED', 'CONSUMED', 'EXPIRED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE app_attest_keys (
key_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
public_key_base64 VARCHAR(512) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
receipt_base64 MEDIUMTEXT CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
sign_counter BIGINT NOT NULL DEFAULT 0,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (key_id),
KEY ix_app_attest_keys_account (account_id),
CONSTRAINT fk_app_attest_keys_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE SET NULL,
CONSTRAINT chk_app_attest_counter_non_negative CHECK (sign_counter >= 0),
CONSTRAINT chk_app_attest_key_status CHECK (status IN ('ACTIVE', 'REVOKED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,113 @@
ALTER TABLE accounts
ADD COLUMN identity_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
ADD COLUMN anti_abuse_restricted BOOLEAN NOT NULL DEFAULT FALSE,
ADD UNIQUE KEY uq_accounts_identity_fingerprint (identity_fingerprint);
CREATE TABLE account_identity_tombstones (
identity_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
deleted_at DATETIME(6) NOT NULL,
expires_at DATETIME(6) NOT NULL,
PRIMARY KEY (identity_fingerprint),
INDEX ix_account_tombstones_expiry (expires_at),
CONSTRAINT chk_account_tombstone_interval CHECK (expires_at > deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE apple_revocation_outbox (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
encrypted_refresh_token MEDIUMTEXT NULL,
created_at DATETIME(6) NOT NULL,
next_attempt_at DATETIME(6) NOT NULL,
attempt_count INT NOT NULL DEFAULT 0,
completed_at DATETIME(6) NULL,
PRIMARY KEY (id),
INDEX ix_apple_revocation_pending (completed_at, next_attempt_at),
CONSTRAINT chk_apple_revocation_attempts CHECK (attempt_count >= 0),
CONSTRAINT chk_apple_revocation_token_lifecycle CHECK (
(completed_at IS NULL AND encrypted_refresh_token IS NOT NULL)
OR
(completed_at IS NOT NULL AND encrypted_refresh_token IS NULL)
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- Existing V2/V3 tables predated account foreign keys. Remove only already-orphaned
-- mutable state before enforcing referential integrity. The immutable credit ledger
-- intentionally remains pseudonymized by its now-unmapped random account UUID.
DELETE ur
FROM usage_records ur
LEFT JOIN provider_requests pr ON pr.request_id = ur.request_id
WHERE pr.request_id IS NULL;
DELETE rb
FROM referral_bindings rb
LEFT JOIN accounts inviter ON inviter.id = rb.inviter_user_id
LEFT JOIN accounts invitee ON invitee.id = rb.invitee_user_id
LEFT JOIN referral_codes rc ON rc.id = rb.code_id
LEFT JOIN accounts code_owner ON code_owner.id = rc.owner_user_id
WHERE inviter.id IS NULL
OR invitee.id IS NULL
OR rc.id IS NULL
OR code_owner.id IS NULL
OR rc.owner_user_id <> rb.inviter_user_id;
DELETE rc
FROM referral_codes rc
LEFT JOIN accounts a ON a.id = rc.owner_user_id
WHERE a.id IS NULL;
DELETE cr
FROM credit_reservations cr
LEFT JOIN accounts a ON a.id = cr.user_id
WHERE a.id IS NULL;
DELETE ca
FROM credit_accounts ca
LEFT JOIN accounts a ON a.id = ca.user_id
WHERE a.id IS NULL;
DELETE ur
FROM usage_records ur
JOIN provider_requests pr ON pr.request_id = ur.request_id
LEFT JOIN accounts a ON a.id = pr.account_id
WHERE a.id IS NULL;
DELETE pr
FROM provider_requests pr
LEFT JOIN accounts a ON a.id = pr.account_id
WHERE a.id IS NULL;
DELETE gg
FROM gateway_grants gg
LEFT JOIN accounts a ON a.id = gg.account_id
WHERE a.id IS NULL;
ALTER TABLE credit_accounts
ADD CONSTRAINT fk_credit_accounts_user
FOREIGN KEY (user_id) REFERENCES accounts (id) ON DELETE CASCADE;
ALTER TABLE credit_reservations
ADD CONSTRAINT fk_credit_reservations_user
FOREIGN KEY (user_id) REFERENCES accounts (id) ON DELETE CASCADE;
ALTER TABLE referral_bindings
DROP FOREIGN KEY fk_referral_bindings_code;
ALTER TABLE referral_bindings
ADD CONSTRAINT fk_referral_bindings_code
FOREIGN KEY (code_id) REFERENCES referral_codes (id) ON DELETE CASCADE,
ADD CONSTRAINT fk_referral_bindings_inviter
FOREIGN KEY (inviter_user_id) REFERENCES accounts (id) ON DELETE CASCADE,
ADD CONSTRAINT fk_referral_bindings_invitee
FOREIGN KEY (invitee_user_id) REFERENCES accounts (id) ON DELETE CASCADE;
ALTER TABLE referral_codes
ADD COLUMN owner_identity_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
ADD CONSTRAINT fk_referral_codes_owner
FOREIGN KEY (owner_user_id) REFERENCES accounts (id) ON DELETE CASCADE;
ALTER TABLE usage_records
DROP FOREIGN KEY fk_usage_records_provider_request;
ALTER TABLE usage_records
ADD CONSTRAINT fk_usage_records_provider_request
FOREIGN KEY (request_id) REFERENCES provider_requests (request_id) ON DELETE CASCADE;
@@ -0,0 +1,65 @@
-- Make client request IDs idempotent within an account, not globally.
ALTER TABLE usage_records
DROP FOREIGN KEY fk_usage_records_provider_request,
DROP INDEX uq_usage_records_request_meter,
ADD COLUMN account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER id;
UPDATE usage_records ur
JOIN provider_requests pr ON pr.request_id = ur.request_id
SET ur.account_id = pr.account_id;
ALTER TABLE usage_records
MODIFY account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL;
-- Normalize every legacy value before installing the binary status column and
-- stricter CHECK. Unknown historical states are held for manual review rather
-- than making a non-transactional MySQL migration fail halfway through.
UPDATE provider_requests
SET status = CASE UPPER(status)
WHEN 'SUCCEEDED' THEN 'SETTLED'
WHEN 'FAILED' THEN 'MANUAL_REVIEW'
WHEN 'CLAIMED' THEN 'CLAIMED'
WHEN 'STARTED' THEN 'STARTED'
WHEN 'SETTLEMENT_PENDING' THEN 'SETTLEMENT_PENDING'
WHEN 'SETTLED' THEN 'SETTLED'
WHEN 'RELEASED' THEN 'RELEASED'
WHEN 'MANUAL_REVIEW' THEN 'MANUAL_REVIEW'
ELSE 'MANUAL_REVIEW'
END;
ALTER TABLE provider_requests
ADD COLUMN reservation_id CHAR(36) NULL AFTER account_id,
ADD COLUMN usage_meter VARCHAR(32) NULL AFTER provider_request_id,
ADD COLUMN usage_units BIGINT UNSIGNED NULL AFTER usage_meter,
ADD COLUMN usage_input_units BIGINT UNSIGNED NULL AFTER usage_units,
ADD COLUMN usage_output_units BIGINT UNSIGNED NULL AFTER usage_input_units,
MODIFY COLUMN status VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
DROP PRIMARY KEY,
ADD PRIMARY KEY (account_id, request_id),
ADD UNIQUE KEY uq_provider_requests_reservation (reservation_id),
ADD INDEX idx_provider_requests_status_created (status, created_at),
ADD CONSTRAINT chk_provider_request_status CHECK (
status IN (
'CLAIMED',
'STARTED',
'SETTLEMENT_PENDING',
'SETTLED',
'RELEASED',
'MANUAL_REVIEW'
)
);
ALTER TABLE usage_records
ADD UNIQUE KEY uq_usage_records_account_request_meter (account_id, request_id, meter),
ADD CONSTRAINT fk_usage_records_provider_request
FOREIGN KEY (account_id, request_id)
REFERENCES provider_requests (account_id, request_id)
ON DELETE CASCADE;
-- A rotated refresh row points to its replacement. RESTRICT can block the
-- account -> grant -> refresh cascade when an account is deleted.
ALTER TABLE gateway_refresh_tokens
DROP FOREIGN KEY fk_gateway_refresh_replacement,
ADD CONSTRAINT fk_gateway_refresh_replacement
FOREIGN KEY (replaced_by_id) REFERENCES gateway_refresh_tokens (id)
ON DELETE SET NULL;
@@ -0,0 +1,49 @@
-- Initial integer-credit rate cards. Future price changes must insert a new
-- immutable version and close the previous effective interval.
INSERT INTO credit_rate_versions (
id,
kind,
provider,
model,
effective_from,
effective_until,
asr_credits_numerator,
asr_millis_denominator,
created_at
) VALUES (
'10000000-0000-0000-0000-000000000001',
'ASR',
'volcengine-sauc-v3',
'volc.seedasr.sauc.duration',
'1970-01-01 00:00:00.000000',
NULL,
1,
1000,
UTC_TIMESTAMP(6)
);
INSERT INTO credit_rate_versions (
id,
kind,
provider,
model,
effective_from,
effective_until,
input_credits_numerator,
input_tokens_denominator,
output_credits_numerator,
output_tokens_denominator,
created_at
) VALUES (
'10000000-0000-0000-0000-000000000002',
'LLM',
'deepseek',
'deepseek-v4-flash',
'1970-01-01 00:00:00.000000',
NULL,
1,
100,
2,
100,
UTC_TIMESTAMP(6)
);
@@ -0,0 +1,17 @@
{
"applinks": {
"details": [
{
"appIDs": [
"{{APPLE_APP_ID}}"
],
"components": [
{
"/": "/i/*",
"comment": "Open first-party OSG invitation links in the app"
}
]
}
]
}
}
+184
View File
@@ -0,0 +1,184 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow,noarchive">
<meta name="color-scheme" content="light dark">
<title>OSG 邀请 / Invitation</title>
<style nonce="{{CSP_NONCE}}">
:root {
color-scheme: light dark;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f5f7;
color: #1d1d1f;
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right))
max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left));
}
main {
width: min(100%, 460px);
padding: clamp(24px, 7vw, 40px);
border: 1px solid #dedee3;
border-radius: 24px;
background: #fff;
text-align: center;
box-shadow: 0 16px 48px rgb(0 0 0 / 8%);
}
.language-switcher {
display: flex;
justify-content: flex-end;
gap: 6px;
margin: -8px -8px 20px 0;
}
.language-button {
min-height: 36px;
padding: 7px 11px;
border: 1px solid #d2d2d7;
border-radius: 999px;
background: transparent;
color: inherit;
font: inherit;
font-size: .82rem;
cursor: pointer;
}
.language-button[aria-pressed="true"] {
border-color: #1d1d1f;
background: #1d1d1f;
color: #fff;
}
h1 { margin: 0 0 12px; font-size: clamp(1.65rem, 7vw, 2.25rem); line-height: 1.15; }
p { margin: 10px 0; color: #6e6e73; line-height: 1.55; }
code {
display: block;
margin: 24px 0;
padding: 17px 10px;
overflow-wrap: anywhere;
border: 1px solid #e5e5ea;
border-radius: 14px;
background: #f5f5f7;
color: #1d1d1f;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: clamp(1rem, 4.8vw, 1.35rem);
letter-spacing: .05em;
user-select: all;
}
.actions { display: grid; gap: 12px; }
.action {
display: grid;
min-height: 50px;
place-items: center;
padding: 13px 18px;
border: 1px solid transparent;
border-radius: 14px;
font: inherit;
font-weight: 650;
cursor: pointer;
text-decoration: none;
}
.primary { background: #0071e3; color: #fff; }
.secondary { border-color: #d2d2d7; background: #fff; color: #1d1d1f; }
.store-link { background: #1d1d1f; color: #fff; }
.action:focus-visible, .language-button:focus-visible {
outline: 3px solid #69aaf5;
outline-offset: 3px;
}
.hint { margin-top: 22px; font-size: .88rem; }
#copy-status { min-height: 1.4em; margin-bottom: 0; font-size: .9rem; }
[data-language] { display: none; }
html[lang="zh-CN"] [data-language="zh-CN"],
html[lang="en"] [data-language="en"] { display: inline; }
@media (prefers-color-scheme: dark) {
:root { background: #101214; color: #f4f5f7; }
main { border-color: #30343a; background: #1a1d21; box-shadow: none; }
p { color: #b5bbc4; }
code { border-color: #3a3f46; background: #282c32; color: #f4f5f7; }
.language-button { border-color: #555b64; }
.language-button[aria-pressed="true"] { border-color: #f4f5f7; background: #f4f5f7; color: #17191c; }
.secondary { border-color: #555b64; background: #282c32; color: #f4f5f7; }
.store-link { background: #f4f5f7; color: #17191c; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<main>
<nav class="language-switcher" aria-label="Language / 语言">
<button class="language-button" id="language-zh" type="button" aria-pressed="true">中文</button>
<button class="language-button" id="language-en" type="button" aria-pressed="false">English</button>
</nav>
<h1>
<span data-language="zh-CN">加入 OSG</span>
<span data-language="en">Join OSG</span>
</h1>
<p>
<span data-language="zh-CN">使用此邀请码开始体验。</span>
<span data-language="en">Use this invitation code to get started.</span>
</p>
<code id="invite-code" aria-label="邀请码 / Invitation code">{{INVITE_CODE}}</code>
<div class="actions">
<button class="action primary" id="copy-button" type="button">
<span data-language="zh-CN">复制邀请码</span>
<span data-language="en">Copy invitation code</span>
</button>
<a class="action secondary" href="{{UNIVERSAL_LINK}}" rel="noopener">
<span data-language="zh-CN">打开 App</span>
<span data-language="en">Open App</span>
</a>
<a class="action store-link" href="{{APP_STORE_URL}}" rel="noopener noreferrer">
<span data-language="zh-CN">前往 App Store</span>
<span data-language="en">Download on the App Store</span>
</a>
</div>
<p id="copy-status" role="status" aria-live="polite"></p>
<p class="hint">
<span data-language="zh-CN">若“打开 App”仍停留在浏览器,请从信息或邮件中再次轻点原邀请链接。</span>
<span data-language="en">If “Open App” stays in the browser, tap the original invitation link again from Messages or Mail.</span>
</p>
</main>
<script nonce="{{CSP_NONCE}}">
const copyButton = document.getElementById("copy-button");
const inviteCode = document.getElementById("invite-code").textContent;
const copyStatus = document.getElementById("copy-status");
const zhButton = document.getElementById("language-zh");
const enButton = document.getElementById("language-en");
let language = navigator.language.toLowerCase().startsWith("zh") ? "zh-CN" : "en";
function setLanguage(nextLanguage) {
language = nextLanguage;
document.documentElement.lang = language;
zhButton.setAttribute("aria-pressed", String(language === "zh-CN"));
enButton.setAttribute("aria-pressed", String(language === "en"));
copyStatus.textContent = "";
}
zhButton.addEventListener("click", () => setLanguage("zh-CN"));
enButton.addEventListener("click", () => setLanguage("en"));
setLanguage(language);
copyButton.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(inviteCode);
copyStatus.textContent = language === "zh-CN" ? "已复制" : "Copied";
} catch {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(document.getElementById("invite-code"));
selection.removeAllRanges();
selection.addRange(range);
copyStatus.textContent = language === "zh-CN"
? "无法自动复制,邀请码已选中"
: "Automatic copy failed; the code is selected";
}
});
</script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>{"time":"%date{ISO8601}","level":"%level","logger":"%logger{36}","message":"%replace(%msg){'[\r\n]+',' '}"}%n</pattern>
</encoder>
</appender>
<logger name="io.netty" level="WARN"/>
<logger name="org.jetbrains.exposed" level="WARN"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<root level="${LOG_LEVEL:-INFO}">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%date{ISO8601} %-5level [%thread] %logger{24} - %msg%n</pattern>
</encoder>
</appender>
<logger name="io.netty" level="WARN"/>
<logger name="org.jetbrains.exposed" level="WARN"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} %-5level [%thread] %logger{36} requestId=%X{requestId:-} - %msg%n</pattern>
</encoder>
</appender>
<logger name="io.netty" level="WARN"/>
<logger name="org.jetbrains.exposed" level="WARN"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<root level="${LOG_LEVEL:-INFO}">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
@@ -0,0 +1,19 @@
package com.osglab.account
import io.kotest.matchers.shouldBe
import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlin.test.Test
class ApplicationTest {
@Test
fun `liveness endpoint remains independent of external services`() = testApplication {
application {
routing { healthRoutes() }
}
client.get("/health/live").status shouldBe HttpStatusCode.OK
}
}
@@ -0,0 +1,84 @@
package com.osglab.account.common.api
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.common.security.SESSION_AUTH_NAME
import com.osglab.account.common.security.installSessionAuthentication
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.auth.authenticate
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
class ApiContractTest : FunSpec({
test("known API failures use the stable error envelope") {
testApplication {
application {
install(ContentNegotiation) { json(Json) }
installApiStatusPages()
routing {
get("/invalid") {
throw InvalidRequestException("Invalid input")
}
}
}
val response = client.get("/invalid")
response.status shouldBe HttpStatusCode.BadRequest
response.bodyAsText() shouldBe
"""{"error":{"code":"invalid_request","message":"Invalid input"}}"""
}
}
test("unexpected failures do not disclose exception details") {
testApplication {
application {
install(ContentNegotiation) { json(Json) }
installApiStatusPages()
routing {
get("/failure") {
error("database-password")
}
}
}
val response = client.get("/failure")
response.status shouldBe HttpStatusCode.InternalServerError
response.bodyAsText() shouldBe
"""{"error":{"code":"internal_error","message":"An internal error occurred"}}"""
}
}
test("authentication challenges use the same error envelope") {
testApplication {
application {
install(ContentNegotiation) { json(Json) }
installApiStatusPages()
installSessionAuthentication { null }
routing {
authenticate(SESSION_AUTH_NAME) {
get("/protected") {
call.respondText("unreachable")
}
}
}
}
val response = client.get("/protected")
response.status shouldBe HttpStatusCode.Unauthorized
response.bodyAsText() shouldBe
"""{"error":{"code":"unauthorized","message":"Authentication required"}}"""
}
}
})
@@ -0,0 +1,53 @@
package com.osglab.account.common.security
import com.osglab.account.config.SessionConfig
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
class SecurityPrimitivesTest : FunSpec({
test("refresh token hashes are deterministic without storing the token") {
TokenHash.sha256("secret") shouldBe
"2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b"
TokenHash.matches("secret", TokenHash.sha256("secret")) shouldBe true
TokenHash.matches("different", TokenHash.sha256("secret")) shouldBe false
}
test("AES-GCM uses unique nonces and authenticates context") {
val encryptor = FieldEncryptor(ByteArray(32) { 3 })
val first = encryptor.encrypt("apple-refresh-token", "account:1")
val second = encryptor.encrypt("apple-refresh-token", "account:1")
first shouldNotBe second
encryptor.decrypt(first, "account:1") shouldBe "apple-refresh-token"
shouldThrow<FieldDecryptionException> {
encryptor.decrypt(first, "account:2")
}
}
test("session JWT validates issuer audience signature and claims") {
val clock = Clock.fixed(Instant.parse("2026-08-15T12:00:00Z"), ZoneOffset.UTC)
val jwt = SessionJwt(
SessionConfig(
issuer = "https://issuer.example",
audience = "ios",
hmacSecret = ByteArray(32) { 9 },
accessMinutes = 15,
refreshDays = 30,
),
clock,
)
val accountId = UUID.randomUUID()
val sessionId = UUID.randomUUID()
val issued = jwt.issue(accountId, sessionId)
jwt.verify(issued.value)?.userId shouldBe accountId
jwt.verify(issued.value)?.sessionId shouldBe sessionId
jwt.verify(issued.value + "tampered") shouldBe null
}
})
@@ -0,0 +1,153 @@
package com.osglab.account.config
import io.ktor.server.config.MapApplicationConfig
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import java.util.Base64
class AppConfigTest : FunSpec({
test("test configuration can be injected without Apple client credentials") {
val config = AppConfig.from(validConfig("test"))
config.environment shouldBe Environment.TEST
config.apple.clientCredentialsAvailable shouldBe false
config.encryption.key.size shouldBe 32
}
test("production rejects placeholder secrets") {
val config = validProductionConfig().apply {
put("app.session.secret", "replace-with-secret")
}
shouldThrow<ConfigValidationException> {
AppConfig.from(config)
}
}
test("production accepts complete independent configuration") {
val config = AppConfig.from(validProductionConfig())
config.environment shouldBe Environment.PRODUCTION
config.database.username shouldBe "test"
config.database.migrationUsername shouldBe "test_migrator"
}
test("production fails fast when Apple signing credentials are missing") {
val config = validProductionConfig().apply {
put("app.apple.keyId", "")
}
shouldThrow<ConfigValidationException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "app.apple.keyId"
}
test("production rejects monitor-only integrity configuration") {
val config = validProductionConfig().apply {
put("app.providers.volcengine.apiKey", "volcengine-key")
put("app.providers.deepseek.apiKey", "deepseek-key")
put("app.integrity.enforceDeviceCheck", "false")
put("app.integrity.enforceAppAttest", "false")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "must enforce both DeviceCheck and App Attest"
}
test("production rejects provider endpoints outside the exact host allowlist") {
val config = validProductionConfig().apply {
put("app.providers.deepseek.endpoint", "https://127.0.0.1/v1")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "DeepSeek endpoint"
}
test("production requires separate migration credentials") {
val missingMigrator = validProductionConfig().apply {
put("app.database.migrationUsername", "")
}
shouldThrow<ConfigValidationException> {
AppConfig.from(missingMigrator)
}.message.orEmpty() shouldContain "app.database.migrationUsername"
val reusedPassword = validProductionConfig().apply {
put("app.database.migrationPassword", "database-password")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(reusedPassword)
}.message.orEmpty() shouldContain "passwords must be distinct"
}
test("production requires independent cryptographic secrets") {
val config = validProductionConfig().apply {
put(
"app.antiAbuse.identityHmacKeyBase64",
Base64.getEncoder().encodeToString(ByteArray(32) { 7 }),
)
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "must be distinct"
}
test("production requires exact public and App Store URLs") {
val publicUrl = validProductionConfig().apply {
put("app.publicBaseUrl", "https://account.osglab.com.evil.example")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(publicUrl)
}.message.orEmpty() shouldContain "PUBLIC_BASE_URL"
val appStoreUrl = validProductionConfig().apply {
put("app.appStoreUrl", "https://apps.apple.com/app/id0000000000")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(appStoreUrl)
}.message.orEmpty() shouldContain "APP_STORE_URL"
}
})
private fun validConfig(environment: String) = MapApplicationConfig(
"app.environment" to environment,
"app.database.jdbcUrl" to "jdbc:mysql://localhost:3306/test",
"app.database.username" to "test",
"app.database.password" to "database-password",
"app.database.migrationUsername" to "test_migrator",
"app.database.migrationPassword" to "migration-password",
"app.database.maximumPoolSize" to "4",
"app.session.issuer" to "https://issuer.example",
"app.session.audience" to "ios-app",
"app.session.secret" to "01234567890123456789012345678901",
"app.session.accessMinutes" to "15",
"app.session.refreshDays" to "30",
"app.encryption.keyBase64" to Base64.getEncoder().encodeToString(ByteArray(32) { 7 }),
"app.antiAbuse.identityHmacKeyBase64" to
Base64.getEncoder().encodeToString(ByteArray(32) { 8 }),
"app.apple.clientId" to "com.example.app",
"app.apple.jwksUrl" to "https://appleid.apple.com/auth/keys",
"app.apple.tokenUrl" to "https://appleid.apple.com/auth/token",
"app.apple.revokeUrl" to "https://appleid.apple.com/auth/revoke",
"app.integrity.enforceDeviceCheck" to "false",
"app.integrity.enforceAppAttest" to "false",
)
private fun validProductionConfig() = validConfig("production").apply {
put("app.publicBaseUrl", "https://account.osglab.com")
put("app.inviteBaseUrl", "https://osglab.com/i")
put("app.appStoreUrl", "https://apps.apple.com/app/id1234567890")
put("app.apple.teamId", APP_ATTEST_TEAM_ID)
put("app.apple.keyId", "APPLE_KEY")
put("app.apple.clientId", APP_ATTEST_BUNDLE_ID)
put("app.apple.privateKeyPem", "private-key-material")
put("app.integrity.appleEnvironment", "production")
put("app.integrity.enforceDeviceCheck", "true")
put("app.integrity.enforceAppAttest", "true")
put("app.providers.volcengine.apiKey", "volcengine-key")
put("app.providers.deepseek.apiKey", "deepseek-key")
}
@@ -0,0 +1,119 @@
package com.osglab.account.config
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import java.nio.file.Files
import java.nio.file.Path
class DeploymentConsistencyTest : FunSpec({
val root = Path.of(System.getProperty("user.dir"))
test("OpenAPI documents every mounted public route") {
val openApi = root.read("docs/openapi.yaml")
val documentedPaths = Regex("""(?m)^ (/[^:]+):\s*$""")
.findAll(openApi)
.map { it.groupValues[1] }
.toSet()
documentedPaths shouldBe EXPECTED_PUBLIC_PATHS
}
test("production Compose reuses private MySQL and hardens the application container") {
val compose = root.read("compose.yaml")
compose shouldContain "127.0.0.1:\${ACCOUNT_BIND_PORT:-18080}:8080"
compose shouldContain "external: true"
compose shouldContain "account-egress:"
compose shouldContain "user: \"10001:10001\""
compose shouldContain "read_only: true"
compose shouldContain "cap_drop:"
compose shouldContain "no-new-privileges:true"
compose shouldNotContain "image: mysql"
compose shouldNotContain "3306:3306"
compose shouldNotContain "0.0.0.0:"
}
test("container image remains non-root and read-only compatible") {
val dockerfile = root.read("Dockerfile")
dockerfile shouldContain "USER 10001:10001"
dockerfile shouldContain "ENV HOME=/tmp"
dockerfile shouldNotContain "ENTRYPOINT [\"sh\""
}
test("OpenResty proxies HTTP WebSocket invitations and both AASA paths safely") {
val openResty = root.read("deploy/openresty-account.conf")
openResty shouldContain "proxy_set_header Upgrade \$http_upgrade;"
openResty shouldContain "proxy_set_header Connection \$connection_upgrade;"
openResty shouldContain "location = /.well-known/apple-app-site-association"
openResty shouldContain "location = /apple-app-site-association"
openResty shouldContain "location ^~ /i/"
Regex("""location \^~ /i/ \{\s+access_log off;""").containsMatchIn(openResty) shouldBe true
openResty shouldNotContain "alias /www/wwwroot/osglab.com/apple-app-site-association"
}
test("CI definition is singular and leaves MySQL lifecycle to Testcontainers") {
val ci = root.read(".github/workflows/ci.yml")
Regex("""(?m)^name: CI$""").findAll(ci).count() shouldBe 1
Regex("""(?m)^jobs:$""").findAll(ci).count() shouldBe 1
ci shouldContain "docker compose -f compose.yaml config --quiet"
ci shouldContain "./gradlew --no-daemon clean test"
ci shouldContain "./gradlew --no-daemon buildFatJar"
ci shouldNotContain "3306:3306"
ci shouldNotContain "TEST_DB_"
}
test("AASA has one runtime template and no deploy-time identifier placeholder") {
val aasa = root.read("src/main/resources/invite/apple-app-site-association.json")
aasa shouldContain "\"{{APPLE_APP_ID}}\""
aasa shouldContain "\"/i/*\""
Files.exists(root.resolve("deploy/apple-app-site-association")) shouldBe false
}
})
private fun Path.read(relativePath: String): String =
Files.readString(resolve(relativePath))
private val EXPECTED_PUBLIC_PATHS = setOf(
"/health",
"/health/live",
"/health/ready",
"/v1/auth/apple",
"/v1/auth/refresh",
"/v1/auth/logout",
"/v1/account",
"/v1/apple/events",
"/v1/credits/balance",
"/v1/credits/ledger",
"/v1/credits/rates",
"/v1/credits/reservations",
"/v1/credits/reservations/{reservationId}",
"/v1/credits/reservations/{reservationId}/settle",
"/v1/credits/reservations/{reservationId}/release",
"/v1/credits/reservations/{reservationId}/refund",
"/v1/referrals",
"/v1/referrals/me",
"/v1/referrals/code",
"/v1/referrals/redeem",
"/v1/referrals/bind",
"/v1/referrals/campaigns",
"/v1/integrity/challenges",
"/v1/integrity/attest",
"/v1/integrity/assert",
"/v1/gateway/catalog",
"/v1/gateway/grants",
"/v1/gateway/grants/refresh",
"/v1/gateway/grants/{grantId}",
"/v1/gateway/llm/{capability}",
"/v1/gateway/asr",
"/v1/gateway/asr/sessions",
"/v1/gateway/asr/sessions/{sessionId}/stream",
"/.well-known/apple-app-site-association",
"/apple-app-site-association",
"/i/{code}",
)
@@ -0,0 +1,171 @@
package com.osglab.account.features.account
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.config.AntiAbuseConfig
import com.osglab.account.features.auth.AppleTokenClient
import com.osglab.account.features.auth.AppleTokenExchange
import com.osglab.account.features.auth.AppleClientUnavailableException
import com.osglab.account.features.auth.appleRefreshContext
import io.kotest.core.spec.style.FunSpec
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.util.UUID
class AccountServiceTest : FunSpec({
test("account deletion commits locally before reliably revoking the Apple token") {
val accountId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
val events = mutableListOf<String>()
val repository = RecordingAccountRepository(
AccountRecord(
id = accountId,
identityFingerprint = "a".repeat(64),
antiAbuseRestricted = false,
encryptedAppleRefreshToken = encryptor.encrypt(
"apple-refresh",
appleRefreshContext(accountId),
),
createdAt = Instant.EPOCH,
),
events,
)
val appleClient = RecordingAppleTokenClient(events)
val processor = AppleRevocationOutboxProcessor(repository, appleClient, encryptor)
val service = AccountService(
repository,
encryptor,
AntiAbuseConfig(ByteArray(32) { 9 }, 365),
processor,
AccountReauthenticator { _, _ -> "apple-refresh" },
)
service.delete(accountId, REAUTH_PROOF)
appleClient.revokedToken shouldBe "apple-refresh"
repository.deleted shouldBe true
events shouldBe listOf("local-delete", "apple-revoke", "outbox-complete")
}
test("Apple outage never rolls back local deletion and leaves durable outbox work") {
val accountId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
val events = mutableListOf<String>()
val repository = RecordingAccountRepository(
AccountRecord(
id = accountId,
identityFingerprint = "b".repeat(64),
antiAbuseRestricted = false,
encryptedAppleRefreshToken = encryptor.encrypt(
"apple-refresh",
appleRefreshContext(accountId),
),
createdAt = Instant.EPOCH,
),
events,
)
val appleClient = RecordingAppleTokenClient(events, unavailable = true)
val service = AccountService(
repository,
encryptor,
AntiAbuseConfig(ByteArray(32) { 9 }, 365),
AppleRevocationOutboxProcessor(repository, appleClient, encryptor),
AccountReauthenticator { _, _ -> "apple-refresh" },
)
service.delete(accountId, REAUTH_PROOF)
repository.deleted shouldBe true
repository.pendingCount shouldBe 1
}
test("account deletion requires recent matching Apple credentials") {
val accountId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
val repository = RecordingAccountRepository(
AccountRecord(
id = accountId,
identityFingerprint = "c".repeat(64),
antiAbuseRestricted = false,
encryptedAppleRefreshToken = null,
createdAt = Instant.EPOCH,
),
mutableListOf(),
)
val appleClient = RecordingAppleTokenClient(mutableListOf())
val service = AccountService(
repository,
encryptor,
AntiAbuseConfig(ByteArray(32) { 9 }, 365),
AppleRevocationOutboxProcessor(repository, appleClient, encryptor),
AccountReauthenticator { _, _ -> throw UnauthorizedException() },
)
shouldThrow<UnauthorizedException> {
service.delete(accountId, REAUTH_PROOF)
}
repository.deleted shouldBe false
}
})
private val REAUTH_PROOF = AppleReauthenticationProof(
identityToken = "identity-token",
authorizationCode = "authorization-code",
nonce = "nonce",
)
private class RecordingAccountRepository(
private val account: AccountRecord,
private val events: MutableList<String>,
) : AccountRepository {
var deleted = false
private var pending: AppleRevocationOutboxRecord? = null
val pendingCount: Int get() = if (pending == null) 0 else 1
override suspend fun findById(accountId: UUID): AccountRecord? = account
override suspend fun deleteById(
accountId: UUID,
deletedAt: Instant,
tombstoneExpiresAt: Instant,
createRevocation: (String?) -> NewAppleRevocation?,
): Boolean {
deleted = true
events += "local-delete"
val revocation = createRevocation(account.encryptedAppleRefreshToken)
pending = revocation?.let {
AppleRevocationOutboxRecord(it.id, it.encryptedRefreshToken, 0)
}
return true
}
override suspend fun pendingAppleRevocations(
now: Instant,
limit: Int,
): List<AppleRevocationOutboxRecord> = listOfNotNull(pending)
override suspend fun rescheduleAppleRevocation(id: UUID, nextAttemptAt: Instant) = Unit
override suspend fun completeAppleRevocation(id: UUID, completedAt: Instant) {
pending = null
events += "outbox-complete"
}
}
private class RecordingAppleTokenClient(
private val events: MutableList<String>,
private val unavailable: Boolean = false,
) : AppleTokenClient {
var revokedToken: String? = null
override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange =
error("Not used by account deletion")
override suspend fun revokeRefreshToken(refreshToken: String) {
if (unavailable) throw AppleClientUnavailableException()
revokedToken = refreshToken
events += "apple-revoke"
}
}
@@ -0,0 +1,14 @@
package com.osglab.account.features.appleevents
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
class AppleEventRepositoryTest : FunSpec({
test("both official and observed Apple account deletion event names terminate accounts") {
isAccountTerminatingAppleEvent("account-delete").shouldBeTrue()
isAccountTerminatingAppleEvent("account-deleted").shouldBeTrue()
isAccountTerminatingAppleEvent("consent-revoked").shouldBeTrue()
isAccountTerminatingAppleEvent("email-enabled").shouldBeFalse()
}
})
@@ -0,0 +1,114 @@
package com.osglab.account.features.appleevents
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.RSASSASigner
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import com.osglab.account.features.auth.AppleJwksProvider
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.security.KeyPairGenerator
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.Date
class AppleEventVerifierTest : FunSpec({
val now = Instant.parse("2026-08-16T00:00:00Z")
val trustedKey = newRsaKey("apple-events")
val verifier = AppleEventVerifier(
config = AppleConfig(
teamId = null,
keyId = null,
clientId = "com.example.ios",
privateKeyPem = null,
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
),
jwksProvider = object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? =
trustedKey.toPublicJWK().takeIf { keyId == trustedKey.keyID }
},
clock = Clock.fixed(now, ZoneOffset.UTC),
)
test("accepts event fields only after the JWS signature is verified") {
val event = verifier.verify(signedEvent(trustedKey, now))
event shouldBe VerifiedAppleEvent(
eventId = "event-1",
type = "consent-revoked",
appleSubject = "apple-subject",
)
}
test("rejects an attacker-signed payload even when its claims look valid") {
val attackerKey = newRsaKey("apple-events")
shouldThrow<InvalidAppleEventException> {
verifier.verify(signedEvent(attackerKey, now))
}
}
test("rejects an unsigned JSON payload") {
shouldThrow<InvalidAppleEventException> {
verifier.verify("""{"events":{"type":"account-delete","sub":"apple-subject"}}""")
}
}
test("rejects an event without expiration or with ambiguous audiences") {
shouldThrow<InvalidAppleEventException> {
verifier.verify(signedEvent(trustedKey, now, includeExpiration = false))
}
shouldThrow<InvalidAppleEventException> {
verifier.verify(signedEvent(trustedKey, now, additionalAudience = "other-client"))
}
}
test("event string rendering never exposes the Apple subject") {
VerifiedAppleEvent("event-1", "consent-revoked", "sensitive-apple-subject").toString() shouldBe
"VerifiedAppleEvent(eventId=event-1, type=consent-revoked, appleSubject=[REDACTED])"
}
})
private fun newRsaKey(keyId: String): RSAKey {
val pair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
return RSAKey.Builder(pair.public as RSAPublicKey)
.privateKey(pair.private as RSAPrivateKey)
.keyID(keyId)
.build()
}
private fun signedEvent(
key: RSAKey,
now: Instant,
includeExpiration: Boolean = true,
additionalAudience: String? = null,
): String {
val claimsBuilder = JWTClaimsSet.Builder()
.issuer("https://appleid.apple.com")
.audience(listOfNotNull("com.example.ios", additionalAudience))
.jwtID("event-1")
.issueTime(Date.from(now))
.claim(
"events",
"""{"type":"consent-revoked","sub":"apple-subject"}""",
)
if (includeExpiration) {
claimsBuilder.expirationTime(Date.from(now.plusSeconds(300)))
}
val claims = claimsBuilder.build()
return SignedJWT(
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.keyID).build(),
claims,
).apply {
sign(RSASSASigner(key.toPrivateKey()))
}.serialize()
}

Some files were not shown because too many files have changed in this diff Show More