From 246c3e147f37cc6381fd09a69ead18270ff7f3c5 Mon Sep 17 00:00:00 2001 From: iamEvara Date: Thu, 23 Jul 2026 01:12:31 +0300 Subject: [PATCH 1/2] feat(auth): add Hive and Google provisioning foundation --- README.md | 4 + apps/api/.env.example | 22 + apps/api/README.md | 44 ++ apps/api/package.json | 6 +- apps/api/src/app.module.ts | 6 + apps/api/src/auth/access-session.guard.ts | 50 +++ apps/api/src/auth/auth.config.spec.ts | 43 ++ apps/api/src/auth/auth.config.ts | 165 ++++++++ apps/api/src/auth/auth.controller.ts | 98 +++++ apps/api/src/auth/auth.module.ts | 57 +++ apps/api/src/auth/auth.service.spec.ts | 166 ++++++++ apps/api/src/auth/auth.service.ts | 276 ++++++++++++ apps/api/src/auth/auth.types.ts | 139 ++++++ .../src/auth/custodial-player-signer.spec.ts | 62 +++ apps/api/src/auth/custodial-player-signer.ts | 60 +++ .../src/auth/disclosure-principal.guard.ts | 50 +++ apps/api/src/auth/disclosure.controller.ts | 82 ++++ apps/api/src/auth/disclosure.service.spec.ts | 63 +++ apps/api/src/auth/disclosure.service.ts | 84 ++++ apps/api/src/auth/google-oidc.gateway.ts | 75 ++++ .../auth/hive-posting-authority.verifier.ts | 127 ++++++ apps/api/src/auth/onboarding-session.guard.ts | 19 + apps/api/src/auth/postgres-auth.repository.ts | 394 ++++++++++++++++++ apps/api/src/auth/token.service.spec.ts | 53 +++ apps/api/src/auth/token.service.ts | 145 +++++++ apps/api/src/database/database.connection.ts | 23 + apps/api/src/database/database.module.ts | 10 + .../api/src/provisioning/onboarding.config.ts | 27 ++ .../src/provisioning/onboarding.controller.ts | 121 ++++++ .../src/provisioning/onboarding.repository.ts | 152 +++++++ .../src/provisioning/onboarding.service.ts | 136 ++++++ .../postgres-provisioning.repository.ts | 249 +++++++++++ .../src/provisioning/provisioning.module.ts | 37 ++ .../provisioning/provisioning.service.spec.ts | 124 ++++++ .../src/provisioning/provisioning.service.ts | 137 ++++++ .../src/provisioning/provisioning.types.ts | 95 +++++ ...velopment-realtime-principal.guard.spec.ts | 67 --- .../development-realtime-principal.guard.ts | 43 -- apps/api/src/realtime/realtime.config.spec.ts | 33 +- apps/api/src/realtime/realtime.config.ts | 59 +-- apps/api/src/realtime/realtime.controller.ts | 23 +- apps/api/src/realtime/realtime.module.ts | 4 +- .../api/src/realtime/realtime.service.spec.ts | 6 +- apps/api/src/realtime/realtime.types.ts | 4 +- .../data-model/hive-chameleon.dbml | 25 ++ .../20260723000100_auth_foundation.sql | 51 +++ infra/postgres/test/001_schema.sql | 12 +- package-lock.json | 62 ++- packages/database/package.json | 4 +- packages/database/src/client.ts | 2 + packages/database/src/index.ts | 1 + packages/hive-gateway/package.json | 14 +- runtime/nakama/README.md | 19 +- runtime/nakama/smoke.mjs | 39 +- scripts/check-nakama-bridge.sh | 53 ++- 55 files changed, 3654 insertions(+), 268 deletions(-) create mode 100644 apps/api/.env.example create mode 100644 apps/api/README.md create mode 100644 apps/api/src/auth/access-session.guard.ts create mode 100644 apps/api/src/auth/auth.config.spec.ts create mode 100644 apps/api/src/auth/auth.config.ts create mode 100644 apps/api/src/auth/auth.controller.ts create mode 100644 apps/api/src/auth/auth.module.ts create mode 100644 apps/api/src/auth/auth.service.spec.ts create mode 100644 apps/api/src/auth/auth.service.ts create mode 100644 apps/api/src/auth/auth.types.ts create mode 100644 apps/api/src/auth/custodial-player-signer.spec.ts create mode 100644 apps/api/src/auth/custodial-player-signer.ts create mode 100644 apps/api/src/auth/disclosure-principal.guard.ts create mode 100644 apps/api/src/auth/disclosure.controller.ts create mode 100644 apps/api/src/auth/disclosure.service.spec.ts create mode 100644 apps/api/src/auth/disclosure.service.ts create mode 100644 apps/api/src/auth/google-oidc.gateway.ts create mode 100644 apps/api/src/auth/hive-posting-authority.verifier.ts create mode 100644 apps/api/src/auth/onboarding-session.guard.ts create mode 100644 apps/api/src/auth/postgres-auth.repository.ts create mode 100644 apps/api/src/auth/token.service.spec.ts create mode 100644 apps/api/src/auth/token.service.ts create mode 100644 apps/api/src/database/database.connection.ts create mode 100644 apps/api/src/database/database.module.ts create mode 100644 apps/api/src/provisioning/onboarding.config.ts create mode 100644 apps/api/src/provisioning/onboarding.controller.ts create mode 100644 apps/api/src/provisioning/onboarding.repository.ts create mode 100644 apps/api/src/provisioning/onboarding.service.ts create mode 100644 apps/api/src/provisioning/postgres-provisioning.repository.ts create mode 100644 apps/api/src/provisioning/provisioning.module.ts create mode 100644 apps/api/src/provisioning/provisioning.service.spec.ts create mode 100644 apps/api/src/provisioning/provisioning.service.ts create mode 100644 apps/api/src/provisioning/provisioning.types.ts delete mode 100644 apps/api/src/realtime/development-realtime-principal.guard.spec.ts delete mode 100644 apps/api/src/realtime/development-realtime-principal.guard.ts create mode 100644 infra/postgres/migrations/20260723000100_auth_foundation.sql diff --git a/README.md b/README.md index be7380b..759c2e9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ The API then exposes: - `GET http://localhost:3000/api/v1/health/live` - `GET http://localhost:3000/api/v1/health/ready` +Authentication and Google-to-Hive provisioning configuration is documented in +[`apps/api/README.md`](apps/api/README.md). The API intentionally fails closed for durable identity +operations when PostgreSQL or an approved external provider is not configured. + Build the Nakama module with the runtime-compatible toolchain: ```bash diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..c44f87a --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,22 @@ +NODE_ENV=development +PORT=3000 +DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5433/hive_chameleon?sslmode=disable + +# Generate independent values with: openssl rand -base64 48 | tr '+/' '-_' | tr -d '=' +AUTH_TOKEN_SECRET= +AUTH_IDENTITY_LOOKUP_KEY= +AUTH_TOKEN_ISSUER=hive-chameleon-api +AUTH_TOKEN_AUDIENCE=hive-chameleon-client + +HIVE_RPC_URL=https://api.hive.blog/ +HIVE_CHAIN_ID=0000000000000000000000000000000000000000000000000000000000000000 + +# Configure all three Google values together; redirect URIs are an exact comma-separated allowlist. +GOOGLE_OIDC_CLIENT_ID= +GOOGLE_OIDC_CLIENT_SECRET= +GOOGLE_OIDC_REDIRECT_URIS= + +# Provider metadata only. Enabling these does not install or approve sponsor/custody adapters. +PROVISIONING_SPONSOR_PROGRAM= +PROVISIONING_SPONSOR_POLICY_VERSION= +PROVISIONING_SPONSOR_HIVE_ACCOUNT= diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..068d91b --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,44 @@ +# Product API + +The NestJS API now owns direct-Hive and Google game-session establishment, refresh rotation, +logout, restricted Google onboarding, and the scoped Nakama handoff. + +## Local configuration + +Start the application PostgreSQL stack and apply migrations, then copy `.env.example` to a local +ignored `.env` or export the values through your shell. Production refuses to start without +independent 32-byte `AUTH_TOKEN_SECRET` and `AUTH_IDENTITY_LOOKUP_KEY` values. The first signs +short-lived access/onboarding JWTs and HMACs refresh-token lookups; the second makes the verified +Google issuer/subject lookup deterministic without storing the raw subject or email. + +Direct Hive login signs the exact UTF-8 challenge returned by +`POST /api/v1/auth/hive/challenges`. Keychain/HiveAuth integrations submit the resulting canonical +65-byte compact secp256k1 signature as 130 lowercase hexadecimal characters. The backend consumes +the challenge once, recovers the signing key from SHA-256 of the exact challenge bytes, and checks +the account's current posting authority through Hive RPC before creating a player session. + +Google uses authorization-code + PKCE exchange through Google's maintained Node library. Redirect +URIs must match the configured allowlist exactly. Only the verified issuer and stable subject are +used for identity; email is never an ownership key. A new subject receives an onboarding token, +not a playable player or ordinary access token. + +## Provisioning gate + +The account-provisioning coordinator uses four provider-neutral contracts: + +- a custody provider generates non-exportable owner, active, posting, and memo keys and returns + only public keys plus opaque references; +- an approved signup sponsor consumes an account-creation token via + `claim_account`/`create_claimed_account` and delegates initial Resource Credits; +- the Hive projection verifies the exact account, authorities, RC evidence, and irreversibility; +- the PostgreSQL repository makes every stage retryable under stable idempotency keys and links a + playable identity only after all verification passes. + +No production custody provider or signup sponsor is selected by this repository. The onboarding +API reports `503 provisioning_provider_unavailable` until approved adapters and policy metadata +are configured. This is deliberate fail-closed behavior; Hetzner is the hosting environment, not +a substitute for a custody/key-management provider. + +The `CustodialPlayerSigner` accepts only an already-authorized, expiring grant containing an opaque +key reference. It verifies every returned signature against the expected public key and never +accepts a raw private key, arbitrary client-selected key reference, or an AWS-specific object. diff --git a/apps/api/package.json b/apps/api/package.json index a52910f..ac3dab1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,12 +13,16 @@ }, "dependencies": { "@heroiclabs/nakama-js": "2.8.0", + "@hive-chameleon/database": "^0.1.0", + "@hive-chameleon/hive-gateway": "^0.1.0", "@nestjs/common": "11.1.28", "@nestjs/config": "4.0.4", "@nestjs/core": "11.1.28", "@nestjs/platform-express": "11.1.28", + "google-auth-library": "^10.5.0", "reflect-metadata": "0.2.2", - "rxjs": "7.8.2" + "rxjs": "7.8.2", + "zod": "^4.4.3" }, "devDependencies": { "@nestjs/cli": "11.0.24", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2e6a1d4..cf32630 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,7 +1,10 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { AuthModule } from './auth/auth.module'; +import { DatabaseModule } from './database/database.module'; import { HealthModule } from './health/health.module'; +import { ProvisioningModule } from './provisioning/provisioning.module'; import { RealtimeModule } from './realtime/realtime.module'; @Module({ @@ -11,6 +14,9 @@ import { RealtimeModule } from './realtime/realtime.module'; ignoreEnvFile: process.env.NODE_ENV === 'production', isGlobal: true, }), + DatabaseModule, + AuthModule, + ProvisioningModule, HealthModule, RealtimeModule, ], diff --git a/apps/api/src/auth/access-session.guard.ts b/apps/api/src/auth/access-session.guard.ts new file mode 100644 index 0000000..127e58d --- /dev/null +++ b/apps/api/src/auth/access-session.guard.ts @@ -0,0 +1,50 @@ +import { + type CanActivate, + type ExecutionContext, + Inject, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; + +import { AUTH_REPOSITORY, type AuthHttpRequest, type AuthRepository } from './auth.types'; +import { TokenService } from './token.service'; + +@Injectable() +export class AccessSessionGuard implements CanActivate { + public constructor( + @Inject(TokenService) private readonly tokens: TokenService, + @Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository, + ) {} + + public async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = bearerToken(request.headers.authorization); + const claims = this.tokens.verifyAccessToken(token, new Date()); + const session = await this.repository.findActiveSession(claims.sessionId, new Date()); + if (!session || session.playerId !== claims.playerId) { + throw invalidSession(); + } + request.authPrincipal = session; + return true; + } +} + +export function bearerToken(header: string | string[] | undefined): string { + if (typeof header !== 'string' || !header.startsWith('Bearer ')) { + throw invalidSession(); + } + const token = header.slice('Bearer '.length); + if (!token || token.includes(' ')) { + throw invalidSession(); + } + return token; +} + +function invalidSession(): UnauthorizedException { + return new UnauthorizedException({ + code: 'invalid_session', + detail: 'The session credential is missing, invalid, expired, or revoked.', + status: 401, + title: 'Unauthorized', + }); +} diff --git a/apps/api/src/auth/auth.config.spec.ts b/apps/api/src/auth/auth.config.spec.ts new file mode 100644 index 0000000..4c71cc8 --- /dev/null +++ b/apps/api/src/auth/auth.config.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { loadAuthConfig } from './auth.config'; + +const secret = Buffer.alloc(32, 7).toString('base64url'); + +describe('loadAuthConfig', () => { + it('requires independent production token and identity lookup secrets', () => { + expect(() => loadAuthConfig({ NODE_ENV: 'production' })).toThrow(/AUTH_TOKEN_SECRET/); + expect(() => loadAuthConfig({ AUTH_TOKEN_SECRET: secret, NODE_ENV: 'production' })).toThrow( + /AUTH_IDENTITY_LOOKUP_KEY/, + ); + }); + + it('accepts a complete Google OIDC configuration and exact redirect allowlist', () => { + const config = loadAuthConfig({ + AUTH_IDENTITY_LOOKUP_KEY: secret, + AUTH_TOKEN_SECRET: secret, + GOOGLE_OIDC_CLIENT_ID: 'client-id', + GOOGLE_OIDC_CLIENT_SECRET: 'client-secret', + GOOGLE_OIDC_REDIRECT_URIS: 'https://play.example.com/oidc/callback', + NODE_ENV: 'production', + }); + + expect(config.google?.redirectUris.has('https://play.example.com/oidc/callback')).toBe(true); + }); + + it('rejects partial Google configuration and non-TLS production redirects', () => { + expect(() => loadAuthConfig({ GOOGLE_OIDC_CLIENT_ID: 'client-id' })).toThrow( + /requires .* together/, + ); + expect(() => + loadAuthConfig({ + AUTH_IDENTITY_LOOKUP_KEY: secret, + AUTH_TOKEN_SECRET: secret, + GOOGLE_OIDC_CLIENT_ID: 'client-id', + GOOGLE_OIDC_CLIENT_SECRET: 'client-secret', + GOOGLE_OIDC_REDIRECT_URIS: 'http://play.example.com/callback', + NODE_ENV: 'production', + }), + ).toThrow(/HTTPS/); + }); +}); diff --git a/apps/api/src/auth/auth.config.ts b/apps/api/src/auth/auth.config.ts new file mode 100644 index 0000000..043dd65 --- /dev/null +++ b/apps/api/src/auth/auth.config.ts @@ -0,0 +1,165 @@ +import { randomBytes } from 'node:crypto'; + +export interface GoogleOidcConfig { + readonly clientId: string; + readonly clientSecret: string; + readonly redirectUris: ReadonlySet; +} + +export interface AuthConfig { + readonly accessTokenTtlSeconds: number; + readonly audience: string; + readonly challengeTtlSeconds: number; + readonly google: GoogleOidcConfig | null; + readonly hiveChainId: string; + readonly hiveRpcUrl: string; + readonly identityLookupKey: Buffer; + readonly issuer: string; + readonly refreshTokenTtlSeconds: number; + readonly tokenKey: Buffer; +} + +export function loadAuthConfig(environment: NodeJS.ProcessEnv = process.env): AuthConfig { + const production = environment.NODE_ENV === 'production'; + const tokenKey = readSecret(environment.AUTH_TOKEN_SECRET, 'AUTH_TOKEN_SECRET', production); + const identityLookupKey = environment.AUTH_IDENTITY_LOOKUP_KEY + ? decodeSecret(environment.AUTH_IDENTITY_LOOKUP_KEY, 'AUTH_IDENTITY_LOOKUP_KEY') + : production + ? missingSecret('AUTH_IDENTITY_LOOKUP_KEY') + : tokenKey; + + const hiveRpcUrl = environment.HIVE_RPC_URL?.trim() || 'https://api.hive.blog/'; + assertHttpsEndpoint(hiveRpcUrl, 'HIVE_RPC_URL'); + const hiveChainId = environment.HIVE_CHAIN_ID?.trim() || '0'.repeat(64); + if (!/^[0-9a-f]{64}$/.test(hiveChainId)) { + throw new Error('HIVE_CHAIN_ID must be 32 lowercase hexadecimal bytes.'); + } + + return { + accessTokenTtlSeconds: readInteger(environment.AUTH_ACCESS_TTL_SECONDS, 900, 60, 3_600), + audience: environment.AUTH_TOKEN_AUDIENCE?.trim() || 'hive-chameleon-client', + challengeTtlSeconds: readInteger(environment.AUTH_CHALLENGE_TTL_SECONDS, 300, 60, 900), + google: readGoogleConfig(environment, production), + hiveChainId, + hiveRpcUrl, + identityLookupKey, + issuer: environment.AUTH_TOKEN_ISSUER?.trim() || 'hive-chameleon-api', + refreshTokenTtlSeconds: readInteger( + environment.AUTH_REFRESH_TTL_SECONDS, + 30 * 24 * 60 * 60, + 3_600, + 90 * 24 * 60 * 60, + ), + tokenKey, + }; +} + +function readGoogleConfig( + environment: NodeJS.ProcessEnv, + production: boolean, +): GoogleOidcConfig | null { + const clientId = environment.GOOGLE_OIDC_CLIENT_ID?.trim(); + const clientSecret = environment.GOOGLE_OIDC_CLIENT_SECRET?.trim(); + const redirectValues = (environment.GOOGLE_OIDC_REDIRECT_URIS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + if (!clientId && !clientSecret && redirectValues.length === 0) { + return null; + } + if (!clientId || !clientSecret || redirectValues.length === 0) { + throw new Error( + 'Google OIDC requires GOOGLE_OIDC_CLIENT_ID, GOOGLE_OIDC_CLIENT_SECRET, and GOOGLE_OIDC_REDIRECT_URIS together.', + ); + } + const redirectUris = new Set( + redirectValues.map((value) => canonicalRedirectUri(value, production)), + ); + return { clientId, clientSecret, redirectUris }; +} + +function canonicalRedirectUri(value: string, production: boolean): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('GOOGLE_OIDC_REDIRECT_URIS must contain absolute HTTP or HTTPS URLs.'); + } + const localDevelopment = + !production && + url.protocol === 'http:' && + (url.hostname === '127.0.0.1' || url.hostname === 'localhost'); + if ( + (!localDevelopment && url.protocol !== 'https:') || + url.username !== '' || + url.password !== '' || + url.hash !== '' + ) { + throw new Error( + 'GOOGLE_OIDC_REDIRECT_URIS must use HTTPS (except local development) and contain no credentials or fragment.', + ); + } + return url.toString(); +} + +function readSecret(value: string | undefined, name: string, required: boolean): Buffer { + if (!value) { + if (required) { + return missingSecret(name); + } + return randomBytes(32); + } + return decodeSecret(value, name); +} + +function decodeSecret(value: string, name: string): Buffer { + if (!/^[A-Za-z0-9_-]{43,}$/.test(value)) { + throw new Error(`${name} must be an unpadded base64url value containing at least 32 bytes.`); + } + const decoded = Buffer.from(value, 'base64url'); + if (decoded.length < 32) { + throw new Error(`${name} must contain at least 32 bytes.`); + } + return decoded; +} + +function missingSecret(name: string): never { + throw new Error(`${name} is required in production.`); +} + +function readInteger( + value: string | undefined, + fallback: number, + minimum: number, + maximum: number, +): number { + if (!value) { + return fallback; + } + if (!/^\d+$/.test(value)) { + throw new Error(`Authentication TTL must be an integer between ${minimum} and ${maximum}.`); + } + const parsed = Number.parseInt(value, 10); + if (parsed < minimum || parsed > maximum) { + throw new Error(`Authentication TTL must be an integer between ${minimum} and ${maximum}.`); + } + return parsed; +} + +function assertHttpsEndpoint(value: string, name: string): void { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${name} must be an absolute HTTPS URL.`); + } + if ( + url.protocol !== 'https:' || + url.username !== '' || + url.password !== '' || + url.search !== '' || + url.hash !== '' + ) { + throw new Error(`${name} must be HTTPS without credentials, query, or fragment.`); + } +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..dff2e57 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,98 @@ +import { + Body, + Controller, + Delete, + Header, + HttpCode, + HttpStatus, + Inject, + Post, + Req, + UnprocessableEntityException, + UseGuards, +} from '@nestjs/common'; +import { hiveAccountSchema } from '@hive-chameleon/hive-gateway'; +import { z } from 'zod'; + +import { AccessSessionGuard } from './access-session.guard'; +import { AuthService } from './auth.service'; +import type { AuthHttpRequest } from './auth.types'; + +const clientPlatformSchema = z.enum(['webgl', 'windows', 'macos', 'linux']); +const hiveChallengeSchema = z.strictObject({ + hiveUsername: hiveAccountSchema, + platform: clientPlatformSchema, +}); +const hiveSessionSchema = z.strictObject({ + challengeId: z.string().uuid(), + hiveUsername: hiveAccountSchema, + signature: z.string().regex(/^[0-9a-f]{130}$/), + signingProvider: z.enum(['keychain', 'hiveauth', 'hivesigner']), +}); +const googleExchangeSchema = z.strictObject({ + authorizationCode: z.string().min(1).max(4_096), + codeVerifier: z.string().min(43).max(128), + platform: clientPlatformSchema, + redirectUri: z.string().url().max(2_048), +}); +const refreshSchema = z.strictObject({ refreshToken: z.string().min(32).max(2_048) }); + +@Controller('auth') +export class AuthController { + public constructor(@Inject(AuthService) private readonly auth: AuthService) {} + + @Post('hive/challenges') + @HttpCode(HttpStatus.CREATED) + @Header('Cache-Control', 'no-store') + public async createHiveChallenge(@Body() body: unknown) { + const input = parse(hiveChallengeSchema, body); + return this.auth.createHiveChallenge(input.hiveUsername, input.platform); + } + + @Post('hive/sessions') + @HttpCode(HttpStatus.CREATED) + @Header('Cache-Control', 'no-store') + public async createHiveSession(@Body() body: unknown) { + return this.auth.createHiveSession(parse(hiveSessionSchema, body)); + } + + @Post('google/exchange') + @HttpCode(HttpStatus.OK) + @Header('Cache-Control', 'no-store') + public async exchangeGoogle(@Body() body: unknown) { + return this.auth.exchangeGoogle(parse(googleExchangeSchema, body)); + } + + @Post('refresh') + @HttpCode(HttpStatus.OK) + @Header('Cache-Control', 'no-store') + public async refresh(@Body() body: unknown) { + return this.auth.refresh(parse(refreshSchema, body).refreshToken); + } + + @Delete('session') + @UseGuards(AccessSessionGuard) + @HttpCode(HttpStatus.NO_CONTENT) + public async revokeSession(@Req() request: AuthHttpRequest): Promise { + if (request.authPrincipal) { + await this.auth.revokeSession(request.authPrincipal.authSessionId); + } + } +} + +function parse(schema: z.ZodType, input: unknown): T { + const result = schema.safeParse(input); + if (!result.success) { + throw new UnprocessableEntityException({ + code: 'validation_failed', + detail: 'The request body does not match the authentication contract.', + errors: result.error.issues.map((issue) => ({ + message: issue.message, + path: issue.path.join('.'), + })), + status: 422, + title: 'Validation failed', + }); + } + return result.data; +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..8b294df --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,57 @@ +import { Module } from '@nestjs/common'; + +import { DatabaseConnection } from '../database/database.connection'; +import { AccessSessionGuard } from './access-session.guard'; +import { loadAuthConfig, type AuthConfig } from './auth.config'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { + AUTH_CONFIG, + AUTH_REPOSITORY, + GOOGLE_OIDC_GATEWAY, + HIVE_POSTING_AUTHORITY_VERIFIER, +} from './auth.types'; +import { GoogleAuthLibraryOidcGateway } from './google-oidc.gateway'; +import { DisclosureController } from './disclosure.controller'; +import { DisclosurePrincipalGuard } from './disclosure-principal.guard'; +import { DisclosureService } from './disclosure.service'; +import { RpcHivePostingAuthorityVerifier } from './hive-posting-authority.verifier'; +import { OnboardingSessionGuard } from './onboarding-session.guard'; +import { PostgresAuthRepository } from './postgres-auth.repository'; +import { TokenService } from './token.service'; + +@Module({ + controllers: [AuthController, DisclosureController], + exports: [ + AccessSessionGuard, + OnboardingSessionGuard, + TokenService, + AUTH_REPOSITORY, + HIVE_POSTING_AUTHORITY_VERIFIER, + ], + providers: [ + { provide: AUTH_CONFIG, useFactory: loadAuthConfig }, + { + inject: [DatabaseConnection], + provide: AUTH_REPOSITORY, + useFactory: (database: DatabaseConnection) => new PostgresAuthRepository(database.pool), + }, + { + inject: [AUTH_CONFIG], + provide: HIVE_POSTING_AUTHORITY_VERIFIER, + useFactory: (config: AuthConfig) => new RpcHivePostingAuthorityVerifier(config), + }, + { + inject: [AUTH_CONFIG], + provide: GOOGLE_OIDC_GATEWAY, + useFactory: (config: AuthConfig) => new GoogleAuthLibraryOidcGateway(config.google), + }, + AccessSessionGuard, + AuthService, + DisclosurePrincipalGuard, + DisclosureService, + OnboardingSessionGuard, + TokenService, + ], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..21c0f62 --- /dev/null +++ b/apps/api/src/auth/auth.service.spec.ts @@ -0,0 +1,166 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { AuthConfig } from './auth.config'; +import { AuthService } from './auth.service'; +import type { + AuthRepository, + GoogleOidcGateway, + HivePostingAuthorityVerifier, + PlayerIdentity, +} from './auth.types'; +import { TokenService } from './token.service'; + +const now = new Date('2026-07-23T00:00:00.000Z'); +const player: PlayerIdentity = { + hiveControlState: 'external_self_custodial', + hiveUsername: 'alice', + id: '01980abc-def0-7abc-8def-0123456789ab', +}; + +describe('AuthService', () => { + let repository: AuthRepository; + let hive: HivePostingAuthorityVerifier; + let google: GoogleOidcGateway; + + beforeEach(() => { + repository = { + consumeHiveChallenge: vi.fn(), + createHiveChallenge: vi.fn(), + createSession: vi.fn(), + findActiveSession: vi.fn(), + findOrCreateDirectHivePlayer: vi.fn().mockResolvedValue(player), + getCurrentDisclosure: vi.fn(), + acknowledgeCurrentDisclosure: vi.fn(), + revokeSession: vi.fn(), + rotateSession: vi.fn(), + upsertGoogleIdentity: vi.fn(), + }; + hive = { usernameExists: vi.fn(), verify: vi.fn() }; + google = { exchange: vi.fn() }; + }); + + it('creates a canonical, expiring, random direct-Hive challenge', async () => { + const service = createService(repository, hive, google); + const result = await service.createHiveChallenge('alice', 'webgl', now); + + expect(result.challenge).toContain('Hive Chameleon login\naudience:test-client\naccount:alice'); + expect(result.expiresAt).toBe('2026-07-23T00:05:00.000Z'); + expect(repository.createHiveChallenge).toHaveBeenCalledOnce(); + }); + + it('burns a challenge once and issues a revocable session only after posting verification', async () => { + vi.mocked(repository.consumeHiveChallenge).mockResolvedValue({ + challenge: 'signed challenge', + expiresAt: new Date('2026-07-23T00:05:00.000Z'), + hiveUsername: 'alice', + id: '01980abc-def2-7abc-8def-0123456789ab', + platform: 'webgl', + }); + vi.mocked(hive.verify).mockResolvedValue(true); + const service = createService(repository, hive, google); + + const result = await service.createHiveSession( + { + challengeId: '01980abc-def2-7abc-8def-0123456789ab', + hiveUsername: 'alice', + signature: '20'.padEnd(130, '0'), + signingProvider: 'keychain', + }, + now, + ); + + expect(result.kind).toBe('authenticated'); + expect(result.player).toMatchObject({ hiveUsername: 'alice' }); + expect(repository.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + authenticationMethod: 'direct_hive_challenge', + hiveSigningProvider: 'keychain', + refreshTokenHash: expect.stringMatching(/^[0-9a-f]{64}$/), + }), + ); + }); + + it('does not create a player or session after an invalid signature', async () => { + vi.mocked(repository.consumeHiveChallenge).mockResolvedValue({ + challenge: 'signed challenge', + expiresAt: new Date('2026-07-23T00:05:00.000Z'), + hiveUsername: 'alice', + id: '01980abc-def2-7abc-8def-0123456789ab', + platform: 'webgl', + }); + vi.mocked(hive.verify).mockResolvedValue(false); + + await expect( + createService(repository, hive, google).createHiveSession( + { + challengeId: '01980abc-def2-7abc-8def-0123456789ab', + hiveUsername: 'alice', + signature: '20'.padEnd(130, '0'), + signingProvider: 'keychain', + }, + now, + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(repository.findOrCreateDirectHivePlayer).not.toHaveBeenCalled(); + expect(repository.createSession).not.toHaveBeenCalled(); + }); + + it('maps a first-time Google subject to a restricted onboarding token without email', async () => { + vi.mocked(google.exchange).mockResolvedValue({ + issuer: 'https://accounts.google.com', + subject: 'stable-subject', + }); + vi.mocked(repository.upsertGoogleIdentity).mockResolvedValue({ + disclosureAcknowledged: false, + id: '01980abc-def3-7abc-8def-0123456789ab', + player: null, + status: 'provisioning', + }); + + const result = await createService(repository, hive, google).exchangeGoogle( + { + authorizationCode: 'code', + codeVerifier: 'v'.repeat(43), + platform: 'webgl', + redirectUri: 'https://play.example.com/callback', + }, + now, + ); + + expect(result).toMatchObject({ + externalIdentityId: '01980abc-def3-7abc-8def-0123456789ab', + kind: 'onboarding', + nextStep: 'acknowledge_disclosure', + }); + expect(repository.upsertGoogleIdentity).toHaveBeenCalledWith( + 'https://accounts.google.com', + expect.stringMatching(/^[0-9a-f]{64}$/), + now, + ); + }); +}); + +function createService( + repository: AuthRepository, + hive: HivePostingAuthorityVerifier, + google: GoogleOidcGateway, +): AuthService { + const authConfig = config(); + return new AuthService(authConfig, repository, hive, google, new TokenService(authConfig)); +} + +function config(): AuthConfig { + return { + accessTokenTtlSeconds: 900, + audience: 'test-client', + challengeTtlSeconds: 300, + google: null, + hiveChainId: '0'.repeat(64), + hiveRpcUrl: 'https://api.hive.blog/', + identityLookupKey: Buffer.alloc(32, 2), + issuer: 'test-api', + refreshTokenTtlSeconds: 3_600, + tokenKey: Buffer.alloc(32, 1), + }; +} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..9bafa97 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,276 @@ +import { createHash, createHmac, randomBytes } from 'node:crypto'; + +import { createUuidV7 } from '@hive-chameleon/database'; +import { ConflictException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; + +import type { AuthConfig } from './auth.config'; +import { + AUTH_CONFIG, + AUTH_REPOSITORY, + GOOGLE_OIDC_GATEWAY, + HIVE_POSTING_AUTHORITY_VERIFIER, + type AuthRepository, + type ClientPlatform, + type ExternalSigningProvider, + type GoogleOidcGateway, + type HivePostingAuthorityVerifier, + type NewSession, + type PlayerIdentity, +} from './auth.types'; +import { TokenService } from './token.service'; + +export interface AuthTokensResponse { + readonly accessToken: string; + readonly accessTokenExpiresAt: string; + readonly kind: 'authenticated'; + readonly player: { + readonly hiveUsername: string; + readonly id: string; + }; + readonly refreshToken: string; + readonly sessionId: string; +} + +export type GoogleExchangeResponse = + | AuthTokensResponse + | { + readonly expiresAt: string; + readonly externalIdentityId: string; + readonly kind: 'onboarding'; + readonly nextStep: 'acknowledge_disclosure' | 'choose_username'; + readonly onboardingToken: string; + }; + +@Injectable() +export class AuthService { + public constructor( + @Inject(AUTH_CONFIG) private readonly config: AuthConfig, + @Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository, + @Inject(HIVE_POSTING_AUTHORITY_VERIFIER) + private readonly hiveVerifier: HivePostingAuthorityVerifier, + @Inject(GOOGLE_OIDC_GATEWAY) private readonly googleGateway: GoogleOidcGateway, + @Inject(TokenService) private readonly tokens: TokenService, + ) {} + + public async createHiveChallenge( + hiveUsername: string, + platform: ClientPlatform, + now = new Date(), + ): Promise<{ + readonly challenge: string; + readonly expiresAt: string; + readonly hiveUsername: string; + readonly id: string; + }> { + const id = createUuidV7(); + const deviceSessionId = createUuidV7(); + const expiresAt = new Date(now.getTime() + this.config.challengeTtlSeconds * 1_000); + const challenge = [ + 'Hive Chameleon login', + `audience:${this.config.audience}`, + `account:${hiveUsername}`, + `device_session:${deviceSessionId}`, + `nonce:${randomBytes(32).toString('base64url')}`, + `issued_at:${now.toISOString()}`, + `expires_at:${expiresAt.toISOString()}`, + ].join('\n'); + await this.repository.createHiveChallenge({ + challenge, + challengeSha256: createHash('sha256').update(challenge, 'utf8').digest('hex'), + deviceSessionId, + expiresAt, + hiveUsername, + id, + issuedAt: now, + platform, + }); + return { challenge, expiresAt: expiresAt.toISOString(), hiveUsername, id }; + } + + public async createHiveSession( + input: { + readonly challengeId: string; + readonly hiveUsername: string; + readonly signature: string; + readonly signingProvider: ExternalSigningProvider; + }, + now = new Date(), + ): Promise { + const challenge = await this.repository.consumeHiveChallenge( + input.challengeId, + input.hiveUsername, + now, + ); + if (!challenge) { + throw invalidHiveLogin(); + } + const verified = await this.hiveVerifier.verify({ + challenge: challenge.challenge, + hiveUsername: input.hiveUsername, + signature: input.signature, + }); + if (!verified) { + throw invalidHiveLogin(); + } + const player = await this.repository.findOrCreateDirectHivePlayer(input.hiveUsername); + return this.createSession( + player, + challenge.platform, + 'direct_hive_challenge', + input.signingProvider, + null, + false, + now, + ); + } + + public async exchangeGoogle( + input: { + readonly authorizationCode: string; + readonly codeVerifier: string; + readonly platform: ClientPlatform; + readonly redirectUri: string; + }, + now = new Date(), + ): Promise { + const identity = await this.googleGateway.exchange(input); + const subjectLookupHash = createHmac('sha256', this.config.identityLookupKey) + .update(identity.issuer) + .update('\0') + .update(identity.subject) + .digest('hex'); + const externalIdentity = await this.repository.upsertGoogleIdentity( + identity.issuer, + subjectLookupHash, + now, + ); + if (externalIdentity.status === 'disabled') { + throw new ConflictException({ + code: 'identity_disabled', + detail: 'This verified identity is disabled.', + status: 409, + title: 'Identity disabled', + }); + } + if (externalIdentity.player) { + return this.createSession( + externalIdentity.player, + input.platform, + 'google_oidc', + 'custodial_service', + externalIdentity.id, + externalIdentity.player.hiveControlState === 'platform_custodial', + now, + ); + } + const onboarding = this.tokens.issueOnboardingToken(externalIdentity.id, now); + return { + expiresAt: onboarding.expiresAt.toISOString(), + externalIdentityId: externalIdentity.id, + kind: 'onboarding', + nextStep: externalIdentity.disclosureAcknowledged + ? 'choose_username' + : 'acknowledge_disclosure', + onboardingToken: onboarding.token, + }; + } + + public async refresh(refreshToken: string, now = new Date()): Promise { + const nextRefreshToken = createRefreshToken(); + const session = await this.repository.rotateSession( + this.hashRefreshToken(refreshToken), + this.hashRefreshToken(nextRefreshToken), + now, + ); + if (!session) { + throw invalidSession(); + } + const access = this.tokens.issueAccessToken(session.player.id, session.authSessionId, now); + return responseTokens( + session.player, + session.authSessionId, + nextRefreshToken, + access.token, + access.expiresAt, + ); + } + + public async revokeSession(sessionId: string, now = new Date()): Promise { + await this.repository.revokeSession(sessionId, now, 'player_logout'); + } + + private async createSession( + player: PlayerIdentity, + platform: ClientPlatform, + authenticationMethod: NewSession['authenticationMethod'], + signingProvider: NewSession['hiveSigningProvider'], + externalIdentityId: string | null, + custodialSigningEligible: boolean, + now: Date, + ): Promise { + const sessionId = createUuidV7(); + const refreshToken = createRefreshToken(); + const expiresAt = new Date(now.getTime() + this.config.refreshTokenTtlSeconds * 1_000); + await this.repository.createSession({ + authenticationMethod, + custodialSigningEligible, + expiresAt, + externalIdentityId, + hiveSigningProvider: signingProvider, + id: sessionId, + issuedAt: now, + platform, + player, + refreshTokenHash: this.hashRefreshToken(refreshToken), + }); + const access = this.tokens.issueAccessToken(player.id, sessionId, now); + return responseTokens(player, sessionId, refreshToken, access.token, access.expiresAt); + } + + private hashRefreshToken(token: string): string { + return createHmac('sha256', this.config.tokenKey).update(token).digest('hex'); + } +} + +function createRefreshToken(): string { + return randomBytes(48).toString('base64url'); +} + +function responseTokens( + player: PlayerIdentity, + sessionId: string, + refreshToken: string, + accessToken: string, + accessTokenExpiresAt: Date, +): AuthTokensResponse { + return { + accessToken, + accessTokenExpiresAt: accessTokenExpiresAt.toISOString(), + kind: 'authenticated', + player: { + hiveUsername: player.hiveUsername, + id: player.id, + }, + refreshToken, + sessionId, + }; +} + +function invalidHiveLogin(): UnauthorizedException { + return new UnauthorizedException({ + code: 'invalid_hive_challenge', + detail: + 'The challenge is invalid, expired, already used, or was not signed by current posting authority.', + status: 401, + title: 'Unauthorized', + }); +} + +function invalidSession(): UnauthorizedException { + return new UnauthorizedException({ + code: 'invalid_session', + detail: 'The refresh credential is invalid, expired, rotated, or revoked.', + status: 401, + title: 'Unauthorized', + }); +} diff --git a/apps/api/src/auth/auth.types.ts b/apps/api/src/auth/auth.types.ts new file mode 100644 index 0000000..6608267 --- /dev/null +++ b/apps/api/src/auth/auth.types.ts @@ -0,0 +1,139 @@ +import type { BridgePrincipal } from '../realtime/bridge-assertion'; + +export type ClientPlatform = 'webgl' | 'windows' | 'macos' | 'linux'; +export type ExternalSigningProvider = 'keychain' | 'hiveauth' | 'hivesigner'; + +export interface PlayerIdentity { + readonly id: string; + readonly hiveControlState: + | 'external_self_custodial' + | 'platform_custodial' + | 'authority_claimed_recovery_pending' + | 'self_custody_complete'; + readonly hiveUsername: string; +} + +export interface HiveLoginChallengeRecord { + readonly challenge: string; + readonly expiresAt: Date; + readonly hiveUsername: string; + readonly id: string; + readonly platform: ClientPlatform; +} + +export interface ExternalIdentityRecord { + readonly disclosureAcknowledged: boolean; + readonly id: string; + readonly player: PlayerIdentity | null; + readonly status: 'provisioning' | 'linked' | 'disabled'; +} + +export interface ActiveSessionRecord extends BridgePrincipal { + readonly disclosureAcknowledged: boolean; + readonly expiresAt: Date; + readonly player: PlayerIdentity; +} + +export interface NewSession { + readonly authenticationMethod: 'direct_hive_challenge' | 'google_oidc'; + readonly custodialSigningEligible: boolean; + readonly expiresAt: Date; + readonly externalIdentityId: string | null; + readonly hiveSigningProvider: ExternalSigningProvider | 'custodial_service' | null; + readonly id: string; + readonly issuedAt: Date; + readonly platform: ClientPlatform; + readonly player: PlayerIdentity; + readonly refreshTokenHash: string; +} + +export interface AuthRepository { + createHiveChallenge( + record: HiveLoginChallengeRecord & { + readonly challengeSha256: string; + readonly deviceSessionId: string; + readonly issuedAt: Date; + }, + ): Promise; + consumeHiveChallenge( + challengeId: string, + hiveUsername: string, + consumedAt: Date, + ): Promise; + findOrCreateDirectHivePlayer(hiveUsername: string): Promise; + createSession(session: NewSession): Promise; + rotateSession( + currentRefreshTokenHash: string, + nextRefreshTokenHash: string, + usedAt: Date, + ): Promise; + findActiveSession(sessionId: string, at: Date): Promise; + revokeSession(sessionId: string, revokedAt: Date, reason: string): Promise; + upsertGoogleIdentity( + verifiedIssuer: string, + subjectLookupHash: string, + authenticatedAt: Date, + ): Promise; + getCurrentDisclosure(at: Date): Promise; + acknowledgeCurrentDisclosure(input: { + readonly acknowledgedAt: Date; + readonly contentSha256: string; + readonly disclosureVersion: string; + readonly externalIdentityId: string | null; + readonly playerId: string | null; + }): Promise; +} + +export interface PublicRecordDisclosure { + readonly contentSha256: string; + readonly effectiveAt: Date; + readonly version: string; +} + +export interface DisclosureAcknowledgment { + readonly acknowledgedAt: Date; + readonly disclosureVersion: string; + readonly id: string; +} + +export interface HivePostingAuthorityVerifier { + verify(input: { + readonly challenge: string; + readonly hiveUsername: string; + readonly signature: string; + }): Promise; + usernameExists(hiveUsername: string): Promise; +} + +export interface GoogleIdentity { + readonly issuer: string; + readonly subject: string; +} + +export interface GoogleOidcGateway { + exchange(input: { + readonly authorizationCode: string; + readonly codeVerifier: string; + readonly redirectUri: string; + }): Promise; +} + +export interface AuthHttpRequest { + authPrincipal?: ActiveSessionRecord; + headers: Record; +} + +export interface OnboardingPrincipal { + readonly externalIdentityId: string; +} + +export interface OnboardingHttpRequest extends AuthHttpRequest { + onboardingPrincipal?: OnboardingPrincipal; +} + +export type DisclosureHttpRequest = OnboardingHttpRequest; + +export const AUTH_CONFIG = Symbol('AUTH_CONFIG'); +export const AUTH_REPOSITORY = Symbol('AUTH_REPOSITORY'); +export const HIVE_POSTING_AUTHORITY_VERIFIER = Symbol('HIVE_POSTING_AUTHORITY_VERIFIER'); +export const GOOGLE_OIDC_GATEWAY = Symbol('GOOGLE_OIDC_GATEWAY'); diff --git a/apps/api/src/auth/custodial-player-signer.spec.ts b/apps/api/src/auth/custodial-player-signer.spec.ts new file mode 100644 index 0000000..5b0dfdf --- /dev/null +++ b/apps/api/src/auth/custodial-player-signer.spec.ts @@ -0,0 +1,62 @@ +import type { DigestSignatureProvider, HiveChainPort } from '@hive-chameleon/hive-gateway'; +import { describe, expect, it, vi } from 'vitest'; + +import { CustodialPlayerSigner } from './custodial-player-signer'; + +const signingFixture = { + digest: '23b62f08f440d24db93bbd61e1a902b9807ce6d4d3a63be80569575651699234', + publicKey: 'STM7u41yX66A2r6JNBrgawxT51sPxRMTJAW1QaEwdxQfFePGvCDET', + signature: + '204486a65bbc34a4d7cacf14ca4fb255ba31a0620ffa9cbd1d1546c8ea839b9abe0f4949c525dbd7105f61d7176348a8a7df68dff90b49b7e58a5660906620f856', +} as const; +const grant = { + authority: 'posting' as const, + expectedPublicKey: signingFixture.publicKey, + expiresAt: new Date('2026-07-23T00:05:00.000Z'), + hiveUsername: 'alice', + idempotencyKey: 'player-intent:1', + intentId: '01980abc-def2-7abc-8def-0123456789ab', + playerId: '01980abc-def0-7abc-8def-0123456789ab', + signerKeyReference: 'provider/player/posting/opaque-reference', +}; + +describe('CustodialPlayerSigner', () => { + it('passes only an opaque key reference and digest, then verifies the returned key', async () => { + const signDigest = vi.fn().mockResolvedValue({ signature: signingFixture.signature }); + const provider: DigestSignatureProvider = { signDigest }; + const signer = new CustodialPlayerSigner(chain(signingFixture.publicKey), provider); + + await expect( + signer.signDigest(grant, signingFixture.digest, new Date('2026-07-23T00:00:00Z')), + ).resolves.toEqual({ + publicKey: signingFixture.publicKey, + signature: signingFixture.signature, + }); + expect(signDigest).toHaveBeenCalledWith({ + expectedPublicKey: signingFixture.publicKey, + idempotencyKey: grant.idempotencyKey, + signatureDigest: signingFixture.digest, + signerKeyReference: grant.signerKeyReference, + }); + }); + + it('fails before provider invocation when the authorization grant expired', async () => { + const signDigest = vi.fn().mockResolvedValue({ signature: signingFixture.signature }); + const signer = new CustodialPlayerSigner(chain(signingFixture.publicKey), { signDigest }); + + await expect( + signer.signDigest(grant, signingFixture.digest, new Date('2026-07-23T00:06:00Z')), + ).rejects.toThrow(/expired/); + expect(signDigest).not.toHaveBeenCalled(); + }); +}); + +function chain(recoveredPublicKey: string): HiveChainPort { + return { + attachSignature: vi.fn(), + broadcast: vi.fn(), + inspectTransaction: vi.fn(), + prepareCustomJson: vi.fn(), + recoverPublicKey: vi.fn().mockResolvedValue(recoveredPublicKey), + }; +} diff --git a/apps/api/src/auth/custodial-player-signer.ts b/apps/api/src/auth/custodial-player-signer.ts new file mode 100644 index 0000000..f2df2f2 --- /dev/null +++ b/apps/api/src/auth/custodial-player-signer.ts @@ -0,0 +1,60 @@ +import { + assertSignatureMatchesPublicKey, + type DigestSignatureProvider, + type HiveChainPort, +} from '@hive-chameleon/hive-gateway'; + +export type PlayerCustodyAuthority = 'owner' | 'active' | 'posting'; + +export interface CustodialSigningGrant { + readonly authority: PlayerCustodyAuthority; + readonly expectedPublicKey: string; + readonly expiresAt: Date; + readonly hiveUsername: string; + readonly idempotencyKey: string; + readonly intentId: string; + readonly playerId: string; + readonly signerKeyReference: string; +} + +/** + * Provider-neutral adapter for an approved, non-exportable player key service. + * Product authorization constructs the grant; this boundary never accepts an + * arbitrary key reference or authority directly from a client. + */ +export class CustodialPlayerSigner { + public constructor( + private readonly chain: HiveChainPort, + private readonly provider: DigestSignatureProvider, + ) {} + + public async signDigest( + grant: CustodialSigningGrant, + signatureDigest: string, + now = new Date(), + ): Promise<{ readonly publicKey: string; readonly signature: string }> { + if (grant.expiresAt <= now) { + throw new Error('Custodial signing grant expired.'); + } + if (!/^[0-9a-f]{64}$/.test(signatureDigest)) { + throw new Error('Custodial signing accepts exactly one lowercase 32-byte digest.'); + } + if (!grant.signerKeyReference || grant.signerKeyReference.length > 1_024) { + throw new Error('Custody key reference is invalid.'); + } + + const response = await this.provider.signDigest({ + expectedPublicKey: grant.expectedPublicKey, + idempotencyKey: grant.idempotencyKey, + signatureDigest, + signerKeyReference: grant.signerKeyReference, + }); + await assertSignatureMatchesPublicKey( + this.chain, + signatureDigest, + response.signature, + grant.expectedPublicKey, + ); + return { publicKey: grant.expectedPublicKey, signature: response.signature }; + } +} diff --git a/apps/api/src/auth/disclosure-principal.guard.ts b/apps/api/src/auth/disclosure-principal.guard.ts new file mode 100644 index 0000000..0045a3e --- /dev/null +++ b/apps/api/src/auth/disclosure-principal.guard.ts @@ -0,0 +1,50 @@ +import { + type CanActivate, + type ExecutionContext, + Inject, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; + +import { bearerToken } from './access-session.guard'; +import { AUTH_REPOSITORY, type AuthRepository, type DisclosureHttpRequest } from './auth.types'; +import { TokenService } from './token.service'; + +@Injectable() +export class DisclosurePrincipalGuard implements CanActivate { + public constructor( + @Inject(TokenService) private readonly tokens: TokenService, + @Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository, + ) {} + + public async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = bearerToken(request.headers.authorization); + const now = new Date(); + try { + const claims = this.tokens.verifyAccessToken(token, now); + const session = await this.repository.findActiveSession(claims.sessionId, now); + if (!session || session.playerId !== claims.playerId) { + throw invalidPrincipal(); + } + request.authPrincipal = session; + return true; + } catch (error: unknown) { + if (!(error instanceof UnauthorizedException)) { + throw error; + } + } + + request.onboardingPrincipal = this.tokens.verifyOnboardingToken(token, now); + return true; + } +} + +function invalidPrincipal(): UnauthorizedException { + return new UnauthorizedException({ + code: 'invalid_session', + detail: 'The disclosure principal is invalid, expired, or revoked.', + status: 401, + title: 'Unauthorized', + }); +} diff --git a/apps/api/src/auth/disclosure.controller.ts b/apps/api/src/auth/disclosure.controller.ts new file mode 100644 index 0000000..7089125 --- /dev/null +++ b/apps/api/src/auth/disclosure.controller.ts @@ -0,0 +1,82 @@ +import { + Body, + Controller, + Get, + Header, + Headers, + HttpCode, + HttpStatus, + Inject, + Post, + Req, + UnauthorizedException, + UnprocessableEntityException, + UseGuards, +} from '@nestjs/common'; +import { z } from 'zod'; + +import type { DisclosureHttpRequest } from './auth.types'; +import { DisclosurePrincipalGuard } from './disclosure-principal.guard'; +import { DisclosureService } from './disclosure.service'; + +const acknowledgmentSchema = z.strictObject({ + contentSha256: z.string().regex(/^[0-9a-f]{64}$/), + disclosureVersion: z.string().min(1).max(32), +}); + +@Controller('disclosures/public-match') +@UseGuards(DisclosurePrincipalGuard) +export class DisclosureController { + public constructor(@Inject(DisclosureService) private readonly disclosures: DisclosureService) {} + + @Get('current') + @Header('Cache-Control', 'no-store') + public async getCurrent() { + return this.disclosures.getCurrent(); + } + + @Post('acknowledgments') + @HttpCode(HttpStatus.CREATED) + @Header('Cache-Control', 'no-store') + public async acknowledge( + @Req() request: DisclosureHttpRequest, + @Headers('idempotency-key') idempotencyKey: string | undefined, + @Body() body: unknown, + ) { + assertIdempotencyKey(idempotencyKey); + const input = acknowledgmentSchema.safeParse(body); + if (!input.success) { + throw validationFailed(); + } + const playerId = request.authPrincipal?.playerId ?? null; + const externalIdentityId = request.onboardingPrincipal?.externalIdentityId ?? null; + if ((playerId === null) === (externalIdentityId === null)) { + throw new UnauthorizedException(); + } + return this.disclosures.acknowledge({ + ...input.data, + externalIdentityId, + playerId, + }); + } +} + +function assertIdempotencyKey(value: string | undefined): void { + if (!value || value.length > 128 || [...value].some(isControlCharacter)) { + throw validationFailed(); + } +} + +function isControlCharacter(value: string): boolean { + const code = value.charCodeAt(0); + return code < 32 || code === 127; +} + +function validationFailed(): UnprocessableEntityException { + return new UnprocessableEntityException({ + code: 'validation_failed', + detail: 'The disclosure request does not match the API contract.', + status: 422, + title: 'Validation failed', + }); +} diff --git a/apps/api/src/auth/disclosure.service.spec.ts b/apps/api/src/auth/disclosure.service.spec.ts new file mode 100644 index 0000000..cd0ca83 --- /dev/null +++ b/apps/api/src/auth/disclosure.service.spec.ts @@ -0,0 +1,63 @@ +import { ConflictException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { AuthRepository } from './auth.types'; +import { DisclosureService } from './disclosure.service'; + +const contentSha256 = '64ae0b7b385c6f4c1287e25979c44fce05c51583f61f20c7bb7e31171d921cc3'; +const now = new Date('2026-07-23T01:00:00.000Z'); + +describe('DisclosureService', () => { + let repository: AuthRepository; + + beforeEach(() => { + repository = { + acknowledgeCurrentDisclosure: vi.fn(), + consumeHiveChallenge: vi.fn(), + createHiveChallenge: vi.fn(), + createSession: vi.fn(), + findActiveSession: vi.fn(), + findOrCreateDirectHivePlayer: vi.fn(), + getCurrentDisclosure: vi.fn().mockResolvedValue({ + contentSha256, + effectiveAt: new Date('2026-07-22T00:00:00.000Z'), + version: '2026-07-22', + }), + revokeSession: vi.fn(), + rotateSession: vi.fn(), + upsertGoogleIdentity: vi.fn(), + }; + }); + + it('returns the exact content bound to the database hash', async () => { + const result = await new DisclosureService(repository).getCurrent(now); + + expect(result).toMatchObject({ + contentSha256, + permanentFields: [ + 'hive_username', + 'match_participation', + 'role', + 'score', + 'outcome', + 'aggregated_likes', + ], + version: '2026-07-22', + }); + }); + + it('rejects stale content rather than acknowledging a changed disclosure', async () => { + await expect( + new DisclosureService(repository).acknowledge( + { + contentSha256: 'a'.repeat(64), + disclosureVersion: '2026-07-22', + externalIdentityId: null, + playerId: '01980abc-def0-7abc-8def-0123456789ab', + }, + now, + ), + ).rejects.toBeInstanceOf(ConflictException); + expect(repository.acknowledgeCurrentDisclosure).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/auth/disclosure.service.ts b/apps/api/src/auth/disclosure.service.ts new file mode 100644 index 0000000..29a9817 --- /dev/null +++ b/apps/api/src/auth/disclosure.service.ts @@ -0,0 +1,84 @@ +import { ConflictException, Inject, Injectable, ServiceUnavailableException } from '@nestjs/common'; + +import { AUTH_REPOSITORY, type AuthRepository } from './auth.types'; + +const disclosureTitle = 'Permanent Hive match-summary record'; +const disclosureBody = + 'Published Hive match summaries permanently expose your Hive username, match participation, role, score, outcome, and aggregated likes. Hive records are public and cannot be removed by an off-chain deletion request.'; +const permanentFields = [ + 'hive_username', + 'match_participation', + 'role', + 'score', + 'outcome', + 'aggregated_likes', +] as const; +const expectedContentSha256 = '64ae0b7b385c6f4c1287e25979c44fce05c51583f61f20c7bb7e31171d921cc3'; + +@Injectable() +export class DisclosureService { + public constructor(@Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository) {} + + public async getCurrent(now = new Date()) { + const disclosure = await this.repository.getCurrentDisclosure(now); + if (!disclosure || disclosure.contentSha256 !== expectedContentSha256) { + throw disclosureUnavailable(); + } + return { + body: disclosureBody, + contentSha256: disclosure.contentSha256, + effectiveAt: disclosure.effectiveAt.toISOString(), + permanentFields, + title: disclosureTitle, + version: disclosure.version, + }; + } + + public async acknowledge( + input: { + readonly contentSha256: string; + readonly disclosureVersion: string; + readonly externalIdentityId: string | null; + readonly playerId: string | null; + }, + now = new Date(), + ) { + if ((input.externalIdentityId === null) === (input.playerId === null)) { + throw new Error('Exactly one disclosure subject is required.'); + } + const current = await this.getCurrent(now); + if ( + input.disclosureVersion !== current.version || + input.contentSha256 !== current.contentSha256 + ) { + throw new ConflictException({ + code: 'disclosure_version_changed', + detail: + 'The disclosure changed before acknowledgment. Fetch and review the current version.', + status: 409, + title: 'Disclosure changed', + }); + } + const acknowledgment = await this.repository.acknowledgeCurrentDisclosure({ + ...input, + acknowledgedAt: now, + }); + if (!acknowledgment) { + throw disclosureUnavailable(); + } + return { + acknowledgedAt: acknowledgment.acknowledgedAt.toISOString(), + disclosureVersion: acknowledgment.disclosureVersion, + id: acknowledgment.id, + }; + } +} + +function disclosureUnavailable(): ServiceUnavailableException { + return new ServiceUnavailableException({ + code: 'public_record_disclosure_unavailable', + detail: 'The current permanent-public-record disclosure is not configured consistently.', + status: 503, + title: 'Disclosure unavailable', + }); +} diff --git a/apps/api/src/auth/google-oidc.gateway.ts b/apps/api/src/auth/google-oidc.gateway.ts new file mode 100644 index 0000000..d16181a --- /dev/null +++ b/apps/api/src/auth/google-oidc.gateway.ts @@ -0,0 +1,75 @@ +import { OAuth2Client } from 'google-auth-library'; +import { Injectable, ServiceUnavailableException, UnauthorizedException } from '@nestjs/common'; + +import type { GoogleOidcConfig } from './auth.config'; +import type { GoogleIdentity, GoogleOidcGateway } from './auth.types'; + +@Injectable() +export class GoogleAuthLibraryOidcGateway implements GoogleOidcGateway { + public constructor(private readonly config: GoogleOidcConfig | null) {} + + public async exchange(input: { + readonly authorizationCode: string; + readonly codeVerifier: string; + readonly redirectUri: string; + }): Promise { + if (!this.config) { + throw googleUnavailable('Google sign-in is not configured.'); + } + const canonicalRedirect = new URL(input.redirectUri).toString(); + if (!this.config.redirectUris.has(canonicalRedirect)) { + throw invalidGoogleIdentity(); + } + + const client = new OAuth2Client(this.config.clientId, this.config.clientSecret); + let idToken: string | undefined; + try { + const response = await client.getToken({ + code: input.authorizationCode, + codeVerifier: input.codeVerifier, + redirect_uri: canonicalRedirect, + }); + idToken = response.tokens.id_token ?? undefined; + } catch { + throw invalidGoogleIdentity(); + } + if (!idToken) { + throw invalidGoogleIdentity(); + } + + try { + const ticket = await client.verifyIdToken({ audience: this.config.clientId, idToken }); + const payload = ticket.getPayload(); + if ( + !payload?.sub || + (payload.iss !== 'accounts.google.com' && payload.iss !== 'https://accounts.google.com') + ) { + throw invalidGoogleIdentity(); + } + return { issuer: payload.iss, subject: payload.sub }; + } catch (error: unknown) { + if (error instanceof UnauthorizedException) { + throw error; + } + throw invalidGoogleIdentity(); + } + } +} + +function invalidGoogleIdentity(): UnauthorizedException { + return new UnauthorizedException({ + code: 'invalid_google_identity', + detail: 'Google authorization could not be verified for this client and redirect URI.', + status: 401, + title: 'Unauthorized', + }); +} + +function googleUnavailable(detail: string): ServiceUnavailableException { + return new ServiceUnavailableException({ + code: 'google_oidc_unavailable', + detail, + status: 503, + title: 'Google sign-in unavailable', + }); +} diff --git a/apps/api/src/auth/hive-posting-authority.verifier.ts b/apps/api/src/auth/hive-posting-authority.verifier.ts new file mode 100644 index 0000000..d2256b6 --- /dev/null +++ b/apps/api/src/auth/hive-posting-authority.verifier.ts @@ -0,0 +1,127 @@ +import { createHash } from 'node:crypto'; + +import { validateHiveCompactSignature, WaxHiveChainAdapter } from '@hive-chameleon/hive-gateway'; +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { z } from 'zod'; + +import type { AuthConfig } from './auth.config'; +import type { HivePostingAuthorityVerifier } from './auth.types'; + +const authoritySchema = z.strictObject({ + account_auths: z.array(z.tuple([z.string(), z.number().int().positive()])), + key_auths: z.array(z.tuple([z.string(), z.number().int().positive()])), + weight_threshold: z.number().int().positive(), +}); +const hiveAccountSchema = z.object({ name: z.string(), posting: authoritySchema }); +const rpcResponseSchema = z.object({ result: z.array(hiveAccountSchema) }); + +@Injectable() +export class RpcHivePostingAuthorityVerifier implements HivePostingAuthorityVerifier { + private readonly chain: WaxHiveChainAdapter; + + public constructor(private readonly config: AuthConfig) { + this.chain = new WaxHiveChainAdapter({ + apiEndpoint: config.hiveRpcUrl, + chainId: config.hiveChainId, + }); + } + + public async verify(input: { + readonly challenge: string; + readonly hiveUsername: string; + readonly signature: string; + }): Promise { + try { + validateHiveCompactSignature(input.signature); + const digest = createHash('sha256').update(input.challenge, 'utf8').digest('hex'); + const recoveredKey = await this.chain.recoverPublicKey(digest, input.signature); + return this.keyCanSatisfyPostingAuthority(input.hiveUsername, recoveredKey, new Set(), 0); + } catch (error: unknown) { + if (error instanceof ServiceUnavailableException) { + throw error; + } + return false; + } + } + + public async usernameExists(hiveUsername: string): Promise { + return (await this.fetchAccount(hiveUsername)) !== null; + } + + private async keyCanSatisfyPostingAuthority( + accountName: string, + recoveredKey: string, + visited: ReadonlySet, + depth: number, + ): Promise { + if (visited.has(accountName) || depth > 2) { + return false; + } + const account = await this.fetchAccount(accountName); + if (!account) { + return false; + } + const directWeight = account.posting.key_auths + .filter(([key]) => key === recoveredKey) + .reduce((sum, [, weight]) => sum + weight, 0); + if (directWeight >= account.posting.weight_threshold) { + return true; + } + + const nextVisited = new Set(visited); + nextVisited.add(accountName); + for (const [nestedAccount, outerWeight] of account.posting.account_auths) { + if ( + outerWeight >= account.posting.weight_threshold && + (await this.keyCanSatisfyPostingAuthority( + nestedAccount, + recoveredKey, + nextVisited, + depth + 1, + )) + ) { + return true; + } + } + return false; + } + + private async fetchAccount( + accountName: string, + ): Promise | null> { + let response: Response; + try { + response = await fetch(this.config.hiveRpcUrl, { + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'condenser_api.get_accounts', + params: [[accountName]], + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + signal: AbortSignal.timeout(5_000), + }); + } catch { + throw hiveUnavailable(); + } + if (!response.ok) { + throw hiveUnavailable(); + } + const parsed = rpcResponseSchema.safeParse(await response.json().catch(() => null)); + if (!parsed.success) { + throw hiveUnavailable(); + } + return parsed.data.result[0] ?? null; + } +} + +function hiveUnavailable(): ServiceUnavailableException { + return new ServiceUnavailableException({ + code: 'hive_unavailable', + detail: + 'Hive account authority could not be verified. Retry without reusing the old challenge.', + status: 503, + title: 'Hive unavailable', + }); +} diff --git a/apps/api/src/auth/onboarding-session.guard.ts b/apps/api/src/auth/onboarding-session.guard.ts new file mode 100644 index 0000000..8687191 --- /dev/null +++ b/apps/api/src/auth/onboarding-session.guard.ts @@ -0,0 +1,19 @@ +import { type CanActivate, type ExecutionContext, Inject, Injectable } from '@nestjs/common'; + +import { bearerToken } from './access-session.guard'; +import type { OnboardingHttpRequest } from './auth.types'; +import { TokenService } from './token.service'; + +@Injectable() +export class OnboardingSessionGuard implements CanActivate { + public constructor(@Inject(TokenService) private readonly tokens: TokenService) {} + + public canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + request.onboardingPrincipal = this.tokens.verifyOnboardingToken( + bearerToken(request.headers.authorization), + new Date(), + ); + return true; + } +} diff --git a/apps/api/src/auth/postgres-auth.repository.ts b/apps/api/src/auth/postgres-auth.repository.ts new file mode 100644 index 0000000..ba7f5d4 --- /dev/null +++ b/apps/api/src/auth/postgres-auth.repository.ts @@ -0,0 +1,394 @@ +import { createUuidV7, type DatabasePool, withTransaction } from '@hive-chameleon/database'; +import { ServiceUnavailableException } from '@nestjs/common'; + +import type { + ActiveSessionRecord, + AuthRepository, + DisclosureAcknowledgment, + ExternalIdentityRecord, + HiveLoginChallengeRecord, + NewSession, + PlayerIdentity, + PublicRecordDisclosure, +} from './auth.types'; + +interface PlayerRow { + hive_control_state: PlayerIdentity['hiveControlState']; + hive_username: string; + id: string; +} + +interface ChallengeRow { + challenge_text: string; + expires_at: Date; + hive_username: string; + id: string; + platform: HiveLoginChallengeRecord['platform']; +} + +interface SessionRow extends PlayerRow { + disclosure_acknowledged: boolean; + expires_at: Date; + session_id: string; +} + +interface DisclosureRow { + content_sha256: string; + effective_at: Date; + disclosure_version: string; +} + +interface DisclosureAcknowledgmentRow { + acknowledged_at: Date; + disclosure_version: string; + id: string; +} + +interface ExternalIdentityRow { + disclosure_acknowledged: boolean; + external_identity_id: string; + hive_control_state: PlayerIdentity['hiveControlState'] | null; + hive_username: string | null; + id: string | null; + player_id: string | null; + status: ExternalIdentityRecord['status']; +} + +export class PostgresAuthRepository implements AuthRepository { + public constructor(private readonly pool: DatabasePool | null) {} + + public async createHiveChallenge( + record: HiveLoginChallengeRecord & { + readonly challengeSha256: string; + readonly deviceSessionId: string; + readonly issuedAt: Date; + }, + ): Promise { + await this.requirePool().query( + `INSERT INTO identity.hive_login_challenge ( + id, hive_username, platform, device_session_id, challenge_text, + challenge_sha256, issued_at, expires_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + record.id, + record.hiveUsername, + record.platform, + record.deviceSessionId, + record.challenge, + record.challengeSha256, + record.issuedAt, + record.expiresAt, + ], + ); + } + + public async consumeHiveChallenge( + challengeId: string, + hiveUsername: string, + consumedAt: Date, + ): Promise { + const result = await this.requirePool().query( + `UPDATE identity.hive_login_challenge + SET consumed_at = $3 + WHERE id = $1 + AND hive_username = $2 + AND consumed_at IS NULL + AND expires_at > $3 + RETURNING id, hive_username, platform, challenge_text, expires_at`, + [challengeId, hiveUsername, consumedAt], + ); + const row = result.rows[0]; + return row ? mapChallenge(row) : null; + } + + public async findOrCreateDirectHivePlayer(hiveUsername: string): Promise { + const result = await this.requirePool().query( + `INSERT INTO identity.player (id, hive_username, hive_control_state) + VALUES ($1, $2, 'external_self_custodial') + ON CONFLICT (hive_username) DO UPDATE + SET hive_username = EXCLUDED.hive_username + RETURNING id, hive_username, hive_control_state`, + [createUuidV7(), hiveUsername], + ); + return mapPlayer(requireRow(result.rows[0])); + } + + public async createSession(session: NewSession): Promise { + await withTransaction(this.requirePool(), async (client) => { + await client.query( + `UPDATE identity.auth_session + SET revoked_at = $2, + revocation_reason = 'superseded_login' + WHERE player_id = $1 AND revoked_at IS NULL`, + [session.player.id, session.issuedAt], + ); + await client.query( + `INSERT INTO identity.auth_session ( + id, player_id, external_identity_id, refresh_token_hash, platform, + authentication_method, hive_signing_provider, hive_control_state_at_issue, + custodial_signing_eligible, issued_at, expires_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + session.id, + session.player.id, + session.externalIdentityId, + session.refreshTokenHash, + session.platform, + session.authenticationMethod, + session.hiveSigningProvider, + session.player.hiveControlState, + session.custodialSigningEligible, + session.issuedAt, + session.expiresAt, + ], + ); + }); + } + + public async rotateSession( + currentRefreshTokenHash: string, + nextRefreshTokenHash: string, + usedAt: Date, + ): Promise { + return withTransaction(this.requirePool(), async (client) => { + const result = await client.query( + `SELECT s.id AS session_id, s.expires_at, p.id, p.hive_username, p.hive_control_state, + ${disclosureAcknowledgedSql('p.id', '$2')} AS disclosure_acknowledged + FROM identity.auth_session s + JOIN identity.player p ON p.id = s.player_id + WHERE s.refresh_token_hash = $1 + AND s.revoked_at IS NULL + AND s.expires_at > $2 + FOR UPDATE OF s`, + [currentRefreshTokenHash, usedAt], + ); + const row = result.rows[0]; + if (!row) { + return null; + } + await client.query( + `UPDATE identity.auth_session + SET refresh_token_hash = $2, last_used_at = $3 + WHERE id = $1`, + [row.session_id, nextRefreshTokenHash, usedAt], + ); + return mapSession(row); + }); + } + + public async findActiveSession(sessionId: string, at: Date): Promise { + const result = await this.requirePool().query( + `SELECT s.id AS session_id, s.expires_at, p.id, p.hive_username, p.hive_control_state, + ${disclosureAcknowledgedSql('p.id', '$2')} AS disclosure_acknowledged + FROM identity.auth_session s + JOIN identity.player p ON p.id = s.player_id + WHERE s.id = $1 AND s.revoked_at IS NULL AND s.expires_at > $2`, + [sessionId, at], + ); + const row = result.rows[0]; + return row ? mapSession(row) : null; + } + + public async revokeSession(sessionId: string, revokedAt: Date, reason: string): Promise { + const result = await this.requirePool().query( + `UPDATE identity.auth_session + SET revoked_at = $2, revocation_reason = $3 + WHERE id = $1 AND revoked_at IS NULL`, + [sessionId, revokedAt, reason], + ); + return result.rowCount === 1; + } + + public async upsertGoogleIdentity( + verifiedIssuer: string, + subjectLookupHash: string, + authenticatedAt: Date, + ): Promise { + const result = await this.requirePool().query( + `WITH upserted AS ( + INSERT INTO identity.external_identity ( + id, provider, verified_issuer, subject_lookup_hash, status, + verified_at, last_authenticated_at + ) VALUES ($1, 'google', $2, $3, 'provisioning', $4, $4) + ON CONFLICT (provider, verified_issuer, subject_lookup_hash) DO UPDATE + SET last_authenticated_at = EXCLUDED.last_authenticated_at, + updated_at = EXCLUDED.last_authenticated_at + RETURNING id, player_id, status + ) + SELECT e.id AS external_identity_id, e.player_id, e.status, + p.id, p.hive_username, p.hive_control_state, + EXISTS ( + SELECT 1 + FROM identity.public_record_disclosure d + JOIN identity.public_record_disclosure_acknowledgment a + ON a.disclosure_version = d.disclosure_version + WHERE a.external_identity_id = e.id + AND d.effective_at <= $4 + AND (d.retired_at IS NULL OR d.retired_at > $4) + ) AS disclosure_acknowledged + FROM upserted e + LEFT JOIN identity.player p ON p.id = e.player_id`, + [createUuidV7(), verifiedIssuer, subjectLookupHash, authenticatedAt], + ); + const row = requireRow(result.rows[0]); + return { + disclosureAcknowledged: row.disclosure_acknowledged, + id: row.external_identity_id, + player: + row.player_id === null + ? null + : mapPlayer({ + hive_control_state: requireValue(row.hive_control_state), + hive_username: requireValue(row.hive_username), + id: requireValue(row.id), + }), + status: row.status, + }; + } + + public async getCurrentDisclosure(at: Date): Promise { + const result = await this.requirePool().query( + `SELECT disclosure_version, content_sha256, effective_at + FROM identity.public_record_disclosure + WHERE effective_at <= $1 + AND (retired_at IS NULL OR retired_at > $1) + ORDER BY effective_at DESC + LIMIT 1`, + [at], + ); + const row = result.rows[0]; + return row + ? { + contentSha256: row.content_sha256, + effectiveAt: row.effective_at, + version: row.disclosure_version, + } + : null; + } + + public async acknowledgeCurrentDisclosure(input: { + readonly acknowledgedAt: Date; + readonly contentSha256: string; + readonly disclosureVersion: string; + readonly externalIdentityId: string | null; + readonly playerId: string | null; + }): Promise { + return withTransaction(this.requirePool(), async (client) => { + const disclosure = await client.query( + `SELECT 1 + FROM identity.public_record_disclosure + WHERE disclosure_version = $1 AND content_sha256 = $2 + AND effective_at <= $3 + AND (retired_at IS NULL OR retired_at > $3)`, + [input.disclosureVersion, input.contentSha256, input.acknowledgedAt], + ); + if (disclosure.rowCount !== 1) { + return null; + } + await client.query( + `INSERT INTO identity.public_record_disclosure_acknowledgment ( + id, disclosure_version, external_identity_id, player_id, source, acknowledged_at + ) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT DO NOTHING`, + [ + createUuidV7(), + input.disclosureVersion, + input.externalIdentityId, + input.playerId, + input.externalIdentityId === null + ? 'direct_hive_pre_participation' + : 'google_pre_provisioning', + input.acknowledgedAt, + ], + ); + const result = await client.query( + `SELECT id, disclosure_version, acknowledged_at + FROM identity.public_record_disclosure_acknowledgment + WHERE disclosure_version = $1 + AND (($2::uuid IS NOT NULL AND external_identity_id = $2) OR + ($3::uuid IS NOT NULL AND player_id = $3)) + ORDER BY acknowledged_at + LIMIT 1`, + [input.disclosureVersion, input.externalIdentityId, input.playerId], + ); + const row = result.rows[0]; + return row + ? { + acknowledgedAt: row.acknowledged_at, + disclosureVersion: row.disclosure_version, + id: row.id, + } + : null; + }); + } + + private requirePool(): DatabasePool { + if (!this.pool) { + throw new ServiceUnavailableException({ + code: 'identity_store_unavailable', + detail: 'Authentication is unavailable because the identity store is not configured.', + status: 503, + title: 'Authentication unavailable', + }); + } + return this.pool; + } +} + +function mapChallenge(row: ChallengeRow): HiveLoginChallengeRecord { + return { + challenge: row.challenge_text, + expiresAt: row.expires_at, + hiveUsername: row.hive_username, + id: row.id, + platform: row.platform, + }; +} + +function mapPlayer(row: PlayerRow): PlayerIdentity { + return { + hiveControlState: row.hive_control_state, + hiveUsername: row.hive_username, + id: row.id, + }; +} + +function mapSession(row: SessionRow): ActiveSessionRecord { + const player = mapPlayer(row); + return { + authSessionId: row.session_id, + disclosureAcknowledged: row.disclosure_acknowledged, + expiresAt: row.expires_at, + player, + playerId: player.id, + }; +} + +function disclosureAcknowledgedSql(playerIdSql: string, atParameter: string): string { + return `EXISTS ( + SELECT 1 + FROM identity.public_record_disclosure d + WHERE d.effective_at <= ${atParameter} + AND (d.retired_at IS NULL OR d.retired_at > ${atParameter}) + AND EXISTS ( + SELECT 1 + FROM identity.public_record_disclosure_acknowledgment a + WHERE a.player_id = ${playerIdSql} + AND a.disclosure_version = d.disclosure_version + ) + )`; +} + +function requireRow(row: T | undefined): T { + if (!row) { + throw new Error('The identity store did not return the expected row.'); + } + return row; +} + +function requireValue(value: T | null): T { + if (value === null) { + throw new Error('Linked external identity is missing its player row.'); + } + return value; +} diff --git a/apps/api/src/auth/token.service.spec.ts b/apps/api/src/auth/token.service.spec.ts new file mode 100644 index 0000000..103560c --- /dev/null +++ b/apps/api/src/auth/token.service.spec.ts @@ -0,0 +1,53 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; + +import type { AuthConfig } from './auth.config'; +import { TokenService } from './token.service'; + +const now = new Date('2026-07-23T00:00:00.000Z'); +const playerId = '01980abc-def0-7abc-8def-0123456789ab'; +const sessionId = '01980abc-def1-7abc-9def-0123456789ab'; + +describe('TokenService', () => { + it('issues scoped access tokens and rejects tampering', () => { + const service = new TokenService(config()); + const issued = service.issueAccessToken(playerId, sessionId, now); + + expect(service.verifyAccessToken(issued.token, now)).toEqual({ playerId, sessionId }); + expect(() => service.verifyAccessToken(`${issued.token}x`, now)).toThrow(UnauthorizedException); + }); + + it('does not accept onboarding credentials as access credentials', () => { + const service = new TokenService(config()); + const issued = service.issueOnboardingToken(playerId, now); + + expect(service.verifyOnboardingToken(issued.token, now)).toEqual({ + externalIdentityId: playerId, + }); + expect(() => service.verifyAccessToken(issued.token, now)).toThrow(UnauthorizedException); + }); + + it('rejects expired tokens', () => { + const service = new TokenService(config()); + const issued = service.issueAccessToken(playerId, sessionId, now); + + expect(() => + service.verifyAccessToken(issued.token, new Date('2026-07-23T00:15:01.000Z')), + ).toThrow(UnauthorizedException); + }); +}); + +function config(): AuthConfig { + return { + accessTokenTtlSeconds: 900, + audience: 'test-client', + challengeTtlSeconds: 300, + google: null, + hiveChainId: '0'.repeat(64), + hiveRpcUrl: 'https://api.hive.blog/', + identityLookupKey: Buffer.alloc(32, 2), + issuer: 'test-api', + refreshTokenTtlSeconds: 3_600, + tokenKey: Buffer.alloc(32, 1), + }; +} diff --git a/apps/api/src/auth/token.service.ts b/apps/api/src/auth/token.service.ts new file mode 100644 index 0000000..532963d --- /dev/null +++ b/apps/api/src/auth/token.service.ts @@ -0,0 +1,145 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { z } from 'zod'; + +import { AUTH_CONFIG, type OnboardingPrincipal } from './auth.types'; +import type { AuthConfig } from './auth.config'; + +const tokenHeader = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); +const tokenClaimsSchema = z.strictObject({ + aud: z.string(), + exp: z.number().int(), + iat: z.number().int(), + iss: z.string(), + sid: z.string().uuid().optional(), + sub: z.string().uuid(), + typ: z.enum(['access', 'onboarding']), +}); + +export interface AccessTokenClaims { + readonly playerId: string; + readonly sessionId: string; +} + +@Injectable() +export class TokenService { + public constructor(@Inject(AUTH_CONFIG) private readonly config: AuthConfig) {} + + public issueAccessToken( + playerId: string, + sessionId: string, + now: Date, + ): { + readonly expiresAt: Date; + readonly token: string; + } { + const expiresAt = new Date(now.getTime() + this.config.accessTokenTtlSeconds * 1_000); + return { + expiresAt, + token: this.sign({ + sid: sessionId, + sub: playerId, + typ: 'access', + now, + expiresAt, + }), + }; + } + + public issueOnboardingToken( + externalIdentityId: string, + now: Date, + ): { + readonly expiresAt: Date; + readonly token: string; + } { + const expiresAt = new Date(now.getTime() + this.config.accessTokenTtlSeconds * 1_000); + return { + expiresAt, + token: this.sign({ sub: externalIdentityId, typ: 'onboarding', now, expiresAt }), + }; + } + + public verifyAccessToken(token: string, now: Date): AccessTokenClaims { + const claims = this.verify(token, now); + if (claims.typ !== 'access' || claims.sid === undefined) { + throw invalidToken(); + } + return { playerId: claims.sub, sessionId: claims.sid }; + } + + public verifyOnboardingToken(token: string, now: Date): OnboardingPrincipal { + const claims = this.verify(token, now); + if (claims.typ !== 'onboarding' || claims.sid !== undefined) { + throw invalidToken(); + } + return { externalIdentityId: claims.sub }; + } + + private sign(input: { + readonly expiresAt: Date; + readonly now: Date; + readonly sid?: string; + readonly sub: string; + readonly typ: 'access' | 'onboarding'; + }): string { + const payload = Buffer.from( + JSON.stringify({ + aud: this.config.audience, + exp: Math.floor(input.expiresAt.getTime() / 1_000), + iat: Math.floor(input.now.getTime() / 1_000), + iss: this.config.issuer, + ...(input.sid === undefined ? {} : { sid: input.sid }), + sub: input.sub, + typ: input.typ, + }), + ).toString('base64url'); + const signingInput = `${tokenHeader}.${payload}`; + const signature = createHmac('sha256', this.config.tokenKey) + .update(signingInput) + .digest('base64url'); + return `${signingInput}.${signature}`; + } + + private verify(token: string, now: Date): z.infer { + const parts = token.split('.'); + if (parts.length !== 3 || parts[0] !== tokenHeader || !parts[1] || !parts[2]) { + throw invalidToken(); + } + const signingInput = `${parts[0]}.${parts[1]}`; + const supplied = Buffer.from(parts[2], 'base64url'); + const expected = createHmac('sha256', this.config.tokenKey).update(signingInput).digest(); + if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) { + throw invalidToken(); + } + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as unknown; + } catch { + throw invalidToken(); + } + const claims = tokenClaimsSchema.safeParse(parsed); + const nowSeconds = Math.floor(now.getTime() / 1_000); + if ( + !claims.success || + claims.data.iss !== this.config.issuer || + claims.data.aud !== this.config.audience || + claims.data.iat > nowSeconds + 30 || + claims.data.exp <= nowSeconds + ) { + throw invalidToken(); + } + return claims.data; + } +} + +function invalidToken(): UnauthorizedException { + return new UnauthorizedException({ + code: 'invalid_session', + detail: 'The session credential is missing, invalid, expired, or revoked.', + status: 401, + title: 'Unauthorized', + }); +} diff --git a/apps/api/src/database/database.connection.ts b/apps/api/src/database/database.connection.ts new file mode 100644 index 0000000..8ecc0a2 --- /dev/null +++ b/apps/api/src/database/database.connection.ts @@ -0,0 +1,23 @@ +import { createDatabasePool, type DatabasePool } from '@hive-chameleon/database'; +import { Injectable, type OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class DatabaseConnection implements OnApplicationShutdown { + public readonly pool: DatabasePool | null; + + public constructor() { + const connectionString = process.env.DATABASE_URL?.trim(); + if (!connectionString) { + if (process.env.NODE_ENV === 'production') { + throw new Error('DATABASE_URL is required in production.'); + } + this.pool = null; + return; + } + this.pool = createDatabasePool({ connectionString }); + } + + public async onApplicationShutdown(): Promise { + await this.pool?.end(); + } +} diff --git a/apps/api/src/database/database.module.ts b/apps/api/src/database/database.module.ts new file mode 100644 index 0000000..96df540 --- /dev/null +++ b/apps/api/src/database/database.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; + +import { DatabaseConnection } from './database.connection'; + +@Global() +@Module({ + exports: [DatabaseConnection], + providers: [DatabaseConnection], +}) +export class DatabaseModule {} diff --git a/apps/api/src/provisioning/onboarding.config.ts b/apps/api/src/provisioning/onboarding.config.ts new file mode 100644 index 0000000..2ce8b66 --- /dev/null +++ b/apps/api/src/provisioning/onboarding.config.ts @@ -0,0 +1,27 @@ +export interface OnboardingConfig { + readonly sponsorHiveAccount: string; + readonly sponsorPolicyVersion: string; + readonly sponsorProgram: string; +} + +export function loadOnboardingConfig( + environment: NodeJS.ProcessEnv = process.env, +): OnboardingConfig | null { + const values = { + sponsorHiveAccount: environment.PROVISIONING_SPONSOR_HIVE_ACCOUNT?.trim(), + sponsorPolicyVersion: environment.PROVISIONING_SPONSOR_POLICY_VERSION?.trim(), + sponsorProgram: environment.PROVISIONING_SPONSOR_PROGRAM?.trim(), + }; + if (Object.values(values).every((value) => !value)) { + return null; + } + if (Object.values(values).some((value) => !value)) { + throw new Error( + 'PROVISIONING_SPONSOR_HIVE_ACCOUNT, PROVISIONING_SPONSOR_POLICY_VERSION, and PROVISIONING_SPONSOR_PROGRAM must be configured together.', + ); + } + if (!/^[a-z][a-z0-9.-]{1,14}[a-z0-9]$/.test(values.sponsorHiveAccount!)) { + throw new Error('PROVISIONING_SPONSOR_HIVE_ACCOUNT must be a normalized Hive account.'); + } + return values as OnboardingConfig; +} diff --git a/apps/api/src/provisioning/onboarding.controller.ts b/apps/api/src/provisioning/onboarding.controller.ts new file mode 100644 index 0000000..b3eb83a --- /dev/null +++ b/apps/api/src/provisioning/onboarding.controller.ts @@ -0,0 +1,121 @@ +import { + Body, + Controller, + Get, + Header, + Headers, + HttpCode, + HttpStatus, + Inject, + Param, + Post, + Put, + Req, + UnauthorizedException, + UnprocessableEntityException, + UseGuards, +} from '@nestjs/common'; +import { hiveAccountSchema } from '@hive-chameleon/hive-gateway'; +import { z } from 'zod'; + +import { OnboardingSessionGuard } from '../auth/onboarding-session.guard'; +import type { OnboardingHttpRequest } from '../auth/auth.types'; +import type { ProvisioningStatus } from './onboarding.repository'; +import { OnboardingService } from './onboarding.service'; + +const usernameSchema = z.strictObject({ hiveUsername: hiveAccountSchema }); +const uuidSchema = z.string().uuid(); + +@Controller('onboarding') +@UseGuards(OnboardingSessionGuard) +export class OnboardingController { + public constructor(@Inject(OnboardingService) private readonly onboarding: OnboardingService) {} + + @Post('hive-username/check') + @Header('Cache-Control', 'no-store') + public async checkUsername(@Body() body: unknown) { + return this.onboarding.checkUsername(parse(usernameSchema, body).hiveUsername); + } + + @Put('hive-username') + @Header('Cache-Control', 'no-store') + public async confirmUsername( + @Req() request: OnboardingHttpRequest, + @Headers('idempotency-key') idempotencyKey: string | undefined, + @Body() body: unknown, + ) { + return publicStatus( + await this.onboarding.confirmUsername( + principalId(request), + parse(usernameSchema, body).hiveUsername, + parseIdempotencyKey(idempotencyKey), + ), + ); + } + + @Post('provisioning') + @HttpCode(HttpStatus.ACCEPTED) + @Header('Cache-Control', 'no-store') + @Header('Retry-After', '5') + public async start(@Req() request: OnboardingHttpRequest) { + return publicStatus(await this.onboarding.start(principalId(request))); + } + + @Get('provisioning/:provisioningId') + @Header('Cache-Control', 'no-store') + public async getStatus( + @Req() request: OnboardingHttpRequest, + @Param('provisioningId') provisioningId: string, + ) { + return publicStatus( + await this.onboarding.getStatus(principalId(request), parse(uuidSchema, provisioningId)), + ); + } +} + +function publicStatus(status: ProvisioningStatus) { + return { + id: status.id, + requestedHiveUsername: status.requestedHiveUsername, + ...(status.retryAfter === null ? {} : { retryAfter: status.retryAfter }), + ...(status.safeFailureCode === null ? {} : { safeFailureCode: status.safeFailureCode }), + state: status.state, + updatedAt: status.updatedAt, + }; +} + +function principalId(request: OnboardingHttpRequest): string { + if (!request.onboardingPrincipal) { + throw new UnauthorizedException(); + } + return request.onboardingPrincipal.externalIdentityId; +} + +function parseIdempotencyKey(value: string | undefined): string { + if (!value || value.length > 128 || [...value].some(isControlCharacter)) { + throw validationFailed(); + } + return value; +} + +function isControlCharacter(value: string): boolean { + const code = value.charCodeAt(0); + return code < 32 || code === 127; +} + +function parse(schema: z.ZodType, input: unknown): T { + const result = schema.safeParse(input); + if (!result.success) { + throw validationFailed(); + } + return result.data; +} + +function validationFailed(): UnprocessableEntityException { + return new UnprocessableEntityException({ + code: 'validation_failed', + detail: 'The onboarding request does not match the API contract.', + status: 422, + title: 'Validation failed', + }); +} diff --git a/apps/api/src/provisioning/onboarding.repository.ts b/apps/api/src/provisioning/onboarding.repository.ts new file mode 100644 index 0000000..9471fba --- /dev/null +++ b/apps/api/src/provisioning/onboarding.repository.ts @@ -0,0 +1,152 @@ +import { createUuidV7, type DatabasePool, withTransaction } from '@hive-chameleon/database'; +import { ServiceUnavailableException } from '@nestjs/common'; + +import type { OnboardingConfig } from './onboarding.config'; +import type { ProvisioningJob, ProvisioningState } from './provisioning.types'; + +interface ProvisioningRow { + external_identity_id: string; + id: string; + idempotency_key: string; + last_failure_code: string | null; + next_retry_at: Date | null; + requested_hive_username: string; + sponsor_hive_account: string; + sponsor_policy_version: string; + sponsor_program: string; + state: ProvisioningState; + updated_at: Date; +} + +export interface ProvisioningStatus extends ProvisioningJob { + readonly retryAfter: string | null; + readonly safeFailureCode: string | null; + readonly updatedAt: string; +} + +export class PostgresOnboardingRepository { + public constructor(private readonly pool: DatabasePool | null) {} + + public async isUsernamePending(hiveUsername: string): Promise { + const result = await this.requirePool().query( + `SELECT 1 + FROM identity.hive_account_provisioning + WHERE requested_hive_username = $1 + AND (state <> 'terminal_failed' OR account_observed_at IS NOT NULL) + LIMIT 1`, + [hiveUsername], + ); + return result.rowCount === 1; + } + + public async confirmUsername(input: { + readonly config: OnboardingConfig; + readonly externalIdentityId: string; + readonly hiveUsername: string; + readonly idempotencyKey: string; + readonly now: Date; + }): Promise { + return withTransaction(this.requirePool(), async (client) => { + const identity = await client.query<{ status: string }>( + `SELECT status + FROM identity.external_identity + WHERE id = $1 + FOR UPDATE`, + [input.externalIdentityId], + ); + if (identity.rows[0]?.status !== 'provisioning') { + throw new Error('Onboarding identity is not available for provisioning.'); + } + const existing = await client.query( + `SELECT * FROM identity.hive_account_provisioning WHERE external_identity_id = $1`, + [input.externalIdentityId], + ); + const current = existing.rows[0]; + if (current) { + if (current.requested_hive_username !== input.hiveUsername) { + throw new Error('A different permanent Hive username is already confirmed.'); + } + return mapStatus(current); + } + const inserted = await client.query( + `INSERT INTO identity.hive_account_provisioning ( + id, external_identity_id, idempotency_key, requested_hive_username, + sponsor_program, sponsor_policy_version, sponsor_hive_account, + username_confirmed_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + createUuidV7(), + input.externalIdentityId, + input.idempotencyKey, + input.hiveUsername, + input.config.sponsorProgram, + input.config.sponsorPolicyVersion, + input.config.sponsorHiveAccount, + input.now, + ], + ); + return mapStatus(requireRow(inserted.rows[0])); + }); + } + + public async findForIdentity(externalIdentityId: string): Promise { + const result = await this.requirePool().query( + `SELECT * + FROM identity.hive_account_provisioning + WHERE external_identity_id = $1`, + [externalIdentityId], + ); + const row = result.rows[0]; + return row ? mapStatus(row) : null; + } + + public async findByIdForIdentity( + provisioningId: string, + externalIdentityId: string, + ): Promise { + const result = await this.requirePool().query( + `SELECT * + FROM identity.hive_account_provisioning + WHERE id = $1 AND external_identity_id = $2`, + [provisioningId, externalIdentityId], + ); + const row = result.rows[0]; + return row ? mapStatus(row) : null; + } + + private requirePool(): DatabasePool { + if (!this.pool) { + throw new ServiceUnavailableException({ + code: 'identity_store_unavailable', + detail: 'Onboarding is unavailable because the identity store is not configured.', + status: 503, + title: 'Onboarding unavailable', + }); + } + return this.pool; + } +} + +function mapStatus(row: ProvisioningRow): ProvisioningStatus { + return { + externalIdentityId: row.external_identity_id, + id: row.id, + idempotencyKey: row.idempotency_key, + requestedHiveUsername: row.requested_hive_username, + retryAfter: row.next_retry_at?.toISOString() ?? null, + safeFailureCode: row.last_failure_code, + sponsorHiveAccount: row.sponsor_hive_account, + sponsorPolicyVersion: row.sponsor_policy_version, + sponsorProgram: row.sponsor_program, + state: row.state, + updatedAt: row.updated_at.toISOString(), + }; +} + +function requireRow(row: T | undefined): T { + if (!row) { + throw new Error('The identity store did not return the expected row.'); + } + return row; +} diff --git a/apps/api/src/provisioning/onboarding.service.ts b/apps/api/src/provisioning/onboarding.service.ts new file mode 100644 index 0000000..75baaa2 --- /dev/null +++ b/apps/api/src/provisioning/onboarding.service.ts @@ -0,0 +1,136 @@ +import { createHash } from 'node:crypto'; + +import { + ConflictException, + Inject, + Injectable, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; + +import { + HIVE_POSTING_AUTHORITY_VERIFIER, + type HivePostingAuthorityVerifier, +} from '../auth/auth.types'; +import type { OnboardingConfig } from './onboarding.config'; +import type { PostgresOnboardingRepository } from './onboarding.repository'; +import { type ProvisioningStatus } from './onboarding.repository'; + +@Injectable() +export class OnboardingService { + public constructor( + private readonly repository: PostgresOnboardingRepository, + @Inject(HIVE_POSTING_AUTHORITY_VERIFIER) + private readonly hive: HivePostingAuthorityVerifier, + private readonly config: OnboardingConfig | null, + ) {} + + public async checkUsername(hiveUsername: string): Promise<{ + readonly available: boolean; + readonly checkedAt: string; + readonly hiveUsername: string; + readonly reservationCreated: false; + readonly unavailableReason?: 'exists_on_hive' | 'pending_platform_job'; + }> { + const checkedAt = new Date(); + const [existsOnHive, pending] = await Promise.all([ + this.hive.usernameExists(hiveUsername), + this.repository.isUsernamePending(hiveUsername), + ]); + return { + available: !existsOnHive && !pending, + checkedAt: checkedAt.toISOString(), + hiveUsername, + reservationCreated: false, + ...(existsOnHive + ? { unavailableReason: 'exists_on_hive' as const } + : pending + ? { unavailableReason: 'pending_platform_job' as const } + : {}), + }; + } + + public async confirmUsername( + externalIdentityId: string, + hiveUsername: string, + idempotencyKey: string, + ): Promise { + const config = this.requireConfiguration(); + const availability = await this.checkUsername(hiveUsername); + const existing = await this.repository.findForIdentity(externalIdentityId); + if (!availability.available && existing?.requestedHiveUsername !== hiveUsername) { + throw new ConflictException({ + code: availability.unavailableReason ?? 'username_unavailable', + detail: 'The Hive username is no longer available.', + status: 409, + title: 'Username unavailable', + }); + } + const scopedIdempotencyKey = createHash('sha256') + .update(externalIdentityId) + .update('\0') + .update(idempotencyKey) + .digest('hex'); + try { + return await this.repository.confirmUsername({ + config, + externalIdentityId, + hiveUsername, + idempotencyKey: scopedIdempotencyKey, + now: new Date(), + }); + } catch (error: unknown) { + if (error instanceof ConflictException || error instanceof ServiceUnavailableException) { + throw error; + } + throw new ConflictException({ + code: 'onboarding_conflict', + detail: 'This onboarding identity already has a different permanent selection.', + status: 409, + title: 'Onboarding conflict', + }); + } + } + + public async start(externalIdentityId: string): Promise { + this.requireConfiguration(); + const status = await this.repository.findForIdentity(externalIdentityId); + if (!status) { + throw provisioningNotFound(); + } + return status; + } + + public async getStatus( + externalIdentityId: string, + provisioningId: string, + ): Promise { + const status = await this.repository.findByIdForIdentity(provisioningId, externalIdentityId); + if (!status) { + throw provisioningNotFound(); + } + return status; + } + + private requireConfiguration(): OnboardingConfig { + if (!this.config) { + throw new ServiceUnavailableException({ + code: 'provisioning_provider_unavailable', + detail: + 'Google-to-Hive provisioning is paused until approved sponsor and custody adapters are configured.', + status: 503, + title: 'Provisioning unavailable', + }); + } + return this.config; + } +} + +function provisioningNotFound(): NotFoundException { + return new NotFoundException({ + code: 'provisioning_not_found', + detail: 'No provisioning operation is visible for this onboarding identity.', + status: 404, + title: 'Not found', + }); +} diff --git a/apps/api/src/provisioning/postgres-provisioning.repository.ts b/apps/api/src/provisioning/postgres-provisioning.repository.ts new file mode 100644 index 0000000..7b6b08c --- /dev/null +++ b/apps/api/src/provisioning/postgres-provisioning.repository.ts @@ -0,0 +1,249 @@ +import { createUuidV7, type DatabasePool, withTransaction } from '@hive-chameleon/database'; + +import type { + CustodyKeyReference, + ProvisioningJob, + ProvisioningRepository, + ProvisioningState, +} from './provisioning.types'; + +interface JobRow { + external_identity_id: string; + id: string; + idempotency_key: string; + requested_hive_username: string; + sponsor_hive_account: string; + sponsor_policy_version: string; + sponsor_program: string; + state: ProvisioningState; +} + +interface KeyRow { + authority_role: CustodyKeyReference['authorityRole']; + custody_provider_key_reference: string; + hive_public_key: string; +} + +export class PostgresProvisioningRepository implements ProvisioningRepository { + public constructor(private readonly pool: DatabasePool) {} + + public async getJobForUpdate(provisioningId: string): Promise { + const result = await this.pool.query( + `SELECT id, external_identity_id, idempotency_key, requested_hive_username, + sponsor_hive_account, sponsor_policy_version, sponsor_program, state + FROM identity.hive_account_provisioning + WHERE id = $1`, + [provisioningId], + ); + const row = result.rows[0]; + return row ? mapJob(row) : null; + } + + public async getKeys(provisioningId: string): Promise { + const result = await this.pool.query( + `SELECT authority_role, hive_public_key, custody_provider_key_reference + FROM identity.custody_key_reference + WHERE provisioning_id = $1 + AND state IN ('generated', 'active') + ORDER BY authority_role`, + [provisioningId], + ); + return result.rows.map((row) => ({ + authorityRole: row.authority_role, + hivePublicKey: row.hive_public_key, + providerKeyReference: row.custody_provider_key_reference, + })); + } + + public async saveKeys( + provisioningId: string, + keys: readonly CustodyKeyReference[], + at: Date, + ): Promise { + await withTransaction(this.pool, async (client) => { + for (const key of keys) { + await client.query( + `INSERT INTO identity.custody_key_reference ( + id, provisioning_id, authority_role, hive_public_key, + custody_provider_key_reference, created_at + ) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (custody_provider_key_reference) DO NOTHING`, + [ + createUuidV7(), + provisioningId, + key.authorityRole, + key.hivePublicKey, + key.providerKeyReference, + at, + ], + ); + } + const count = await client.query<{ count: string }>( + `SELECT count(*)::text AS count + FROM identity.custody_key_reference + WHERE provisioning_id = $1 AND state = 'generated'`, + [provisioningId], + ); + if (count.rows[0]?.count !== '4') { + throw new Error('Provisioning does not have exactly four generated keys.'); + } + await client.query( + `UPDATE identity.hive_account_provisioning + SET state = 'keys_ready', keys_ready_at = COALESCE(keys_ready_at, $2), + updated_at = $2, row_version = row_version + 1 + WHERE id = $1 AND state IN ('username_confirmed', 'keys_ready', 'retryable_failed')`, + [provisioningId, at], + ); + }); + } + + public async markAccountCreationPending( + provisioningId: string, + sponsorRequestReference: string, + at: Date, + ): Promise { + await this.pool.query( + `UPDATE identity.hive_account_provisioning + SET state = 'account_creation_pending', + sponsor_request_reference = COALESCE(sponsor_request_reference, $2), + last_attempt_at = $3, attempt_count = attempt_count + 1, + updated_at = $3, row_version = row_version + 1 + WHERE id = $1 + AND state IN ('keys_ready', 'account_creation_pending', 'retryable_failed')`, + [provisioningId, sponsorRequestReference, at], + ); + } + + public async markAccountCreated( + provisioningId: string, + transactionId: string, + observedAt: Date, + ): Promise { + await this.pool.query( + `UPDATE identity.hive_account_provisioning + SET state = 'account_created', + account_creation_transaction_id = COALESCE(account_creation_transaction_id, $2), + account_observed_at = COALESCE(account_observed_at, $3), + updated_at = $3, row_version = row_version + 1 + WHERE id = $1 + AND state IN ('account_creation_pending', 'account_created', 'retryable_failed')`, + [provisioningId, transactionId, observedAt], + ); + } + + public async markRcDelegationPending(provisioningId: string, at: Date): Promise { + await this.pool.query( + `UPDATE identity.hive_account_provisioning + SET state = 'rc_delegation_pending', updated_at = $2, row_version = row_version + 1 + WHERE id = $1 + AND state IN ('account_created', 'rc_delegation_pending', 'retryable_failed')`, + [provisioningId, at], + ); + } + + public async markRcDelegated( + provisioningId: string, + delegationReference: string, + at: Date, + ): Promise { + await this.pool.query( + `UPDATE identity.hive_account_provisioning + SET state = 'rc_delegated', initial_rc_delegation_id = $2, + updated_at = $3, row_version = row_version + 1 + WHERE id = $1 + AND state IN ('rc_delegation_pending', 'rc_delegated', 'retryable_failed')`, + [provisioningId, delegationReference, at], + ); + } + + public async markReady(provisioningId: string, at: Date): Promise { + await withTransaction(this.pool, async (client) => { + const selected = await client.query( + `SELECT id, external_identity_id, idempotency_key, requested_hive_username, + sponsor_hive_account, sponsor_policy_version, sponsor_program, state + FROM identity.hive_account_provisioning + WHERE id = $1 + FOR UPDATE`, + [provisioningId], + ); + const job = selected.rows[0]; + if (!job) { + throw new Error('Provisioning job disappeared before finalization.'); + } + if (job.state === 'ready') { + return; + } + const playerId = createUuidV7(); + const player = await client.query<{ id: string }>( + `INSERT INTO identity.player (id, hive_username, hive_control_state) + VALUES ($1, $2, 'platform_custodial') + ON CONFLICT (hive_username) DO UPDATE SET hive_username = EXCLUDED.hive_username + RETURNING id`, + [playerId, job.requested_hive_username], + ); + const resultingPlayerId = player.rows[0]?.id; + if (!resultingPlayerId) { + throw new Error('Provisioning could not create the playable identity.'); + } + await client.query( + `UPDATE identity.custody_key_reference + SET player_id = $2, state = 'active', activated_at = COALESCE(activated_at, $3) + WHERE provisioning_id = $1 AND state IN ('generated', 'active')`, + [provisioningId, resultingPlayerId, at], + ); + await client.query( + `UPDATE identity.hive_account_provisioning + SET resulting_player_id = $2, state = 'ready', + account_irreversible_at = COALESCE(account_irreversible_at, $3), + authorities_verified_at = COALESCE(authorities_verified_at, $3), + rc_verified_at = COALESCE(rc_verified_at, $3), + ready_at = COALESCE(ready_at, $3), updated_at = $3, + last_failure_code = NULL, next_retry_at = NULL, + row_version = row_version + 1 + WHERE id = $1 AND state IN ('rc_delegated', 'retryable_failed')`, + [provisioningId, resultingPlayerId, at], + ); + await client.query( + `UPDATE identity.external_identity + SET player_id = $2, status = 'linked', linked_at = COALESCE(linked_at, $3), + updated_at = $3 + WHERE id = $1 AND status = 'provisioning'`, + [job.external_identity_id, resultingPlayerId, at], + ); + await client.query( + `UPDATE identity.public_record_disclosure_acknowledgment + SET player_id = $2, player_linked_at = COALESCE(player_linked_at, $3) + WHERE external_identity_id = $1 AND player_id IS NULL`, + [job.external_identity_id, resultingPlayerId, at], + ); + }); + } + + public async markRetryableFailure( + provisioningId: string, + safeFailureCode: string, + at: Date, + ): Promise { + await this.pool.query( + `UPDATE identity.hive_account_provisioning + SET state = 'retryable_failed', last_failure_code = $2, + next_retry_at = $3 + interval '30 seconds', + updated_at = $3, row_version = row_version + 1 + WHERE id = $1 AND state NOT IN ('ready', 'terminal_failed')`, + [provisioningId, safeFailureCode, at], + ); + } +} + +function mapJob(row: JobRow): ProvisioningJob { + return { + externalIdentityId: row.external_identity_id, + id: row.id, + idempotencyKey: row.idempotency_key, + requestedHiveUsername: row.requested_hive_username, + sponsorHiveAccount: row.sponsor_hive_account, + sponsorPolicyVersion: row.sponsor_policy_version, + sponsorProgram: row.sponsor_program, + state: row.state, + }; +} diff --git a/apps/api/src/provisioning/provisioning.module.ts b/apps/api/src/provisioning/provisioning.module.ts new file mode 100644 index 0000000..eeafe17 --- /dev/null +++ b/apps/api/src/provisioning/provisioning.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { + HIVE_POSTING_AUTHORITY_VERIFIER, + type HivePostingAuthorityVerifier, +} from '../auth/auth.types'; +import { DatabaseConnection } from '../database/database.connection'; +import { loadOnboardingConfig, type OnboardingConfig } from './onboarding.config'; +import { OnboardingController } from './onboarding.controller'; +import { PostgresOnboardingRepository } from './onboarding.repository'; +import { OnboardingService } from './onboarding.service'; + +const ONBOARDING_CONFIG = Symbol('ONBOARDING_CONFIG'); + +@Module({ + controllers: [OnboardingController], + imports: [AuthModule], + providers: [ + { provide: ONBOARDING_CONFIG, useFactory: loadOnboardingConfig }, + { + inject: [DatabaseConnection], + provide: PostgresOnboardingRepository, + useFactory: (database: DatabaseConnection) => new PostgresOnboardingRepository(database.pool), + }, + { + inject: [PostgresOnboardingRepository, HIVE_POSTING_AUTHORITY_VERIFIER, ONBOARDING_CONFIG], + provide: OnboardingService, + useFactory: ( + repository: PostgresOnboardingRepository, + hive: HivePostingAuthorityVerifier, + config: OnboardingConfig | null, + ) => new OnboardingService(repository, hive, config), + }, + ], +}) +export class ProvisioningModule {} diff --git a/apps/api/src/provisioning/provisioning.service.spec.ts b/apps/api/src/provisioning/provisioning.service.spec.ts new file mode 100644 index 0000000..bba69d0 --- /dev/null +++ b/apps/api/src/provisioning/provisioning.service.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ProvisioningService } from './provisioning.service'; +import { + RetryableProvisioningError, + type CustodyKeyReference, + type ProvisioningJob, + type ProvisioningRepository, +} from './provisioning.types'; + +const job: ProvisioningJob = { + externalIdentityId: '01980abc-def1-7abc-8def-0123456789ab', + id: '01980abc-def2-7abc-8def-0123456789ab', + idempotencyKey: 'provisioning-logical-job', + requestedHiveUsername: 'alice', + sponsorHiveAccount: 'signup-sponsor', + sponsorPolicyVersion: 'sponsor-1', + sponsorProgram: 'approved-program', + state: 'username_confirmed', +}; +const keys: readonly CustodyKeyReference[] = [ + key('owner'), + key('active'), + key('posting'), + key('memo'), +]; + +describe('ProvisioningService', () => { + it('uses stable sub-operation keys and marks ready only after irreversible evidence', async () => { + const repository = repositoryFake(); + const custody = { generateAuthorityKeys: vi.fn().mockResolvedValue(keys) }; + const sponsor = { + createClaimedAccount: vi.fn().mockResolvedValue({ + requestReference: 'sponsor-request', + transactionId: 'hive-transaction', + }), + delegateInitialResourceCredits: vi.fn().mockResolvedValue({ + delegationReference: 'delegation-reference', + }), + }; + const chain = { + verifyReady: vi.fn().mockResolvedValue({ + accountIrreversible: true, + authoritiesMatch: true, + initialResourceCreditsReady: true, + }), + }; + + const result = await new ProvisioningService(repository, custody, sponsor, chain).process( + job.id, + new Date('2026-07-23T00:00:00Z'), + ); + + expect(result.state).toBe('ready'); + expect(custody.generateAuthorityKeys).toHaveBeenCalledWith( + expect.objectContaining({ idempotencyKey: 'provisioning-logical-job:keys' }), + ); + expect(sponsor.createClaimedAccount).toHaveBeenCalledWith( + expect.objectContaining({ + accountCreationMethod: 'claim_account_create_claimed_account', + idempotencyKey: 'provisioning-logical-job:account', + }), + ); + expect(sponsor.delegateInitialResourceCredits).toHaveBeenCalledWith( + expect.objectContaining({ idempotencyKey: 'provisioning-logical-job:initial-rc' }), + ); + expect(repository.markReady).toHaveBeenCalledOnce(); + }); + + it('records a retryable state while Hive evidence is incomplete', async () => { + const repository = repositoryFake(); + vi.mocked(repository.getKeys).mockResolvedValue(keys); + const service = new ProvisioningService( + repository, + { generateAuthorityKeys: vi.fn() }, + { + createClaimedAccount: vi.fn().mockResolvedValue({ + requestReference: 'sponsor-request', + transactionId: 'hive-transaction', + }), + delegateInitialResourceCredits: vi.fn().mockResolvedValue({ + delegationReference: 'delegation-reference', + }), + }, + { + verifyReady: vi.fn().mockResolvedValue({ + accountIrreversible: false, + authoritiesMatch: true, + initialResourceCreditsReady: true, + }), + }, + ); + + await expect(service.process(job.id)).rejects.toBeInstanceOf(RetryableProvisioningError); + expect(repository.markReady).not.toHaveBeenCalled(); + expect(repository.markRetryableFailure).toHaveBeenCalledWith( + job.id, + 'hive_account_confirmation_pending', + expect.any(Date), + ); + }); +}); + +function key(authorityRole: CustodyKeyReference['authorityRole']): CustodyKeyReference { + return { + authorityRole, + hivePublicKey: `STM-${authorityRole}-public`, + providerKeyReference: `opaque/${authorityRole}`, + }; +} + +function repositoryFake(): ProvisioningRepository { + return { + getJobForUpdate: vi.fn().mockResolvedValue(job), + getKeys: vi.fn().mockResolvedValue([]), + markAccountCreated: vi.fn(), + markAccountCreationPending: vi.fn(), + markRcDelegated: vi.fn(), + markRcDelegationPending: vi.fn(), + markReady: vi.fn(), + markRetryableFailure: vi.fn(), + saveKeys: vi.fn(), + }; +} diff --git a/apps/api/src/provisioning/provisioning.service.ts b/apps/api/src/provisioning/provisioning.service.ts new file mode 100644 index 0000000..803db0e --- /dev/null +++ b/apps/api/src/provisioning/provisioning.service.ts @@ -0,0 +1,137 @@ +import type { + CustodyKeyReference, + CustodyProvisioningProvider, + HiveAuthorityRole, + ProvisioningChainVerifier, + ProvisioningJob, + ProvisioningRepository, + SignupSponsorProvider, +} from './provisioning.types'; +import { RetryableProvisioningError } from './provisioning.types'; + +const authorityRoles = ['owner', 'active', 'posting', 'memo'] as const; + +/** + * Durable, retry-safe Google-to-Hive provisioning coordinator. External adapters + * must be approved independently; this service never owns sponsor or player keys. + */ +export class ProvisioningService { + public constructor( + private readonly repository: ProvisioningRepository, + private readonly custody: CustodyProvisioningProvider, + private readonly sponsor: SignupSponsorProvider, + private readonly chain: ProvisioningChainVerifier, + ) {} + + public async process(provisioningId: string, now = new Date()): Promise { + const job = await this.repository.getJobForUpdate(provisioningId); + if (!job) { + throw new Error('Provisioning job does not exist.'); + } + if (job.state === 'ready' || job.state === 'terminal_failed') { + return job; + } + + try { + const keyReferences = await this.ensureKeys(job, now); + const keys = keyMap(keyReferences); + + if ( + job.state === 'username_confirmed' || + job.state === 'keys_ready' || + job.state === 'account_creation_pending' || + job.state === 'retryable_failed' + ) { + const account = await this.sponsor.createClaimedAccount({ + accountCreationMethod: 'claim_account_create_claimed_account', + idempotencyKey: `${job.idempotencyKey}:account`, + keys, + policyVersion: job.sponsorPolicyVersion, + sponsorHiveAccount: job.sponsorHiveAccount, + username: job.requestedHiveUsername, + }); + await this.repository.markAccountCreationPending(job.id, account.requestReference, now); + const accountVerification = await this.chain.verifyReady({ + expectedKeys: keys, + sponsorHiveAccount: job.sponsorHiveAccount, + username: job.requestedHiveUsername, + }); + if (!accountVerification.accountIrreversible || !accountVerification.authoritiesMatch) { + throw new RetryableProvisioningError('hive_account_confirmation_pending'); + } + await this.repository.markAccountCreated(job.id, account.transactionId, now); + } + + if (job.state !== 'rc_delegated') { + await this.repository.markRcDelegationPending(job.id, now); + const delegation = await this.sponsor.delegateInitialResourceCredits({ + idempotencyKey: `${job.idempotencyKey}:initial-rc`, + policyVersion: job.sponsorPolicyVersion, + sponsorHiveAccount: job.sponsorHiveAccount, + username: job.requestedHiveUsername, + }); + await this.repository.markRcDelegated(job.id, delegation.delegationReference, now); + } + + const verification = await this.chain.verifyReady({ + expectedKeys: keys, + sponsorHiveAccount: job.sponsorHiveAccount, + username: job.requestedHiveUsername, + }); + if ( + !verification.accountIrreversible || + !verification.authoritiesMatch || + !verification.initialResourceCreditsReady + ) { + throw new RetryableProvisioningError('hive_confirmation_pending'); + } + await this.repository.markReady(job.id, now); + return { ...job, state: 'ready' }; + } catch (error: unknown) { + const safeCode = + error instanceof RetryableProvisioningError + ? error.safeCode + : 'provisioning_provider_unavailable'; + await this.repository.markRetryableFailure(job.id, safeCode, now); + throw error; + } + } + + private async ensureKeys( + job: ProvisioningJob, + now: Date, + ): Promise { + const existing = await this.repository.getKeys(job.id); + if (existing.length === authorityRoles.length) { + keyMap(existing); + return existing; + } + if (existing.length !== 0) { + throw new Error('Provisioning has an incomplete custody-key set.'); + } + const generated = await this.custody.generateAuthorityKeys({ + idempotencyKey: `${job.idempotencyKey}:keys`, + provisioningId: job.id, + roles: authorityRoles, + }); + keyMap(generated); + await this.repository.saveKeys(job.id, generated, now); + return generated; + } +} + +function keyMap(keys: readonly CustodyKeyReference[]): Readonly> { + const entries = new Map(); + for (const key of keys) { + if (entries.has(key.authorityRole) || !key.hivePublicKey || !key.providerKeyReference) { + throw new Error('Custody provider returned an invalid authority-key set.'); + } + entries.set(key.authorityRole, key.hivePublicKey); + } + for (const role of authorityRoles) { + if (!entries.has(role)) { + throw new Error('Custody provider did not generate every required authority role.'); + } + } + return Object.fromEntries(entries) as Record; +} diff --git a/apps/api/src/provisioning/provisioning.types.ts b/apps/api/src/provisioning/provisioning.types.ts new file mode 100644 index 0000000..4ecb197 --- /dev/null +++ b/apps/api/src/provisioning/provisioning.types.ts @@ -0,0 +1,95 @@ +export type HiveAuthorityRole = 'owner' | 'active' | 'posting' | 'memo'; +export type ProvisioningState = + | 'username_confirmed' + | 'keys_ready' + | 'account_creation_pending' + | 'account_created' + | 'rc_delegation_pending' + | 'rc_delegated' + | 'ready' + | 'retryable_failed' + | 'terminal_failed'; + +export interface ProvisioningJob { + readonly externalIdentityId: string; + readonly id: string; + readonly idempotencyKey: string; + readonly requestedHiveUsername: string; + readonly sponsorHiveAccount: string; + readonly sponsorPolicyVersion: string; + readonly sponsorProgram: string; + readonly state: ProvisioningState; +} + +export interface CustodyKeyReference { + readonly authorityRole: HiveAuthorityRole; + readonly hivePublicKey: string; + readonly providerKeyReference: string; +} + +export interface ProvisioningRepository { + getJobForUpdate(provisioningId: string): Promise; + getKeys(provisioningId: string): Promise; + saveKeys(provisioningId: string, keys: readonly CustodyKeyReference[], at: Date): Promise; + markAccountCreationPending( + provisioningId: string, + sponsorRequestReference: string, + at: Date, + ): Promise; + markAccountCreated( + provisioningId: string, + transactionId: string, + observedAt: Date, + ): Promise; + markRcDelegationPending(provisioningId: string, at: Date): Promise; + markRcDelegated(provisioningId: string, delegationReference: string, at: Date): Promise; + markReady(provisioningId: string, at: Date): Promise; + markRetryableFailure(provisioningId: string, safeFailureCode: string, at: Date): Promise; +} + +export interface CustodyProvisioningProvider { + generateAuthorityKeys(input: { + readonly idempotencyKey: string; + readonly provisioningId: string; + readonly roles: readonly HiveAuthorityRole[]; + }): Promise; +} + +export interface SignupSponsorProvider { + createClaimedAccount(input: { + readonly accountCreationMethod: 'claim_account_create_claimed_account'; + readonly idempotencyKey: string; + readonly keys: Readonly>; + readonly policyVersion: string; + readonly sponsorHiveAccount: string; + readonly username: string; + }): Promise<{ + readonly requestReference: string; + readonly transactionId: string; + }>; + delegateInitialResourceCredits(input: { + readonly idempotencyKey: string; + readonly policyVersion: string; + readonly sponsorHiveAccount: string; + readonly username: string; + }): Promise<{ readonly delegationReference: string }>; +} + +export interface ProvisioningChainVerifier { + verifyReady(input: { + readonly expectedKeys: Readonly>; + readonly sponsorHiveAccount: string; + readonly username: string; + }): Promise<{ + readonly accountIrreversible: boolean; + readonly authoritiesMatch: boolean; + readonly initialResourceCreditsReady: boolean; + }>; +} + +export class RetryableProvisioningError extends Error { + public constructor(public readonly safeCode: string) { + super(safeCode); + this.name = 'RetryableProvisioningError'; + } +} diff --git a/apps/api/src/realtime/development-realtime-principal.guard.spec.ts b/apps/api/src/realtime/development-realtime-principal.guard.spec.ts deleted file mode 100644 index 3860e6d..0000000 --- a/apps/api/src/realtime/development-realtime-principal.guard.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { type ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { describe, expect, it } from 'vitest'; - -import { DevelopmentRealtimePrincipalGuard } from './development-realtime-principal.guard'; -import type { RealtimeConfig } from './realtime.config'; -import type { RealtimeHttpRequest } from './realtime.types'; - -describe('DevelopmentRealtimePrincipalGuard', () => { - it('binds only the fixed configured principal', () => { - const request = requestWithAuthorization('Bearer 0123456789abcdef0123456789abcdef'); - const guard = new DevelopmentRealtimePrincipalGuard(config()); - - expect(guard.canActivate(executionContext(request))).toBe(true); - expect(request.realtimePrincipal).toEqual({ - authSessionId: '01980abc-def1-7abc-9def-0123456789ab', - playerId: '01980abc-def0-7abc-8def-0123456789ab', - }); - }); - - it('denies missing or incorrect credentials without accepting caller identity', () => { - const guard = new DevelopmentRealtimePrincipalGuard(config()); - const request = requestWithAuthorization('Bearer wrong'); - - expect(() => guard.canActivate(executionContext(request))).toThrow(UnauthorizedException); - expect(request.realtimePrincipal).toBeUndefined(); - }); -}); - -function config(): RealtimeConfig { - return { - developmentPrincipal: { - authSessionId: '01980abc-def1-7abc-9def-0123456789ab', - bearerToken: '0123456789abcdef0123456789abcdef', - playerId: '01980abc-def0-7abc-8def-0123456789ab', - }, - nakama: null, - nodeEnvironment: 'test', - }; -} - -function requestWithAuthorization(authorization: string): RealtimeHttpRequest { - return { headers: { authorization } }; -} - -function executionContext(request: RealtimeHttpRequest): ExecutionContext { - return { - getArgByIndex: () => undefined, - getArgs: () => [], - getClass: () => DevelopmentRealtimePrincipalGuard, - getHandler: () => DevelopmentRealtimePrincipalGuard.prototype.canActivate, - getType: () => 'http', - switchToHttp: () => ({ - getNext: () => undefined, - getRequest: () => request, - getResponse: () => undefined, - }), - switchToRpc: () => ({ - getContext: () => undefined, - getData: () => undefined, - }), - switchToWs: () => ({ - getClient: () => undefined, - getData: () => undefined, - getPattern: () => undefined, - }), - } as unknown as ExecutionContext; -} diff --git a/apps/api/src/realtime/development-realtime-principal.guard.ts b/apps/api/src/realtime/development-realtime-principal.guard.ts deleted file mode 100644 index 9e47758..0000000 --- a/apps/api/src/realtime/development-realtime-principal.guard.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - type CanActivate, - type ExecutionContext, - Inject, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; - -import { matchesDevelopmentBearer, REALTIME_CONFIG, type RealtimeConfig } from './realtime.config'; -import type { RealtimeHttpRequest } from './realtime.types'; - -@Injectable() -export class DevelopmentRealtimePrincipalGuard implements CanActivate { - // Card #25 replaces this temporary strategy with the real playable-session/disclosure guard. - // Keeping the fixed principal behind explicit non-production configuration lets #23 exercise - // the full bridge without creating a second identity system. - constructor(@Inject(REALTIME_CONFIG) private readonly config: RealtimeConfig) {} - - canActivate(context: ExecutionContext): boolean { - const developmentPrincipal = this.config.developmentPrincipal; - const request = context.switchToHttp().getRequest(); - const authorization = request.headers.authorization; - - if ( - !developmentPrincipal || - typeof authorization !== 'string' || - !matchesDevelopmentBearer(authorization, developmentPrincipal.bearerToken) - ) { - throw new UnauthorizedException({ - code: 'playable_session_required', - detail: 'A playable game session is required to mint a realtime credential.', - status: 401, - title: 'Unauthorized', - }); - } - - request.realtimePrincipal = { - authSessionId: developmentPrincipal.authSessionId, - playerId: developmentPrincipal.playerId, - }; - return true; - } -} diff --git a/apps/api/src/realtime/realtime.config.spec.ts b/apps/api/src/realtime/realtime.config.spec.ts index 781cf48..0683635 100644 --- a/apps/api/src/realtime/realtime.config.spec.ts +++ b/apps/api/src/realtime/realtime.config.spec.ts @@ -1,19 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { loadRealtimeConfig, matchesDevelopmentBearer } from './realtime.config'; +import { loadRealtimeConfig } from './realtime.config'; const bridgeKey = Buffer.from('0123456789abcdef0123456789abcdef').toString('base64url'); describe('loadRealtimeConfig', () => { it('keeps the scaffold startable when realtime is not configured', () => { expect(loadRealtimeConfig({ NODE_ENV: 'development' })).toEqual({ - developmentPrincipal: null, nakama: null, nodeEnvironment: 'development', }); }); - it('loads a complete local bridge and fixed development principal', () => { + it('loads a complete local bridge', () => { const config = loadRealtimeConfig(developmentEnvironment()); expect(config.nakama).toMatchObject({ @@ -22,21 +21,6 @@ describe('loadRealtimeConfig', () => { requestTimeoutMs: 5_000, socketUrl: 'ws://127.0.0.1:7350/ws', }); - expect(config.developmentPrincipal).toMatchObject({ - authSessionId: '01980abc-def1-7abc-9def-0123456789ab', - playerId: '01980abc-def0-7abc-8def-0123456789ab', - }); - }); - - it('refuses the temporary principal in production', () => { - expect(() => - loadRealtimeConfig({ - ...developmentEnvironment(), - NAKAMA_HTTP_URL: 'https://nakama.internal.example', - NAKAMA_SOCKET_URL: 'wss://game.example/ws', - NODE_ENV: 'production', - }), - ).toThrow(/cannot be enabled in production/); }); it('refuses partial or weak bridge configuration', () => { @@ -54,14 +38,6 @@ describe('loadRealtimeConfig', () => { }); }); -describe('matchesDevelopmentBearer', () => { - it('accepts only the exact configured bearer token', () => { - expect(matchesDevelopmentBearer('Bearer abcdef', 'abcdef')).toBe(true); - expect(matchesDevelopmentBearer('Bearer abcdeg', 'abcdef')).toBe(false); - expect(matchesDevelopmentBearer(undefined, 'abcdef')).toBe(false); - }); -}); - function developmentEnvironment(): NodeJS.ProcessEnv { return { NAKAMA_BRIDGE_HMAC_KEY: bridgeKey, @@ -69,10 +45,5 @@ function developmentEnvironment(): NodeJS.ProcessEnv { NAKAMA_SERVER_KEY: 'local-public-key', NAKAMA_SOCKET_URL: 'ws://127.0.0.1:7350/ws', NODE_ENV: 'development', - REALTIME_DEV_AUTH_SESSION_ID: '01980abc-def1-7abc-9def-0123456789ab', - REALTIME_DEV_BEARER_TOKEN: '0123456789abcdef0123456789abcdef', - REALTIME_DEV_DISCLOSURE_ACKNOWLEDGED: 'true', - REALTIME_DEV_PLAYER_ID: '01980abc-def0-7abc-8def-0123456789ab', - REALTIME_DEV_PRINCIPAL_ENABLED: 'true', }; } diff --git a/apps/api/src/realtime/realtime.config.ts b/apps/api/src/realtime/realtime.config.ts index a356018..af062d0 100644 --- a/apps/api/src/realtime/realtime.config.ts +++ b/apps/api/src/realtime/realtime.config.ts @@ -1,15 +1,7 @@ -import { createHash, timingSafeEqual } from 'node:crypto'; - export const REALTIME_CONFIG = Symbol('REALTIME_CONFIG'); const uuidV7Pattern = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; -export interface DevelopmentRealtimePrincipalConfig { - authSessionId: string; - bearerToken: string; - playerId: string; -} - export interface NakamaBridgeConfig { assertionTtlSeconds: number; bridgeHmacKey: Buffer; @@ -20,7 +12,6 @@ export interface NakamaBridgeConfig { } export interface RealtimeConfig { - developmentPrincipal: DevelopmentRealtimePrincipalConfig | null; nakama: NakamaBridgeConfig | null; nodeEnvironment: string; } @@ -28,24 +19,13 @@ export interface RealtimeConfig { export function loadRealtimeConfig(environment: NodeJS.ProcessEnv = process.env): RealtimeConfig { const nodeEnvironment = environment.NODE_ENV?.trim() || 'development'; const nakama = loadNakamaBridgeConfig(environment, nodeEnvironment); - const developmentPrincipal = loadDevelopmentPrincipal(environment, nodeEnvironment, nakama); - - return { developmentPrincipal, nakama, nodeEnvironment }; + return { nakama, nodeEnvironment }; } export function isCanonicalUuidV7(value: string): boolean { return uuidV7Pattern.test(value); } -export function matchesDevelopmentBearer(header: string | undefined, expected: string): boolean { - const prefix = 'Bearer '; - const candidate = header?.startsWith(prefix) ? header.slice(prefix.length) : ''; - const candidateHash = createHash('sha256').update(candidate, 'utf8').digest(); - const expectedHash = createHash('sha256').update(expected, 'utf8').digest(); - - return candidate.length > 0 && timingSafeEqual(candidateHash, expectedHash); -} - function loadNakamaBridgeConfig( environment: NodeJS.ProcessEnv, nodeEnvironment: string, @@ -101,43 +81,6 @@ function loadNakamaBridgeConfig( }; } -function loadDevelopmentPrincipal( - environment: NodeJS.ProcessEnv, - nodeEnvironment: string, - nakama: NakamaBridgeConfig | null, -): DevelopmentRealtimePrincipalConfig | null { - const enabledValue = environment.REALTIME_DEV_PRINCIPAL_ENABLED?.trim().toLowerCase(); - if (!enabledValue || enabledValue === 'false') { - return null; - } - if (enabledValue !== 'true') { - throw new Error('REALTIME_DEV_PRINCIPAL_ENABLED must be true or false.'); - } - if (nodeEnvironment === 'production') { - throw new Error('The development realtime principal cannot be enabled in production.'); - } - if (!nakama) { - throw new Error('The development realtime principal requires Nakama bridge configuration.'); - } - if (environment.REALTIME_DEV_DISCLOSURE_ACKNOWLEDGED?.trim().toLowerCase() !== 'true') { - throw new Error( - 'REALTIME_DEV_DISCLOSURE_ACKNOWLEDGED=true is required for the development realtime principal.', - ); - } - - const playerId = environment.REALTIME_DEV_PLAYER_ID?.trim() ?? ''; - const authSessionId = environment.REALTIME_DEV_AUTH_SESSION_ID?.trim() ?? ''; - const bearerToken = environment.REALTIME_DEV_BEARER_TOKEN ?? ''; - if (!isCanonicalUuidV7(playerId) || !isCanonicalUuidV7(authSessionId)) { - throw new Error('Development player and auth-session IDs must be canonical UUIDv7 values.'); - } - if (bearerToken.length < 32) { - throw new Error('REALTIME_DEV_BEARER_TOKEN must contain at least 32 characters.'); - } - - return { authSessionId, bearerToken, playerId }; -} - function decodeBridgeKey(encoded: string): Buffer { if (!/^[A-Za-z0-9_-]+$/.test(encoded)) { throw new Error('NAKAMA_BRIDGE_HMAC_KEY must be canonical unpadded base64url.'); diff --git a/apps/api/src/realtime/realtime.controller.ts b/apps/api/src/realtime/realtime.controller.ts index 481e3ef..43d6a2a 100644 --- a/apps/api/src/realtime/realtime.controller.ts +++ b/apps/api/src/realtime/realtime.controller.ts @@ -2,6 +2,7 @@ import { Controller, Header, HttpCode, + HttpException, HttpStatus, Inject, Post, @@ -10,12 +11,12 @@ import { UseGuards, } from '@nestjs/common'; -import { DevelopmentRealtimePrincipalGuard } from './development-realtime-principal.guard'; +import { AccessSessionGuard } from '../auth/access-session.guard'; import { RealtimeService } from './realtime.service'; import type { RealtimeHttpRequest, RealtimeSessionResponse } from './realtime.types'; @Controller('realtime') -@UseGuards(DevelopmentRealtimePrincipalGuard) +@UseGuards(AccessSessionGuard) export class RealtimeController { constructor(@Inject(RealtimeService) private readonly realtimeService: RealtimeService) {} @@ -23,7 +24,7 @@ export class RealtimeController { @HttpCode(HttpStatus.CREATED) @Header('Cache-Control', 'no-store') async createSession(@Req() request: RealtimeHttpRequest): Promise { - if (!request.realtimePrincipal) { + if (!request.authPrincipal) { throw new UnauthorizedException({ code: 'playable_session_required', detail: 'A playable game session is required to mint a realtime credential.', @@ -31,6 +32,20 @@ export class RealtimeController { title: 'Unauthorized', }); } - return this.realtimeService.createSession(request.realtimePrincipal); + if (!request.authPrincipal.disclosureAcknowledged) { + throw new HttpException( + { + code: 'public_record_disclosure_required', + detail: 'Acknowledge the current permanent-public-record disclosure before playing.', + status: 428, + title: 'Disclosure acknowledgment required', + }, + 428, + ); + } + return this.realtimeService.createSession({ + authSessionId: request.authPrincipal.authSessionId, + playerId: request.authPrincipal.playerId, + }); } } diff --git a/apps/api/src/realtime/realtime.module.ts b/apps/api/src/realtime/realtime.module.ts index f09e414..68ad833 100644 --- a/apps/api/src/realtime/realtime.module.ts +++ b/apps/api/src/realtime/realtime.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { Client } from '@heroiclabs/nakama-js'; -import { DevelopmentRealtimePrincipalGuard } from './development-realtime-principal.guard'; +import { AuthModule } from '../auth/auth.module'; import { RealtimeController } from './realtime.controller'; import { loadRealtimeConfig, REALTIME_CONFIG, type RealtimeConfig } from './realtime.config'; import { RealtimeService } from './realtime.service'; import { NAKAMA_AUTH_CLIENT, type NakamaAuthClient } from './realtime.types'; @Module({ + imports: [AuthModule], controllers: [RealtimeController], providers: [ { @@ -34,7 +35,6 @@ import { NAKAMA_AUTH_CLIENT, type NakamaAuthClient } from './realtime.types'; ); }, }, - DevelopmentRealtimePrincipalGuard, RealtimeService, ], exports: [RealtimeService], diff --git a/apps/api/src/realtime/realtime.service.spec.ts b/apps/api/src/realtime/realtime.service.spec.ts index d1aa76b..05d0d36 100644 --- a/apps/api/src/realtime/realtime.service.spec.ts +++ b/apps/api/src/realtime/realtime.service.spec.ts @@ -45,10 +45,7 @@ describe('RealtimeService', () => { }); it('fails closed when realtime configuration is absent', async () => { - const service = new RealtimeService( - { developmentPrincipal: null, nakama: null, nodeEnvironment: 'test' }, - null, - ); + const service = new RealtimeService({ nakama: null, nodeEnvironment: 'test' }, null); await expect(service.createSession(principal)).rejects.toBeInstanceOf( ServiceUnavailableException, @@ -58,7 +55,6 @@ describe('RealtimeService', () => { function config(): RealtimeConfig { return { - developmentPrincipal: null, nakama: { assertionTtlSeconds: 30, bridgeHmacKey: Buffer.from('0123456789abcdef0123456789abcdef'), diff --git a/apps/api/src/realtime/realtime.types.ts b/apps/api/src/realtime/realtime.types.ts index a0bf7c3..e41d888 100644 --- a/apps/api/src/realtime/realtime.types.ts +++ b/apps/api/src/realtime/realtime.types.ts @@ -1,4 +1,5 @@ import type { BridgePrincipal } from './bridge-assertion'; +import type { AuthHttpRequest } from '../auth/auth.types'; export interface NakamaSessionResult { expires_at?: number; @@ -15,8 +16,7 @@ export interface NakamaAuthClient { ): Promise; } -export interface RealtimeHttpRequest { - headers: Record; +export interface RealtimeHttpRequest extends AuthHttpRequest { realtimePrincipal?: BridgePrincipal; } diff --git a/docs/technical-specification/data-model/hive-chameleon.dbml b/docs/technical-specification/data-model/hive-chameleon.dbml index 4748a2a..c2f6cf6 100644 --- a/docs/technical-specification/data-model/hive-chameleon.dbml +++ b/docs/technical-specification/data-model/hive-chameleon.dbml @@ -574,6 +574,31 @@ Table identity.player { Note: 'Playable identity. Direct-Hive login may create it after signature verification; Google provisioning creates it only when the real Hive account, expected authorities, and initial RC are verified ready. No guest/pending player is stored.' } +Table identity.hive_login_challenge { + id uuid [pk, not null, note: 'Application-generated UUIDv7; unguessable single-use handle'] + hive_username varchar(16) [not null] + platform identity.client_platform [not null] + device_session_id uuid [not null] + challenge_text text [not null] + challenge_sha256 char(64) [not null, unique] + issued_at timestamptz [not null] + expires_at timestamptz [not null] + consumed_at timestamptz + + indexes { + (expires_at, consumed_at) [name: 'idx_hive_login_challenge_expiry'] + } + + checks { + `hive_username = lower(hive_username)` [name: 'chk_hive_login_challenge_username_lower'] + `expires_at > issued_at` [name: 'chk_hive_login_challenge_expiry'] + `consumed_at IS NULL OR consumed_at >= issued_at` [name: 'chk_hive_login_challenge_consumed'] + `encode(digest(convert_to(challenge_text, 'UTF8'), 'sha256'), 'hex') = challenge_sha256` [name: 'chk_hive_login_challenge_hash'] + } + + Note: 'Canonical login text is public; the random nonce and UUID make a challenge unguessable. Consumption is atomic and occurs before signature verification so one handle cannot be replayed.' +} + Table identity.external_identity { id uuid [pk, not null, note: 'Application-generated UUIDv7'] provider identity.external_identity_provider [not null] diff --git a/infra/postgres/migrations/20260723000100_auth_foundation.sql b/infra/postgres/migrations/20260723000100_auth_foundation.sql new file mode 100644 index 0000000..f4d8ec1 --- /dev/null +++ b/infra/postgres/migrations/20260723000100_auth_foundation.sql @@ -0,0 +1,51 @@ +-- migrate:up + +CREATE TABLE identity.hive_login_challenge ( + id uuid PRIMARY KEY NOT NULL, + hive_username varchar(16) NOT NULL, + platform identity.client_platform NOT NULL, + device_session_id uuid NOT NULL, + challenge_text text NOT NULL, + challenge_sha256 char(64) UNIQUE NOT NULL, + issued_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + CONSTRAINT chk_hive_login_challenge_id_uuid_v7 + CHECK (public.hc_is_uuid_v7(id)), + CONSTRAINT chk_hive_login_challenge_username_lower + CHECK (hive_username = lower(hive_username)), + CONSTRAINT chk_hive_login_challenge_expiry + CHECK (expires_at > issued_at), + CONSTRAINT chk_hive_login_challenge_consumed + CHECK (consumed_at IS NULL OR consumed_at >= issued_at), + CONSTRAINT chk_hive_login_challenge_hash + CHECK ( + encode(digest(convert_to(challenge_text, 'UTF8'), 'sha256'), 'hex') = challenge_sha256 + ) +); + +CREATE INDEX idx_hive_login_challenge_expiry + ON identity.hive_login_challenge (expires_at, consumed_at); + +COMMENT ON TABLE identity.hive_login_challenge IS + 'Single-use canonical direct-Hive login challenges. Text is public; random nonce and UUID prevent guessing, and atomic consumption prevents replay.'; + +GRANT SELECT, INSERT, UPDATE ON identity.hive_login_challenge TO hc_api; +GRANT UPDATE (player_id, player_linked_at) + ON identity.public_record_disclosure_acknowledgment TO hc_provisioning; + +INSERT INTO identity.public_record_disclosure ( + disclosure_version, content_sha256, effective_at +) VALUES ( + '2026-07-22', + '64ae0b7b385c6f4c1287e25979c44fce05c51583f61f20c7bb7e31171d921cc3', + '2026-07-22T00:00:00Z' +); + +-- migrate:down + +DO $$ +BEGIN + RAISE EXCEPTION 'Hive Chameleon database migrations are forward-only; restore a tested backup and apply a forward repair'; +END; +$$; diff --git a/infra/postgres/test/001_schema.sql b/infra/postgres/test/001_schema.sql index fef80ac..b6d4b76 100644 --- a/infra/postgres/test/001_schema.sql +++ b/infra/postgres/test/001_schema.sql @@ -24,14 +24,22 @@ SELECT pg_temp.assert_true( SELECT pg_temp.assert_true( ( - SELECT count(*) = 74 + SELECT count(*) = 75 FROM information_schema.tables WHERE table_schema IN ( 'identity', 'social', 'game', 'content', 'commerce', 'tournament', 'hive_projection' ) AND table_type = 'BASE TABLE' ), - 'all 74 modeled tables exist' + 'all 75 modeled tables exist' +); + +SELECT pg_temp.assert_true( + has_table_privilege('hc_api', 'identity.hive_login_challenge', 'SELECT') + AND has_table_privilege('hc_api', 'identity.hive_login_challenge', 'INSERT') + AND has_table_privilege('hc_api', 'identity.hive_login_challenge', 'UPDATE') + AND NOT has_table_privilege('hc_nakama', 'identity.hive_login_challenge', 'SELECT'), + 'only the product API can create and consume direct-Hive challenges' ); SELECT pg_temp.assert_true( diff --git a/package-lock.json b/package-lock.json index f55b8b0..348d858 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,12 +31,16 @@ "version": "0.1.0", "dependencies": { "@heroiclabs/nakama-js": "2.8.0", + "@hive-chameleon/database": "^0.1.0", + "@hive-chameleon/hive-gateway": "^0.1.0", "@nestjs/common": "11.1.28", "@nestjs/config": "4.0.4", "@nestjs/core": "11.1.28", "@nestjs/platform-express": "11.1.28", + "google-auth-library": "^10.5.0", "reflect-metadata": "0.2.2", - "rxjs": "7.8.2" + "rxjs": "7.8.2", + "zod": "^4.4.3" }, "devDependencies": { "@nestjs/cli": "11.0.24", @@ -56,6 +60,24 @@ "undici-types": "~7.18.0" } }, + "apps/api/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "apps/api/node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -4559,7 +4581,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -4898,7 +4919,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -4956,7 +4976,6 @@ "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -5136,7 +5155,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/buffer-from": { @@ -5624,7 +5642,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -5829,7 +5846,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -6316,7 +6332,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -6347,9 +6362,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -6443,7 +6458,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, "funding": [ { "type": "github", @@ -6724,7 +6738,6 @@ "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, "license": "MIT", "dependencies": { "fetch-blob": "^3.1.2" @@ -6815,7 +6828,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.2.0.tgz", "integrity": "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -6830,7 +6842,6 @@ "version": "8.1.2", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "gaxios": "^7.0.0", @@ -6969,7 +6980,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=14" @@ -6994,6 +7004,19 @@ "dev": true, "license": "ISC" }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -7112,7 +7135,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -7521,7 +7543,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "dev": true, "license": "MIT", "dependencies": { "bignumber.js": "^9.0.0" @@ -7615,7 +7636,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dev": true, "license": "MIT", "dependencies": { "buffer-equal-constant-time": "^1.0.1", @@ -7627,7 +7647,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "dev": true, "license": "MIT", "dependencies": { "jwa": "^2.0.1", @@ -8523,7 +8542,6 @@ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", - "dev": true, "funding": [ { "type": "github", @@ -8553,7 +8571,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -10875,7 +10892,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" diff --git a/packages/database/package.json b/packages/database/package.json index d2bbf79..1c2f402 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -9,7 +9,9 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "require": "./dist/index.js", + "default": "./dist/index.js" } }, "files": [ diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts index 8d50049..7c5ebf1 100644 --- a/packages/database/src/client.ts +++ b/packages/database/src/client.ts @@ -1,5 +1,7 @@ import { Pool, type PoolClient, type PoolConfig } from 'pg'; +export type DatabasePool = Pool; + export type TransactionIsolation = 'read committed' | 'repeatable read' | 'serializable'; export interface TransactionOptions { diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index fee2a62..a42ed77 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -2,6 +2,7 @@ export { checkDatabaseConnection, createDatabasePool, withTransaction, + type DatabasePool, type TransactionIsolation, type TransactionOptions, } from './client.js'; diff --git a/packages/hive-gateway/package.json b/packages/hive-gateway/package.json index 00a1c93..b702de8 100644 --- a/packages/hive-gateway/package.json +++ b/packages/hive-gateway/package.json @@ -4,18 +4,26 @@ "private": true, "type": "module", "description": "Typed Hive protocol, WAX transaction, and isolated signer-policy boundary", + "main": "dist/index.js", + "types": "dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "require": "./dist/index.js", + "default": "./dist/index.js" }, "./protocol": { "types": "./dist/protocol/index.d.ts", - "import": "./dist/protocol/index.js" + "import": "./dist/protocol/index.js", + "require": "./dist/protocol/index.js", + "default": "./dist/protocol/index.js" }, "./testing": { "types": "./dist/testing/index.d.ts", - "import": "./dist/testing/index.js" + "import": "./dist/testing/index.js", + "require": "./dist/testing/index.js", + "default": "./dist/testing/index.js" } }, "scripts": { diff --git a/runtime/nakama/README.md b/runtime/nakama/README.md index 1b51ef2..6279c33 100644 --- a/runtime/nakama/README.md +++ b/runtime/nakama/README.md @@ -35,13 +35,10 @@ set +a npm run dev ``` -The development principal is deliberately unavailable unless explicitly enabled, acknowledged, -and supplied through the configured bearer token. Production configuration rejects this mode. -The Unity desktop development client reads `HIVE_CHAMELEON_API_URL`, `NAKAMA_SERVER_KEY`, and -`REALTIME_DEV_BEARER_TOKEN` from its environment. With no local credentials it stays offline and -the scaffold build continues normally. WebGL cannot inherit desktop environment variables, so its -local-only values must be injected by the development build pipeline; never serialize them into a -production scene or build. +Realtime issuance now requires a real NestJS access token backed by an active PostgreSQL session; +the former fixed development principal has been removed. Unity obtains the token through the auth +API before requesting a Nakama credential. Never serialize API, Nakama, or signing credentials +into a scene or production build. ## Realtime contract boundary @@ -60,12 +57,12 @@ The documented lobby RPC names and `match.reconnect` are registered but return `feature_not_ready` with gRPC `UNIMPLEMENTED`. This is intentional: later gameplay cards replace the stubs with authoritative behavior without allowing a placeholder to report false success. -With the compose stack and NestJS API running from the same environment, exercise the complete -bridge, same-assertion replay denial, and non-bridge authentication denial without printing -credentials: +The repository smoke command starts disposable application and Nakama databases, seeds one +short-lived real API session, and exercises the complete bridge, same-assertion replay denial, and +non-bridge authentication denial without printing credentials: ```bash -node runtime/nakama/smoke.mjs +npm run realtime:smoke ``` Nakama's WebSocket protocol reports the intentional RPC runtime exception as realtime code `7` diff --git a/runtime/nakama/smoke.mjs b/runtime/nakama/smoke.mjs index 6d50e30..eafc832 100644 --- a/runtime/nakama/smoke.mjs +++ b/runtime/nakama/smoke.mjs @@ -5,9 +5,9 @@ import { Client, Session } from '@heroiclabs/nakama-js'; const requiredEnvironment = [ 'NAKAMA_SERVER_KEY', 'NAKAMA_BRIDGE_HMAC_KEY', - 'REALTIME_DEV_AUTH_SESSION_ID', - 'REALTIME_DEV_BEARER_TOKEN', - 'REALTIME_DEV_PLAYER_ID', + 'AUTH_TOKEN_SECRET', + 'SMOKE_AUTH_SESSION_ID', + 'SMOKE_PLAYER_ID', ]; for (const name of requiredEnvironment) { @@ -33,8 +33,8 @@ const client = new Client( let deviceAuthenticationRejected = false; try { await client.authenticateDevice(randomBytes(32).toString('hex'), true, undefined, { - app_auth_session_id: process.env.REALTIME_DEV_AUTH_SESSION_ID, - app_player_id: process.env.REALTIME_DEV_PLAYER_ID, + app_auth_session_id: process.env.SMOKE_AUTH_SESSION_ID, + app_player_id: process.env.SMOKE_PLAYER_ID, bridge_version: 'v1', }); } catch { @@ -46,9 +46,9 @@ if (!deviceAuthenticationRejected) { } const replayAssertion = createBridgeAssertion({ - authSessionId: process.env.REALTIME_DEV_AUTH_SESSION_ID, + authSessionId: process.env.SMOKE_AUTH_SESSION_ID, bridgeKey: process.env.NAKAMA_BRIDGE_HMAC_KEY, - playerId: process.env.REALTIME_DEV_PLAYER_ID, + playerId: process.env.SMOKE_PLAYER_ID, }); const directBridgeSession = await client.authenticateCustom(replayAssertion, true); @@ -64,7 +64,7 @@ if (!bridgeAssertionReplayRejected) { let customIdentityPreclaimRejected = false; try { - await client.linkCustom(directBridgeSession, { id: process.env.REALTIME_DEV_AUTH_SESSION_ID }); + await client.linkCustom(directBridgeSession, { id: process.env.SMOKE_AUTH_SESSION_ID }); } catch { customIdentityPreclaimRejected = true; } @@ -74,7 +74,7 @@ if (!customIdentityPreclaimRejected) { const response = await fetch(apiUrl, { headers: { - authorization: `Bearer ${process.env.REALTIME_DEV_BEARER_TOKEN}`, + authorization: `Bearer ${createAccessToken()}`, }, method: 'POST', }); @@ -142,6 +142,27 @@ function createBridgeAssertion({ authSessionId, bridgeKey, playerId }) { return `${payload}.${signature}`; } +function createAccessToken() { + const now = Math.floor(Date.now() / 1_000); + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from( + JSON.stringify({ + aud: 'hive-chameleon-client', + exp: now + 300, + iat: now, + iss: 'hive-chameleon-api', + sid: process.env.SMOKE_AUTH_SESSION_ID, + sub: process.env.SMOKE_PLAYER_ID, + typ: 'access', + }), + ).toString('base64url'); + const signingInput = `${header}.${payload}`; + const signature = createHmac('sha256', Buffer.from(process.env.AUTH_TOKEN_SECRET, 'base64url')) + .update(signingInput) + .digest('base64url'); + return `${signingInput}.${signature}`; +} + function compactUuid(value) { const hex = value.replaceAll('-', ''); if (!/^[0-9a-f]{32}$/.test(hex)) { diff --git a/scripts/check-nakama-bridge.sh b/scripts/check-nakama-bridge.sh index fc9e1d3..e2509c4 100755 --- a/scripts/check-nakama-bridge.sh +++ b/scripts/check-nakama-bridge.sh @@ -4,7 +4,9 @@ set -euo pipefail repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" compose_file="${repository_root}/runtime/nakama/compose.yaml" +application_compose_file="${repository_root}/infra/postgres/compose.yaml" project_name="${HIVE_CHAMELEON_NAKAMA_PROJECT:-hive-chameleon-nakama-smoke-$$}" +application_project_name="${project_name}-application" api_log="$(mktemp "${TMPDIR:-/tmp}/hive-chameleon-api-smoke.XXXXXX")" api_pid="" @@ -15,6 +17,13 @@ compose() { "$@" } +application_compose() { + docker compose \ + --project-name "${application_project_name}" \ + --file "${application_compose_file}" \ + "$@" +} + redact_api_log() { node - "${api_log}" <<'NODE' const fs = require('node:fs'); @@ -28,7 +37,8 @@ const secretNames = [ 'NAKAMA_REFRESH_ENCRYPTION_KEY', 'NAKAMA_CONSOLE_PASSWORD', 'NAKAMA_CONSOLE_SIGNING_KEY', - 'REALTIME_DEV_BEARER_TOKEN', + 'AUTH_TOKEN_SECRET', + 'AUTH_IDENTITY_LOOKUP_KEY', ]; let output = fs.readFileSync(process.argv[2], 'utf8'); @@ -63,6 +73,7 @@ cleanup() { fi compose down --volumes --remove-orphans --rmi local || cleanup_status=$? + application_compose down --volumes --remove-orphans || cleanup_status=$? rm -f "${api_log}" if [[ ${status} -eq 0 && ${cleanup_status} -ne 0 ]]; then @@ -175,16 +186,46 @@ export NAKAMA_CONSOLE_PORT=0 export NAKAMA_METRICS_PORT=0 export NODE_ENV=development -export REALTIME_DEV_PRINCIPAL_ENABLED=true -export REALTIME_DEV_DISCLOSURE_ACKNOWLEDGED=true -export REALTIME_DEV_PLAYER_ID="$(random_uuid_v7)" -export REALTIME_DEV_AUTH_SESSION_ID="$(random_uuid_v7)" -export REALTIME_DEV_BEARER_TOKEN="$(random_hex 32)" +export SMOKE_PLAYER_ID="$(random_uuid_v7)" +export SMOKE_AUTH_SESSION_ID="$(random_uuid_v7)" +export SMOKE_DISCLOSURE_ACK_ID="$(random_uuid_v7)" +export AUTH_TOKEN_SECRET="$(random_base64url 32)" +export AUTH_IDENTITY_LOOKUP_KEY="$(random_base64url 32)" +export HC_POSTGRES_PORT="$(free_port)" +export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${HC_POSTGRES_PORT}/hive_chameleon?sslmode=disable" cd "${repository_root}" echo "Building the NestJS bridge and isolated Nakama runtime..." npm run build --workspace @hive-chameleon/api +application_compose up --detach postgres +application_compose run --rm dbmate +application_compose exec --no-TTY postgres psql \ + -U postgres \ + -d hive_chameleon \ + -v ON_ERROR_STOP=1 \ + -v player_id="${SMOKE_PLAYER_ID}" \ + -v session_id="${SMOKE_AUTH_SESSION_ID}" \ + -v disclosure_ack_id="${SMOKE_DISCLOSURE_ACK_ID}" <<'SQL' +INSERT INTO identity.player (id, hive_username, hive_control_state) +VALUES (:'player_id', 'smoke-user', 'external_self_custodial'); + +INSERT INTO identity.auth_session ( + id, player_id, refresh_token_hash, platform, authentication_method, + hive_signing_provider, hive_control_state_at_issue, custodial_signing_eligible, + issued_at, expires_at +) VALUES ( + :'session_id', :'player_id', repeat('a', 64), 'linux', 'direct_hive_challenge', + 'keychain', 'external_self_custodial', false, now(), now() + interval '1 hour' +); + +INSERT INTO identity.public_record_disclosure_acknowledgment ( + id, disclosure_version, player_id, source, acknowledged_at +) VALUES ( + :'disclosure_ack_id', '2026-07-22', :'player_id', + 'direct_hive_pre_participation', now() +); +SQL compose up --build --detach nakama wait_for_nakama From 69c0e310cd954c04c1e6674f112c03584cc26b20 Mon Sep 17 00:00:00 2001 From: iamEvara Date: Thu, 23 Jul 2026 02:03:30 +0300 Subject: [PATCH 2/2] fix(ci): build API workspace dependencies first --- apps/api/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index ac3dab1..77cc332 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,12 +4,12 @@ "private": true, "description": "Hive Chameleon NestJS product API", "scripts": { - "build": "nest build", + "build": "npm run build --workspace @hive-chameleon/database && npm run build --workspace @hive-chameleon/hive-gateway && nest build", "dev": "nest start --watch", "start": "node dist/main.js", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "npm run build --workspace @hive-chameleon/database && npm run build --workspace @hive-chameleon/hive-gateway && tsc --noEmit" }, "dependencies": { "@heroiclabs/nakama-js": "2.8.0",