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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -0,0 +1,15 @@
|
||||
.gradle/
|
||||
.idea/
|
||||
.kotlin/
|
||||
build/
|
||||
out/
|
||||
.DS_Store
|
||||
.env
|
||||
*.local
|
||||
*.iml
|
||||
*.log
|
||||
*.p8
|
||||
*.jks
|
||||
*.keystore
|
||||
docker/mysql/
|
||||
secrets/
|
||||
+25
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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。
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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。
|
||||
@@ -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 Key;DeepSeek 使用 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/443,3306/8080/18080 不可达;邀请 URL 不出现在访问日志;
|
||||
供应商凭据可用;完成加密备份并记录一次隔离恢复结果。
|
||||
@@ -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.%';
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
Vendored
BIN
Binary file not shown.
+10
@@ -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
|
||||
@@ -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
@@ -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%
|
||||
@@ -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,
|
||||
)
|
||||
+55
@@ -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
|
||||
}
|
||||
+674
@@ -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)
|
||||
+448
@@ -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?,
|
||||
)
|
||||
+248
@@ -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)
|
||||
+400
@@ -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
|
||||
}
|
||||
}
|
||||
+485
@@ -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) {
|
||||
'&' -> "&"
|
||||
'<' -> "<"
|
||||
'>' -> ">"
|
||||
'"' -> """
|
||||
'\'' -> "'"
|
||||
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,
|
||||
)
|
||||
+42
@@ -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-----
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user