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