From 7043af4cb358ea1b6a41ebc663139969c4ff05c7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Apr 2026 09:00:06 +0200 Subject: [PATCH] feat: support time_zone for user registration context --- ARCHITECTURE.md | 15 +- authsession/README.md | 8 +- authsession/api/public-openapi.yaml | 16 +- authsession/contract_openapi_test.go | 6 + authsession/docs/examples.md | 3 +- authsession/docs/flows.md | 2 +- authsession/gateway_compatibility_test.go | 52 +- .../adapters/userservice/rest_client.go | 31 +- .../adapters/userservice/rest_client_test.go | 53 +- .../adapters/userservice/stub_directory.go | 14 +- .../userservice/stub_directory_test.go | 44 +- .../internal/api/publichttp/e2e_test.go | 31 +- .../internal/api/publichttp/handler.go | 7 + .../internal/api/publichttp/handler_test.go | 30 +- authsession/internal/ports/user_directory.go | 62 +- .../service/blockuser/cross_flow_test.go | 2 + .../confirmemailcode/anti_abuse_test.go | 1 + .../confirmemailcode/consistency_test.go | 4 + .../service/confirmemailcode/service.go | 24 +- .../service/confirmemailcode/service_test.go | 120 ++ .../stub_user_directory_test.go | 2 + .../confirmemailcode/telemetry_test.go | 1 + .../service/sendemailcode/anti_abuse_test.go | 2 +- .../internal/service/shared/normalize.go | 14 + .../internal/service/shared/shared_test.go | 13 + .../internal/testkit/user_directory.go | 14 +- .../internal/testkit/user_directory_test.go | 8 +- .../production_hardening_concurrency_test.go | 36 +- authsession/production_hardening_test.go | 50 +- .../user_service_rest_compatibility_test.go | 60 +- gateway/README.md | 9 +- gateway/docs/examples.md | 3 +- gateway/docs/flows.md | 2 +- .../restapi/public_anti_abuse_test.go | 8 +- gateway/internal/restapi/public_auth.go | 10 + gateway/internal/restapi/public_auth_test.go | 24 +- gateway/openapi.yaml | 14 +- user/PLAN.md | 644 ++++++++ user/README.md | 801 ++++++++++ user/openapi.yaml | 1376 +++++++++++++++++ 40 files changed, 3452 insertions(+), 164 deletions(-) create mode 100644 user/PLAN.md create mode 100644 user/README.md create mode 100644 user/openapi.yaml diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0ec295b..ae3062e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,7 +68,7 @@ The gateway already distinguishes: The public auth contract is: * `send-email-code(email) -> challenge_id` -* `confirm-email-code(challenge_id, code, client_public_key) -> device_session_id` +* `confirm-email-code(challenge_id, code, client_public_key, time_zone) -> device_session_id` The authenticated request contract is based on: @@ -217,6 +217,11 @@ Important architectural rules: * active-session limits are configuration-driven; * `send-email-code` stays success-shaped for existing, new, blocked, and throttled email flows. +When `confirm-email-code` reaches first successful completion for an e-mail +address that does not yet belong to a user, auth may pass create-only +registration context to `User Service` during the synchronous ensure/create +step. + Direct integrations: * synchronous to `User Service` for user resolution/create/block decision; @@ -619,10 +624,10 @@ sequenceDiagram Auth-->>Gateway: challenge_id Gateway-->>Client: challenge_id - Client->>Gateway: POST confirm-email-code - Gateway->>Auth: confirm-email-code - Auth->>Auth: validate challenge/code/public key - Auth->>User: resolve/create/block + Client->>Gateway: POST confirm-email-code(time_zone) + Gateway->>Auth: confirm-email-code(time_zone) + Auth->>Auth: validate challenge/code/public key/time_zone + Auth->>User: resolve/create/block with create-only registration context when needed User-->>Auth: user_id or deny Auth->>Auth: create device_session Auth->>Redis: write gateway session projection diff --git a/authsession/README.md b/authsession/README.md index b36c41a..a4e1319 100644 --- a/authsession/README.md +++ b/authsession/README.md @@ -138,10 +138,14 @@ The effective DTO contract is: | Operation | Request | Success response | | --- | --- | --- | | `POST /api/v1/public/auth/send-email-code` | `{ "email": string }` | `{ "challenge_id": string }` | -| `POST /api/v1/public/auth/confirm-email-code` | `{ "challenge_id": string, "code": string, "client_public_key": string }` | `{ "device_session_id": string }` | +| `POST /api/v1/public/auth/confirm-email-code` | `{ "challenge_id": string, "code": string, "client_public_key": string, "time_zone": string }` | `{ "device_session_id": string }` | `client_public_key` is the standard base64-encoded raw 32-byte Ed25519 public key registered for the created device session. +`time_zone` is the client-selected IANA time zone name. During the current +rollout phase, successful confirms forward create-only user registration +context to `User Service` as `preferred_language="en"` and the supplied +`time_zone` until gateway geoip-based language derivation is deployed. Public boundary rules: @@ -151,6 +155,8 @@ Public boundary rules: `400 invalid_request` - surrounding ASCII and Unicode whitespace is trimmed from input string fields before validation +- `confirm-email-code` requires a non-empty `time_zone` and validates it as an + IANA time zone name - `send-email-code` remains success-shaped for existing, new, blocked, and throttled e-mail paths - `confirm-email-code` returns a ready `device_session_id` synchronously on diff --git a/authsession/api/public-openapi.yaml b/authsession/api/public-openapi.yaml index c3b027b..d8552e0 100644 --- a/authsession/api/public-openapi.yaml +++ b/authsession/api/public-openapi.yaml @@ -71,8 +71,8 @@ paths: description: | Completes a previously issued `challenge_id`, validates the submitted verification code, registers the standard base64-encoded raw 32-byte - Ed25519 `client_public_key`, and returns the created - `device_session_id`. + Ed25519 `client_public_key`, validates the submitted IANA + `time_zone`, and returns the created `device_session_id`. security: [] requestBody: required: true @@ -86,6 +86,7 @@ paths: challenge_id: challenge-123 code: "123456" client_public_key: 11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no= + time_zone: Europe/Kaliningrad responses: "200": description: The device session was created and is ready for use. @@ -137,6 +138,7 @@ components: - challenge_id - code - client_public_key + - time_zone properties: challenge_id: type: string @@ -147,6 +149,9 @@ components: client_public_key: type: string description: Standard base64-encoded raw 32-byte Ed25519 public key registered for the new device session. + time_zone: + type: string + description: Client-selected IANA time zone name forwarded as create-only registration context. ConfirmEmailCodeResponse: type: object additionalProperties: false @@ -201,7 +206,7 @@ components: description: | Request body or field values are invalid. This includes malformed request payloads, invalid confirmation codes, and malformed - `client_public_key` values. + `client_public_key` or `time_zone` values. content: application/json: schema: @@ -222,6 +227,11 @@ components: error: code: invalid_client_public_key message: client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key + invalidTimeZone: + value: + error: + code: invalid_request + message: time_zone must be a valid IANA time zone name ChallengeNotFoundError: description: The referenced challenge does not exist. content: diff --git a/authsession/contract_openapi_test.go b/authsession/contract_openapi_test.go index 2c4296c..530a417 100644 --- a/authsession/contract_openapi_test.go +++ b/authsession/contract_openapi_test.go @@ -118,6 +118,12 @@ func TestPublicOpenAPISpecErrorExamplesMatchStablePublicErrors(t *testing.T) { exampleName: "invalidClientPublicKey", projection: shared.ProjectPublicError(shared.InvalidClientPublicKey()), }, + { + name: "confirm invalid time zone", + responseName: "ConfirmEmailCodeBadRequestError", + exampleName: "invalidTimeZone", + projection: shared.ProjectPublicError(shared.InvalidRequest("time_zone must be a valid IANA time zone name")), + }, { name: "challenge not found", responseName: "ChallengeNotFoundError", diff --git a/authsession/docs/examples.md b/authsession/docs/examples.md index dbaef02..da9960c 100644 --- a/authsession/docs/examples.md +++ b/authsession/docs/examples.md @@ -60,7 +60,8 @@ curl -X POST http://127.0.0.1:8080/api/v1/public/auth/confirm-email-code \ -d '{ "challenge_id": "challenge-123", "code": "123456", - "client_public_key": "11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no=" + "client_public_key": "11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no=", + "time_zone": "Europe/Kaliningrad" }' ``` diff --git a/authsession/docs/flows.md b/authsession/docs/flows.md index 8d0a016..6ee5542 100644 --- a/authsession/docs/flows.md +++ b/authsession/docs/flows.md @@ -40,7 +40,7 @@ sequenceDiagram Client->>Gateway: POST /api/v1/public/auth/confirm-email-code Gateway->>Auth: POST /api/v1/public/auth/confirm-email-code Auth->>Challenge: load and validate challenge - Auth->>User: EnsureUserByEmail(email) + Auth->>User: EnsureUserByEmail(email, registration_context) User-->>Auth: existing / created / blocked Auth->>Config: LoadSessionLimit() Auth->>Session: CountActiveByUserID(user_id) diff --git a/authsession/gateway_compatibility_test.go b/authsession/gateway_compatibility_test.go index e40de8b..7d8aaff 100644 --- a/authsession/gateway_compatibility_test.go +++ b/authsession/gateway_compatibility_test.go @@ -51,12 +51,22 @@ const ( gatewayCompatibilitySessionEventsStream = "gateway:session_events" gatewayCompatibilityStreamMaxLen int64 = 128 - gatewayCompatibilityEmail = "pilot@example.com" - gatewayCompatibilityCode = "123456" + gatewayCompatibilityEmail = "pilot@example.com" + gatewayCompatibilityCode = "123456" + gatewayCompatibilityTimeZone = "Europe/Kaliningrad" ) var gatewayCompatibilityClientPublicKey = mustGatewayCompatibilityClientPublicKeyBase64() +func gatewayCompatibilityConfirmRequest(challengeID string, code string, clientPublicKey string) map[string]string { + return map[string]string{ + "challenge_id": challengeID, + "code": code, + "client_public_key": clientPublicKey, + "time_zone": gatewayCompatibilityTimeZone, + } +} + func TestGatewayCompatibilityConfirmReturnsGatewayReadableSessionProjection(t *testing.T) { t.Parallel() @@ -74,11 +84,11 @@ func TestGatewayCompatibilityConfirmReturnsGatewayReadableSessionProjection(t *t attempts := app.mailSender.RecordedAttempts() require.Len(t, attempts, 1) - confirmResponse := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": sendBody.ChallengeID, - "code": attempts[0].Input.Code, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) + confirmResponse := gatewayCompatibilityPostJSONValue( + t, + app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest(sendBody.ChallengeID, attempts[0].Input.Code, gatewayCompatibilityClientPublicKey), + ) assert.Equal(t, http.StatusOK, confirmResponse.StatusCode) var confirmBody struct { @@ -159,11 +169,7 @@ func TestGatewayCompatibilityRepeatedConfirmReturnsSameSessionID(t *testing.T) { attempts := app.mailSender.RecordedAttempts() require.Len(t, attempts, 1) - requestBody := map[string]string{ - "challenge_id": sendBody.ChallengeID, - "code": attempts[0].Input.Code, - "client_public_key": gatewayCompatibilityClientPublicKey, - } + requestBody := gatewayCompatibilityConfirmRequest(sendBody.ChallengeID, attempts[0].Input.Code, gatewayCompatibilityClientPublicKey) first := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", requestBody) second := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", requestBody) @@ -228,11 +234,11 @@ func TestGatewayCompatibilitySessionLimitExceededReturnsStableClientError(t *tes attempts := app.mailSender.RecordedAttempts() require.Len(t, attempts, 1) - confirmResponse := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": "challenge-1", - "code": attempts[0].Input.Code, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) + confirmResponse := gatewayCompatibilityPostJSONValue( + t, + app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest("challenge-1", attempts[0].Input.Code, gatewayCompatibilityClientPublicKey), + ) assert.Equal(t, http.StatusConflict, confirmResponse.StatusCode) assert.JSONEq(t, `{"error":{"code":"session_limit_exceeded","message":"active session limit would be exceeded"}}`, confirmResponse.Body) } @@ -245,7 +251,7 @@ func TestGatewayCompatibilityMalformedClientPublicKeyReturnsStableError(t *testi response := gatewayCompatibilityPostJSON( t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", - `{"challenge_id":"challenge-123","code":"123456","client_public_key":"invalid"}`, + `{"challenge_id":"challenge-123","code":"123456","client_public_key":"invalid","time_zone":"`+gatewayCompatibilityTimeZone+`"}`, ) assert.Equal(t, http.StatusBadRequest, response.StatusCode) assert.JSONEq(t, `{"error":{"code":"invalid_client_public_key","message":"client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key"}}`, response.Body) @@ -437,11 +443,11 @@ func (h gatewayCompatibilityHarness) createSessionThroughPublicFlow(t *testing.T attempts := h.mailSender.RecordedAttempts() require.Len(t, attempts, 1) - confirmResponse := gatewayCompatibilityPostJSONValue(t, h.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": sendBody.ChallengeID, - "code": attempts[0].Input.Code, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) + confirmResponse := gatewayCompatibilityPostJSONValue( + t, + h.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest(sendBody.ChallengeID, attempts[0].Input.Code, gatewayCompatibilityClientPublicKey), + ) assert.Equal(t, http.StatusOK, confirmResponse.StatusCode) var confirmBody struct { diff --git a/authsession/internal/adapters/userservice/rest_client.go b/authsession/internal/adapters/userservice/rest_client.go index 8777304..c881637 100644 --- a/authsession/internal/adapters/userservice/rest_client.go +++ b/authsession/internal/adapters/userservice/rest_client.go @@ -149,25 +149,42 @@ func (c *RESTClient) ExistsByUserID(ctx context.Context, userID common.UserID) ( return response.Exists, nil } -// EnsureUserByEmail returns an existing user for email, creates a new user -// when registration is allowed, or reports a blocked outcome. -func (c *RESTClient) EnsureUserByEmail(ctx context.Context, email common.Email) (ports.EnsureUserResult, error) { +// EnsureUserByEmail returns an existing user for input.Email, creates a new +// user when registration is allowed, or reports a blocked outcome. +func (c *RESTClient) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) { if err := validateContext(ctx, "ensure user by email"); err != nil { return ports.EnsureUserResult{}, err } - if err := email.Validate(); err != nil { + if err := input.Validate(); err != nil { return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err) } + payload := struct { + Email string `json:"email"` + RegistrationContext *struct { + PreferredLanguage string `json:"preferred_language"` + TimeZone string `json:"time_zone"` + } `json:"registration_context,omitempty"` + }{ + Email: input.Email.String(), + } + if input.RegistrationContext != nil { + payload.RegistrationContext = &struct { + PreferredLanguage string `json:"preferred_language"` + TimeZone string `json:"time_zone"` + }{ + PreferredLanguage: input.RegistrationContext.PreferredLanguage, + TimeZone: input.RegistrationContext.TimeZone, + } + } + var response struct { Outcome ports.EnsureUserOutcome `json:"outcome"` UserID string `json:"user_id,omitempty"` BlockReasonCode userresolution.BlockReasonCode `json:"block_reason_code,omitempty"` } - if err := c.doJSON(ctx, "ensure user by email", http.MethodPost, ensureByEmailPath, map[string]string{ - "email": email.String(), - }, &response, false); err != nil { + if err := c.doJSON(ctx, "ensure user by email", http.MethodPost, ensureByEmailPath, payload, &response, false); err != nil { return ports.EnsureUserResult{}, err } diff --git a/authsession/internal/adapters/userservice/rest_client_test.go b/authsession/internal/adapters/userservice/rest_client_test.go index 01a5c36..01173e3 100644 --- a/authsession/internal/adapters/userservice/rest_client_test.go +++ b/authsession/internal/adapters/userservice/rest_client_test.go @@ -20,6 +20,8 @@ import ( "github.com/stretchr/testify/require" ) +const restClientEnsureTimeZone = "Europe/Kaliningrad" + func TestNewRESTClient(t *testing.T) { t.Parallel() @@ -107,7 +109,13 @@ func TestRESTClientEndpointSuccessCases(t *testing.T) { { name: "ensure user by email", run: func(t *testing.T, client *RESTClient) { - result, err := client.EnsureUserByEmail(context.Background(), common.Email("created@example.com")) + result, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{ + Email: common.Email("created@example.com"), + RegistrationContext: &ports.RegistrationContext{ + PreferredLanguage: "en", + TimeZone: restClientEnsureTimeZone, + }, + }) require.NoError(t, err) assert.Equal(t, ports.EnsureUserResult{ Outcome: ports.EnsureUserOutcomeCreated, @@ -212,7 +220,7 @@ func TestRESTClientEndpointSuccessCases(t *testing.T) { Method: http.MethodPost, Path: ensureByEmailPath, ContentType: "application/json", - Body: `{"email":"created@example.com"}`, + Body: `{"email":"created@example.com","registration_context":{"preferred_language":"en","time_zone":"Europe/Kaliningrad"}}`, }, requests[0]) case "block by user id": assert.Equal(t, capturedRequest{ @@ -331,7 +339,13 @@ func TestRESTClientMutationMethodsDoNotRetry(t *testing.T) { { name: "ensure user by email", run: func(client *RESTClient) error { - _, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com")) + _, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{ + Email: common.Email("pilot@example.com"), + RegistrationContext: &ports.RegistrationContext{ + PreferredLanguage: "en", + TimeZone: restClientEnsureTimeZone, + }, + }) return err }, }, @@ -405,7 +419,13 @@ func TestRESTClientStrictDecodingAndUnexpectedStatuses(t *testing.T) { body: `{"outcome":"mystery"}`, wantErrText: "unsupported", run: func(client *RESTClient) error { - _, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com")) + _, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{ + Email: common.Email("pilot@example.com"), + RegistrationContext: &ports.RegistrationContext{ + PreferredLanguage: "en", + TimeZone: restClientEnsureTimeZone, + }, + }) return err }, }, @@ -415,7 +435,13 @@ func TestRESTClientStrictDecodingAndUnexpectedStatuses(t *testing.T) { body: `{"outcome":"created"}`, wantErrText: "user id", run: func(client *RESTClient) error { - _, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com")) + _, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{ + Email: common.Email("pilot@example.com"), + RegistrationContext: &ports.RegistrationContext{ + PreferredLanguage: "en", + TimeZone: restClientEnsureTimeZone, + }, + }) return err }, }, @@ -516,7 +542,22 @@ func TestRESTClientContextAndValidation(t *testing.T) { { name: "invalid email", run: func() error { - _, err := client.EnsureUserByEmail(context.Background(), common.Email(" bad@example.com ")) + _, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{ + Email: common.Email(" bad@example.com "), + }) + return err + }, + }, + { + name: "invalid registration context", + run: func() error { + _, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{ + Email: common.Email("pilot@example.com"), + RegistrationContext: &ports.RegistrationContext{ + PreferredLanguage: " en ", + TimeZone: restClientEnsureTimeZone, + }, + }) return err }, }, diff --git a/authsession/internal/adapters/userservice/stub_directory.go b/authsession/internal/adapters/userservice/stub_directory.go index 58582ba..02a8ae9 100644 --- a/authsession/internal/adapters/userservice/stub_directory.go +++ b/authsession/internal/adapters/userservice/stub_directory.go @@ -69,13 +69,13 @@ func (d *StubDirectory) ExistsByUserID(ctx context.Context, userID common.UserID return ok, nil } -// EnsureUserByEmail returns an existing user for email, creates a new user -// when registration is allowed, or reports a blocked outcome. -func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, email common.Email) (ports.EnsureUserResult, error) { +// EnsureUserByEmail returns an existing user for input.Email, creates a new +// user when registration is allowed, or reports a blocked outcome. +func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) { if err := validateContext(ctx, "ensure user by email"); err != nil { return ports.EnsureUserResult{}, err } - if err := email.Validate(); err != nil { + if err := input.Validate(); err != nil { return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err) } @@ -84,7 +84,7 @@ func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, email common.Emai d.ensureMapsLocked() - stored, ok := d.byEmail[email] + stored, ok := d.byEmail[input.Email] if ok { if !stored.blockReasonCode.IsZero() { result := ports.EnsureUserResult{ @@ -113,8 +113,8 @@ func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, email common.Emai if err != nil { return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err) } - d.byEmail[email] = entry{userID: userID} - d.emailByUserID[userID] = email + d.byEmail[input.Email] = entry{userID: userID} + d.emailByUserID[userID] = input.Email result := ports.EnsureUserResult{ Outcome: ports.EnsureUserOutcomeCreated, diff --git a/authsession/internal/adapters/userservice/stub_directory_test.go b/authsession/internal/adapters/userservice/stub_directory_test.go index d6916cd..9a9896b 100644 --- a/authsession/internal/adapters/userservice/stub_directory_test.go +++ b/authsession/internal/adapters/userservice/stub_directory_test.go @@ -70,7 +70,13 @@ func TestStubDirectoryEnsureUserByEmail(t *testing.T) { directory := &StubDirectory{} require.NoError(t, directory.SeedExisting(common.Email("existing@example.com"), common.UserID("user-existing"))) - result, err := directory.EnsureUserByEmail(context.Background(), common.Email("existing@example.com")) + result, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{ + Email: common.Email("existing@example.com"), + RegistrationContext: &ports.RegistrationContext{ + PreferredLanguage: "en", + TimeZone: "Europe/Kaliningrad", + }, + }) require.NoError(t, err) assert.Equal(t, ports.EnsureUserOutcomeExisting, result.Outcome) assert.Equal(t, common.UserID("user-existing"), result.UserID) @@ -82,7 +88,13 @@ func TestStubDirectoryEnsureUserByEmail(t *testing.T) { directory := &StubDirectory{} require.NoError(t, directory.SeedBlockedEmail(common.Email("blocked@example.com"), userresolution.BlockReasonCode("policy_block"))) - result, err := directory.EnsureUserByEmail(context.Background(), common.Email("blocked@example.com")) + result, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{ + Email: common.Email("blocked@example.com"), + RegistrationContext: &ports.RegistrationContext{ + PreferredLanguage: "en", + TimeZone: "Europe/Kaliningrad", + }, + }) require.NoError(t, err) assert.Equal(t, ports.EnsureUserOutcomeBlocked, result.Outcome) assert.Equal(t, userresolution.BlockReasonCode("policy_block"), result.BlockReasonCode) @@ -94,12 +106,24 @@ func TestStubDirectoryEnsureUserByEmail(t *testing.T) { directory := &StubDirectory{} require.NoError(t, directory.QueueCreatedUserIDs(common.UserID("user-created"))) - first, err := directory.EnsureUserByEmail(context.Background(), common.Email("created@example.com")) + first, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{ + Email: common.Email("created@example.com"), + RegistrationContext: &ports.RegistrationContext{ + PreferredLanguage: "en", + TimeZone: "Europe/Kaliningrad", + }, + }) require.NoError(t, err) assert.Equal(t, ports.EnsureUserOutcomeCreated, first.Outcome) assert.Equal(t, common.UserID("user-created"), first.UserID) - second, err := directory.EnsureUserByEmail(context.Background(), common.Email("created@example.com")) + second, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{ + Email: common.Email("created@example.com"), + RegistrationContext: &ports.RegistrationContext{ + PreferredLanguage: "fr", + TimeZone: "Europe/Paris", + }, + }) require.NoError(t, err) assert.Equal(t, ports.EnsureUserOutcomeExisting, second.Outcome) assert.Equal(t, common.UserID("user-created"), second.UserID) @@ -110,7 +134,13 @@ func TestStubDirectoryEnsureUserByEmail(t *testing.T) { directory := &StubDirectory{} - result, err := directory.EnsureUserByEmail(context.Background(), common.Email("fallback@example.com")) + result, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{ + Email: common.Email("fallback@example.com"), + RegistrationContext: &ports.RegistrationContext{ + PreferredLanguage: "en", + TimeZone: "Europe/Kaliningrad", + }, + }) require.NoError(t, err) assert.Equal(t, ports.EnsureUserOutcomeCreated, result.Outcome) assert.Equal(t, common.UserID("user-1"), result.UserID) @@ -240,7 +270,9 @@ func TestStubDirectoryContextAndValidation(t *testing.T) { { name: "ensure cancelled context", run: func() error { - _, err := directory.EnsureUserByEmail(cancelledCtx, common.Email("pilot@example.com")) + _, err := directory.EnsureUserByEmail(cancelledCtx, ports.EnsureUserInput{ + Email: common.Email("pilot@example.com"), + }) return err }, want: context.Canceled.Error(), diff --git a/authsession/internal/api/publichttp/e2e_test.go b/authsession/internal/api/publichttp/e2e_test.go index 6b2af4d..2b6b2ad 100644 --- a/authsession/internal/api/publichttp/e2e_test.go +++ b/authsession/internal/api/publichttp/e2e_test.go @@ -46,6 +46,7 @@ func TestPublicHTTPEndToEndSendThenConfirm(t *testing.T) { "challenge_id": "challenge-1", "code": attempts[0].Input.Code, "client_public_key": validClientPublicKey, + "time_zone": publicConfirmTimeZone, } confirmResponse := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", confirmBody) @@ -104,13 +105,36 @@ func TestPublicHTTPEndToEndInvalidClientPublicKey(t *testing.T) { response := postJSON( t, server.URL+"/api/v1/public/auth/confirm-email-code", - `{"challenge_id":"challenge-123","code":"123456","client_public_key":"invalid"}`, + `{"challenge_id":"challenge-123","code":"123456","client_public_key":"invalid","time_zone":"`+publicConfirmTimeZone+`"}`, ) assert.Equal(t, http.StatusBadRequest, response.StatusCode) assert.JSONEq(t, `{"error":{"code":"invalid_client_public_key","message":"client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key"}}`, response.Body) } +func TestPublicHTTPEndToEndInvalidTimeZone(t *testing.T) { + t.Parallel() + + app := newEndToEndApp(t, endToEndOptions{ + SeedChallenge: seedChallengeOptions{ + ID: "challenge-123", + Code: "123456", + Status: challenge.StatusSent, + }, + }) + server := httptest.NewServer(app.handler) + defer server.Close() + + response := postJSON( + t, + server.URL+"/api/v1/public/auth/confirm-email-code", + `{"challenge_id":"challenge-123","code":"123456","client_public_key":"`+validClientPublicKey+`","time_zone":"Mars/Olympus"}`, + ) + + assert.Equal(t, http.StatusBadRequest, response.StatusCode) + assert.JSONEq(t, `{"error":{"code":"invalid_request","message":"time_zone must be a valid IANA time zone name"}}`, response.Body) +} + func TestPublicHTTPEndToEndChallengeNotFound(t *testing.T) { t.Parallel() @@ -122,6 +146,7 @@ func TestPublicHTTPEndToEndChallengeNotFound(t *testing.T) { "challenge_id": "missing", "code": "123456", "client_public_key": validClientPublicKey, + "time_zone": publicConfirmTimeZone, }) assert.Equal(t, http.StatusNotFound, response.StatusCode) @@ -146,6 +171,7 @@ func TestPublicHTTPEndToEndChallengeExpired(t *testing.T) { "challenge_id": "challenge-123", "code": "123456", "client_public_key": validClientPublicKey, + "time_zone": publicConfirmTimeZone, }) assert.Equal(t, http.StatusGone, response.StatusCode) @@ -169,6 +195,7 @@ func TestPublicHTTPEndToEndInvalidCode(t *testing.T) { "challenge_id": "challenge-123", "code": "654321", "client_public_key": validClientPublicKey, + "time_zone": publicConfirmTimeZone, }) assert.Equal(t, http.StatusBadRequest, response.StatusCode) @@ -192,6 +219,7 @@ func TestPublicHTTPEndToEndThrottledChallengeConfirmReturnsInvalidCode(t *testin "challenge_id": "challenge-123", "code": "123456", "client_public_key": validClientPublicKey, + "time_zone": publicConfirmTimeZone, }) assert.Equal(t, http.StatusBadRequest, response.StatusCode) @@ -226,6 +254,7 @@ func TestPublicHTTPEndToEndSessionLimitExceeded(t *testing.T) { "challenge_id": "challenge-1", "code": attempts[0].Input.Code, "client_public_key": validClientPublicKey, + "time_zone": publicConfirmTimeZone, }) assert.Equal(t, http.StatusConflict, confirmResponse.StatusCode) diff --git a/authsession/internal/api/publichttp/handler.go b/authsession/internal/api/publichttp/handler.go index b5af8ab..6e6a4b0 100644 --- a/authsession/internal/api/publichttp/handler.go +++ b/authsession/internal/api/publichttp/handler.go @@ -35,6 +35,7 @@ type confirmEmailCodeRequest struct { ChallengeID string `json:"challenge_id"` Code string `json:"code"` ClientPublicKey string `json:"client_public_key"` + TimeZone string `json:"time_zone"` } type confirmEmailCodeResponse struct { @@ -142,6 +143,7 @@ func handleConfirmEmailCode(useCase ConfirmEmailCodeUseCase, timeout time.Durati ChallengeID: request.ChallengeID, Code: request.Code, ClientPublicKey: request.ClientPublicKey, + TimeZone: request.TimeZone, }) if err != nil { abortWithProjection(c, projectConfirmEmailCodeError(err)) @@ -195,6 +197,11 @@ func validateConfirmEmailCodeRequest(request *confirmEmailCodeRequest) error { return errors.New("client_public_key must not be empty") } + request.TimeZone = strings.TrimSpace(request.TimeZone) + if request.TimeZone == "" { + return errors.New("time_zone must not be empty") + } + return nil } diff --git a/authsession/internal/api/publichttp/handler_test.go b/authsession/internal/api/publichttp/handler_test.go index 756dee2..9ea9953 100644 --- a/authsession/internal/api/publichttp/handler_test.go +++ b/authsession/internal/api/publichttp/handler_test.go @@ -19,6 +19,8 @@ import ( "go.uber.org/zap/zapcore" ) +const publicConfirmTimeZone = "Europe/Kaliningrad" + func TestSendEmailCodeHandlerSuccess(t *testing.T) { t.Parallel() @@ -58,6 +60,7 @@ func TestConfirmEmailCodeHandlerSuccess(t *testing.T) { ChallengeID: "challenge-123", Code: "123456", ClientPublicKey: "public-key-material", + TimeZone: publicConfirmTimeZone, }, input) return confirmemailcode.Result{DeviceSessionID: "device-session-123"}, nil }), @@ -67,7 +70,7 @@ func TestConfirmEmailCodeHandlerSuccess(t *testing.T) { request := httptest.NewRequest( http.MethodPost, "/api/v1/public/auth/confirm-email-code", - bytes.NewBufferString(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material "}`), + bytes.NewBufferString(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material ","time_zone":" `+publicConfirmTimeZone+` "}`), ) request.Header.Set("Content-Type", "application/json") @@ -133,10 +136,17 @@ func TestPublicAuthHandlersRejectInvalidRequests(t *testing.T) { { name: "empty code", target: "/api/v1/public/auth/confirm-email-code", - body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material"}`, + body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_request","message":"code must not be empty"}}`, }, + { + name: "empty time zone", + target: "/api/v1/public/auth/confirm-email-code", + body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":" "}`, + wantStatus: http.StatusBadRequest, + wantBody: `{"error":{"code":"invalid_request","message":"time_zone must not be empty"}}`, + }, } handler := mustNewHandler(t, DefaultConfig(), Dependencies{ @@ -198,7 +208,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) { { name: "confirm invalid client public key", target: "/api/v1/public/auth/confirm-email-code", - body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`, + body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") @@ -213,7 +223,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) { { name: "confirm challenge not found", target: "/api/v1/public/auth/confirm-email-code", - body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`, + body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") @@ -228,7 +238,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) { { name: "confirm challenge expired", target: "/api/v1/public/auth/confirm-email-code", - body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`, + body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") @@ -243,7 +253,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) { { name: "confirm blocked by policy", target: "/api/v1/public/auth/confirm-email-code", - body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`, + body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") @@ -258,7 +268,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) { { name: "confirm session limit exceeded", target: "/api/v1/public/auth/confirm-email-code", - body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`, + body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") @@ -273,7 +283,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) { { name: "confirm hides internal error", target: "/api/v1/public/auth/confirm-email-code", - body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`, + body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") @@ -363,7 +373,7 @@ func TestPublicAuthHandlersRejectInvalidSuccessPayloads(t *testing.T) { { name: "confirm blank device session id", target: "/api/v1/public/auth/confirm-email-code", - body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`, + body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") @@ -413,7 +423,7 @@ func TestPublicAuthLogsDoNotContainSensitiveFields(t *testing.T) { request := httptest.NewRequest( http.MethodPost, "/api/v1/public/auth/confirm-email-code", - bytes.NewBufferString(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`), + bytes.NewBufferString(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"`+publicConfirmTimeZone+`"}`), ) request.Header.Set("Content-Type", "application/json") diff --git a/authsession/internal/ports/user_directory.go b/authsession/internal/ports/user_directory.go index 835a544..56be4f4 100644 --- a/authsession/internal/ports/user_directory.go +++ b/authsession/internal/ports/user_directory.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/domain/userresolution" @@ -23,7 +24,7 @@ type UserDirectory interface { // EnsureUserByEmail returns an existing user for email, creates a new user // when registration is allowed, or reports a blocked outcome when the // address may not continue through confirm flow. - EnsureUserByEmail(ctx context.Context, email common.Email) (EnsureUserResult, error) + EnsureUserByEmail(ctx context.Context, input EnsureUserInput) (EnsureUserResult, error) // BlockByUserID applies a block state to the user identified by // input.UserID. Implementations must wrap ErrNotFound when input.UserID does @@ -35,6 +36,65 @@ type UserDirectory interface { BlockByEmail(ctx context.Context, input BlockUserByEmailInput) (BlockUserResult, error) } +// EnsureUserInput describes one user-directory ensure request keyed by the +// normalized e-mail address. +type EnsureUserInput struct { + // Email identifies the normalized e-mail address that should resolve to an + // existing user, a newly created user, or a blocked outcome. + Email common.Email + + // RegistrationContext carries create-only user initialization fields. The + // user directory must ignore this context for existing users. + RegistrationContext *RegistrationContext +} + +// Validate reports whether EnsureUserInput contains a complete request. +func (i EnsureUserInput) Validate() error { + if err := i.Email.Validate(); err != nil { + return fmt.Errorf("ensure user input email: %w", err) + } + if i.RegistrationContext != nil { + if err := i.RegistrationContext.Validate(); err != nil { + return fmt.Errorf("ensure user input registration context: %w", err) + } + } + + return nil +} + +// RegistrationContext describes create-only user initialization fields +// forwarded from the public confirm-email-code flow. +type RegistrationContext struct { + // PreferredLanguage stores the BCP 47 language tag that should initialize a + // newly created user. During the current rollout phase Auth / Session + // Service sends a temporary `"en"` default until gateway geoip derivation is + // deployed. + PreferredLanguage string + + // TimeZone stores the client-selected IANA time zone name that should + // initialize a newly created user. + TimeZone string +} + +// Validate reports whether RegistrationContext contains complete create-only +// initialization metadata. +func (c RegistrationContext) Validate() error { + if strings.TrimSpace(c.PreferredLanguage) == "" { + return errors.New("preferred language must not be empty") + } + if strings.TrimSpace(c.PreferredLanguage) != c.PreferredLanguage { + return errors.New("preferred language must not contain surrounding whitespace") + } + if strings.TrimSpace(c.TimeZone) == "" { + return errors.New("time zone must not be empty") + } + if strings.TrimSpace(c.TimeZone) != c.TimeZone { + return errors.New("time zone must not contain surrounding whitespace") + } + + return nil +} + // EnsureUserOutcome identifies the coarse outcome of ensuring a user record // for one normalized e-mail address. type EnsureUserOutcome string diff --git a/authsession/internal/service/blockuser/cross_flow_test.go b/authsession/internal/service/blockuser/cross_flow_test.go index c338a3d..1472bec 100644 --- a/authsession/internal/service/blockuser/cross_flow_test.go +++ b/authsession/internal/service/blockuser/cross_flow_test.go @@ -17,6 +17,7 @@ import ( ) const blockFlowPublicKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" +const blockFlowTimeZone = "Europe/Kaliningrad" func TestBlockUserAffectsLaterSendAndConfirmFlows(t *testing.T) { t.Parallel() @@ -81,6 +82,7 @@ func TestBlockUserAffectsLaterSendAndConfirmFlows(t *testing.T) { ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: blockFlowPublicKey, + TimeZone: blockFlowTimeZone, }) require.Error(t, err) assert.Equal(t, shared.ErrorCodeBlockedByPolicy, shared.CodeOf(err)) diff --git a/authsession/internal/service/confirmemailcode/anti_abuse_test.go b/authsession/internal/service/confirmemailcode/anti_abuse_test.go index f235cd6..6ab7c4a 100644 --- a/authsession/internal/service/confirmemailcode/anti_abuse_test.go +++ b/authsession/internal/service/confirmemailcode/anti_abuse_test.go @@ -28,6 +28,7 @@ func TestExecuteReturnsInvalidCodeForThrottledChallengeWithoutConsumingAttempts( ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) require.Error(t, err) assert.Equal(t, shared.ErrorCodeInvalidCode, shared.CodeOf(err)) diff --git a/authsession/internal/service/confirmemailcode/consistency_test.go b/authsession/internal/service/confirmemailcode/consistency_test.go index bb8dd45..8e56b58 100644 --- a/authsession/internal/service/confirmemailcode/consistency_test.go +++ b/authsession/internal/service/confirmemailcode/consistency_test.go @@ -31,6 +31,7 @@ func TestExecuteConfirmsChallengeAfterTransientProjectionPublishFailures(t *test ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) require.NoError(t, err) assert.Equal(t, "device-session-1", result.DeviceSessionID) @@ -57,6 +58,7 @@ func TestExecuteConfirmedRetryRepublishesAfterTransientProjectionPublishFailures ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) require.NoError(t, err) assert.Equal(t, "device-session-1", result.DeviceSessionID) @@ -79,6 +81,7 @@ func TestExecuteRepairsProjectionOnIdenticalRetryAfterExhaustedPublishRetries(t ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) require.Error(t, err) assert.Equal(t, shared.ErrorCodeServiceUnavailable, shared.CodeOf(err)) @@ -99,6 +102,7 @@ func TestExecuteRepairsProjectionOnIdenticalRetryAfterExhaustedPublishRetries(t ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) require.NoError(t, err) assert.Equal(t, "device-session-1", result.DeviceSessionID) diff --git a/authsession/internal/service/confirmemailcode/service.go b/authsession/internal/service/confirmemailcode/service.go index 18b7243..f2b9ad2 100644 --- a/authsession/internal/service/confirmemailcode/service.go +++ b/authsession/internal/service/confirmemailcode/service.go @@ -19,9 +19,10 @@ import ( ) const ( - revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair" - revokeActorTypeService common.RevokeActorType = "service" - revokeActorIDService = "confirmemailcode" + revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair" + revokeActorTypeService common.RevokeActorType = "service" + revokeActorIDService = "confirmemailcode" + defaultPreferredLanguage = "en" ) // Input describes one public confirm-email-code request. @@ -35,6 +36,11 @@ type Input struct { // ClientPublicKey is the base64-encoded raw 32-byte Ed25519 public key that // should be registered for the created device session. ClientPublicKey string + + // TimeZone is the client-selected IANA time zone name that should be + // forwarded as create-only registration context when the user does not yet + // exist. + TimeZone string } // Result describes one public confirm-email-code response. @@ -192,6 +198,10 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err if err != nil { return Result{}, err } + timeZone, err := shared.ParseTimeZone(input.TimeZone) + if err != nil { + return Result{}, err + } for attempt := 0; attempt < shared.MaxCompareAndSwapRetries; attempt++ { current, err := s.challengeStore.Get(ctx, challengeID) @@ -236,7 +246,13 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err return Result{}, shared.InvalidCode() } - ensureUserResult, err := s.userDirectory.EnsureUserByEmail(ctx, current.Email) + ensureUserResult, err := s.userDirectory.EnsureUserByEmail(ctx, ports.EnsureUserInput{ + Email: current.Email, + RegistrationContext: &ports.RegistrationContext{ + PreferredLanguage: defaultPreferredLanguage, + TimeZone: timeZone, + }, + }) if err != nil { return Result{}, shared.ServiceUnavailable(err) } diff --git a/authsession/internal/service/confirmemailcode/service_test.go b/authsession/internal/service/confirmemailcode/service_test.go index 93e3234..7c44436 100644 --- a/authsession/internal/service/confirmemailcode/service_test.go +++ b/authsession/internal/service/confirmemailcode/service_test.go @@ -11,10 +11,13 @@ import ( "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/domain/devicesession" "galaxy/authsession/internal/domain/userresolution" + "galaxy/authsession/internal/ports" "galaxy/authsession/internal/service/shared" "galaxy/authsession/internal/testkit" ) +const confirmEmailCodeTimeZone = "Europe/Kaliningrad" + func TestExecuteConfirmsChallengeForExistingUser(t *testing.T) { t.Parallel() @@ -31,6 +34,7 @@ func TestExecuteConfirmsChallengeForExistingUser(t *testing.T) { ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if err != nil { require.Failf(t, "test failed", "Execute() returned error: %v", err) @@ -75,6 +79,7 @@ func TestExecuteConfirmsChallengeByCreatingUser(t *testing.T) { ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if err != nil { require.Failf(t, "test failed", "Execute() returned error: %v", err) @@ -114,6 +119,7 @@ func TestExecuteConfirmsSuppressedChallenge(t *testing.T) { ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if err != nil { require.Failf(t, "test failed", "Execute() returned error: %v", err) @@ -132,6 +138,7 @@ func TestExecuteReturnsChallengeNotFound(t *testing.T) { ChallengeID: "missing", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if shared.CodeOf(err) != shared.ErrorCodeChallengeNotFound { require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeChallengeNotFound) @@ -152,6 +159,7 @@ func TestExecuteReturnsChallengeExpiredAndMarksExpired(t *testing.T) { ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if shared.CodeOf(err) != shared.ErrorCodeChallengeExpired { require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeChallengeExpired) @@ -194,6 +202,7 @@ func TestExecuteReturnsChallengeExpiredForConfirmedChallengeAfterRetentionWindow ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if shared.CodeOf(err) != shared.ErrorCodeChallengeExpired { require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeChallengeExpired) @@ -220,12 +229,32 @@ func TestExecuteReturnsInvalidClientPublicKey(t *testing.T) { ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: "invalid", + TimeZone: confirmEmailCodeTimeZone, }) if shared.CodeOf(err) != shared.ErrorCodeInvalidClientPublicKey { require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidClientPublicKey) } } +func TestExecuteReturnsInvalidRequestForInvalidTimeZone(t *testing.T) { + t.Parallel() + + service := mustNewConfirmService(t, newConfirmDeps(t)) + + _, err := service.Execute(context.Background(), Input{ + ChallengeID: "challenge-1", + Code: "654321", + ClientPublicKey: publicKeyString(), + TimeZone: "Mars/Olympus", + }) + if shared.CodeOf(err) != shared.ErrorCodeInvalidRequest { + require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidRequest) + } + if err == nil || err.Error() != "time_zone must be a valid IANA time zone name" { + require.Failf(t, "test failed", "Execute() error = %v, want invalid time_zone detail", err) + } +} + func TestExecuteInvalidCodeIncrementsAttempts(t *testing.T) { t.Parallel() @@ -239,6 +268,7 @@ func TestExecuteInvalidCodeIncrementsAttempts(t *testing.T) { ChallengeID: "challenge-1", Code: "000000", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if shared.CodeOf(err) != shared.ErrorCodeInvalidCode { require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode) @@ -268,6 +298,7 @@ func TestExecuteFifthInvalidAttemptMarksChallengeFailed(t *testing.T) { ChallengeID: "challenge-1", Code: "000000", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if shared.CodeOf(err) != shared.ErrorCodeInvalidCode { require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode) @@ -304,6 +335,7 @@ func TestExecuteDoesNotCreateSessionAfterTooManyAttempts(t *testing.T) { ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if shared.CodeOf(err) != shared.ErrorCodeInvalidCode { require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode) @@ -337,6 +369,7 @@ func TestExecuteReturnsSameSessionIDForIdempotentRetryAndRepublishes(t *testing. ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if err != nil { require.Failf(t, "test failed", "Execute() returned error: %v", err) @@ -370,6 +403,7 @@ func TestExecuteReturnsInvalidCodeForDifferentKeyDuringIdempotentRetry(t *testin ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: alternatePublicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if shared.CodeOf(err) != shared.ErrorCodeInvalidCode { require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode) @@ -425,6 +459,7 @@ func TestExecuteReturnsInvalidCodeForNonConfirmableStates(t *testing.T) { ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if shared.CodeOf(err) != shared.ErrorCodeInvalidCode { require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode) @@ -457,6 +492,7 @@ func TestExecuteMarksChallengeFailedAndReturnsBlockedByPolicy(t *testing.T) { ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if shared.CodeOf(err) != shared.ErrorCodeBlockedByPolicy { require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeBlockedByPolicy) @@ -492,6 +528,7 @@ func TestExecuteReturnsSessionLimitExceededWithoutConsumingChallenge(t *testing. ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if shared.CodeOf(err) != shared.ErrorCodeSessionLimitExceeded { require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeSessionLimitExceeded) @@ -509,6 +546,57 @@ func TestExecuteReturnsSessionLimitExceededWithoutConsumingChallenge(t *testing. } } +func TestExecutePassesRegistrationContextToUserDirectory(t *testing.T) { + t.Parallel() + + deps := newConfirmDeps(t) + recordingDirectory := &recordingEnsureUserDirectory{delegate: deps.userDirectory} + deps.userDirectory = nil + + if err := recordingDirectory.delegate.QueueCreatedUserIDs(common.UserID("user-created")); err != nil { + require.Failf(t, "test failed", "QueueCreatedUserIDs() returned error: %v", err) + } + if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "new@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))); err != nil { + require.Failf(t, "test failed", "Create() returned error: %v", err) + } + + service, err := New( + deps.challengeStore, + deps.sessionStore, + recordingDirectory, + deps.configProvider, + deps.publisher, + deps.idGenerator, + deps.hasher, + testkit.FixedClock{Time: deps.now}, + ) + if err != nil { + require.Failf(t, "test failed", "New() returned error: %v", err) + } + + _, err = service.Execute(context.Background(), Input{ + ChallengeID: "challenge-1", + Code: "654321", + ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, + }) + if err != nil { + require.Failf(t, "test failed", "Execute() returned error: %v", err) + } + if recordingDirectory.lastEnsureInput.Email != common.Email("new@example.com") { + require.Failf(t, "test failed", "last ensure email = %q, want %q", recordingDirectory.lastEnsureInput.Email, common.Email("new@example.com")) + } + if recordingDirectory.lastEnsureInput.RegistrationContext == nil { + require.FailNow(t, "last ensure registration context = nil, want value") + } + if recordingDirectory.lastEnsureInput.RegistrationContext.PreferredLanguage != "en" { + require.Failf(t, "test failed", "preferred language = %q, want %q", recordingDirectory.lastEnsureInput.RegistrationContext.PreferredLanguage, "en") + } + if recordingDirectory.lastEnsureInput.RegistrationContext.TimeZone != confirmEmailCodeTimeZone { + require.Failf(t, "test failed", "time zone = %q, want %q", recordingDirectory.lastEnsureInput.RegistrationContext.TimeZone, confirmEmailCodeTimeZone) + } +} + func TestExecuteReturnsServiceUnavailableThenSucceedsIdempotentlyAfterPublishFailure(t *testing.T) { t.Parallel() @@ -526,6 +614,7 @@ func TestExecuteReturnsServiceUnavailableThenSucceedsIdempotentlyAfterPublishFai ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if shared.CodeOf(err) != shared.ErrorCodeServiceUnavailable { require.Failf(t, "test failed", "first Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeServiceUnavailable) @@ -536,6 +625,7 @@ func TestExecuteReturnsServiceUnavailableThenSucceedsIdempotentlyAfterPublishFai ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) if err != nil { require.Failf(t, "test failed", "second Execute() returned error: %v", err) @@ -680,3 +770,33 @@ func publicKeyString() string { func alternatePublicKeyString() string { return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" } + +// recordingEnsureUserDirectory records the last ensure input while delegating +// behavior to the in-memory testkit directory. +type recordingEnsureUserDirectory struct { + delegate *testkit.InMemoryUserDirectory + lastEnsureInput ports.EnsureUserInput +} + +func (d *recordingEnsureUserDirectory) ResolveByEmail(ctx context.Context, email common.Email) (userresolution.Result, error) { + return d.delegate.ResolveByEmail(ctx, email) +} + +func (d *recordingEnsureUserDirectory) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) { + return d.delegate.ExistsByUserID(ctx, userID) +} + +func (d *recordingEnsureUserDirectory) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) { + d.lastEnsureInput = input + return d.delegate.EnsureUserByEmail(ctx, input) +} + +func (d *recordingEnsureUserDirectory) BlockByUserID(ctx context.Context, input ports.BlockUserByIDInput) (ports.BlockUserResult, error) { + return d.delegate.BlockByUserID(ctx, input) +} + +func (d *recordingEnsureUserDirectory) BlockByEmail(ctx context.Context, input ports.BlockUserByEmailInput) (ports.BlockUserResult, error) { + return d.delegate.BlockByEmail(ctx, input) +} + +var _ ports.UserDirectory = (*recordingEnsureUserDirectory)(nil) diff --git a/authsession/internal/service/confirmemailcode/stub_user_directory_test.go b/authsession/internal/service/confirmemailcode/stub_user_directory_test.go index 6289735..87ecd7e 100644 --- a/authsession/internal/service/confirmemailcode/stub_user_directory_test.go +++ b/authsession/internal/service/confirmemailcode/stub_user_directory_test.go @@ -51,6 +51,7 @@ func TestExecuteWithRuntimeStubUserDirectory(t *testing.T) { ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) require.NoError(t, err) assert.Equal(t, "device-session-1", result.DeviceSessionID) @@ -92,6 +93,7 @@ func TestExecuteWithRuntimeStubUserDirectory(t *testing.T) { ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) require.Error(t, err) assert.Equal(t, shared.ErrorCodeBlockedByPolicy, shared.CodeOf(err)) diff --git a/authsession/internal/service/confirmemailcode/telemetry_test.go b/authsession/internal/service/confirmemailcode/telemetry_test.go index 873901d..d94c0c5 100644 --- a/authsession/internal/service/confirmemailcode/telemetry_test.go +++ b/authsession/internal/service/confirmemailcode/telemetry_test.go @@ -44,6 +44,7 @@ func TestExecuteRecordsInvalidCodeMetricForThrottledChallenge(t *testing.T) { ChallengeID: "challenge-1", Code: "654321", ClientPublicKey: publicKeyString(), + TimeZone: confirmEmailCodeTimeZone, }) require.Error(t, err) diff --git a/authsession/internal/service/sendemailcode/anti_abuse_test.go b/authsession/internal/service/sendemailcode/anti_abuse_test.go index 2b2bf46..7832f89 100644 --- a/authsession/internal/service/sendemailcode/anti_abuse_test.go +++ b/authsession/internal/service/sendemailcode/anti_abuse_test.go @@ -154,7 +154,7 @@ func (d *countingUserDirectory) ExistsByUserID(context.Context, common.UserID) ( return false, nil } -func (d *countingUserDirectory) EnsureUserByEmail(context.Context, common.Email) (ports.EnsureUserResult, error) { +func (d *countingUserDirectory) EnsureUserByEmail(context.Context, ports.EnsureUserInput) (ports.EnsureUserResult, error) { return ports.EnsureUserResult{}, nil } diff --git a/authsession/internal/service/shared/normalize.go b/authsession/internal/service/shared/normalize.go index 9a58d8b..decf04b 100644 --- a/authsession/internal/service/shared/normalize.go +++ b/authsession/internal/service/shared/normalize.go @@ -91,6 +91,20 @@ func ParseClientPublicKey(value string) (common.ClientPublicKey, error) { return key, nil } +// ParseTimeZone trims value and validates it as an IANA time zone name. +func ParseTimeZone(value string) (string, error) { + timeZone := NormalizeString(value) + if timeZone == "" { + return "", InvalidRequest("time_zone must not be empty") + } + + if _, err := time.LoadLocation(timeZone); err != nil { + return "", InvalidRequest("time_zone must be a valid IANA time zone name") + } + + return timeZone, nil +} + // ParseRevokeReasonCode trims value and validates it as one machine-readable // revoke reason code. func ParseRevokeReasonCode(value string) (common.RevokeReasonCode, error) { diff --git a/authsession/internal/service/shared/shared_test.go b/authsession/internal/service/shared/shared_test.go index 32b5c9b..08f0c05 100644 --- a/authsession/internal/service/shared/shared_test.go +++ b/authsession/internal/service/shared/shared_test.go @@ -34,6 +34,19 @@ func TestParseClientPublicKey(t *testing.T) { assert.Equal(t, ErrorCodeInvalidClientPublicKey, CodeOf(err)) } +func TestParseTimeZone(t *testing.T) { + t.Parallel() + + timeZone, err := ParseTimeZone(" Europe/Kaliningrad ") + require.NoError(t, err) + assert.Equal(t, "Europe/Kaliningrad", timeZone) + + _, err = ParseTimeZone("Mars/Olympus") + require.Error(t, err) + assert.Equal(t, ErrorCodeInvalidRequest, CodeOf(err)) + assert.Equal(t, "time_zone must be a valid IANA time zone name", err.Error()) +} + func TestToSession(t *testing.T) { t.Parallel() diff --git a/authsession/internal/testkit/user_directory.go b/authsession/internal/testkit/user_directory.go index e5e4f00..5b32f8b 100644 --- a/authsession/internal/testkit/user_directory.go +++ b/authsession/internal/testkit/user_directory.go @@ -63,13 +63,13 @@ func (d *InMemoryUserDirectory) ExistsByUserID(ctx context.Context, userID commo return ok, nil } -// EnsureUserByEmail returns an existing user for email, creates a new user -// when registration is allowed, or reports a blocked outcome. -func (d *InMemoryUserDirectory) EnsureUserByEmail(ctx context.Context, email common.Email) (ports.EnsureUserResult, error) { +// EnsureUserByEmail returns an existing user for input.Email, creates a new +// user when registration is allowed, or reports a blocked outcome. +func (d *InMemoryUserDirectory) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) { if err := ctx.Err(); err != nil { return ports.EnsureUserResult{}, err } - if err := email.Validate(); err != nil { + if err := input.Validate(); err != nil { return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err) } @@ -83,7 +83,7 @@ func (d *InMemoryUserDirectory) EnsureUserByEmail(ctx context.Context, email com d.emailByUserID = make(map[common.UserID]common.Email) } - entry, ok := d.byEmail[email] + entry, ok := d.byEmail[input.Email] if ok { if !entry.BlockReasonCode.IsZero() { result := ports.EnsureUserResult{ @@ -104,8 +104,8 @@ func (d *InMemoryUserDirectory) EnsureUserByEmail(ctx context.Context, email com if err != nil { return ports.EnsureUserResult{}, err } - d.byEmail[email] = userDirectoryEntry{UserID: userID} - d.emailByUserID[userID] = email + d.byEmail[input.Email] = userDirectoryEntry{UserID: userID} + d.emailByUserID[userID] = input.Email result := ports.EnsureUserResult{ Outcome: ports.EnsureUserOutcomeCreated, diff --git a/authsession/internal/testkit/user_directory_test.go b/authsession/internal/testkit/user_directory_test.go index bb620ea..4f99dce 100644 --- a/authsession/internal/testkit/user_directory_test.go +++ b/authsession/internal/testkit/user_directory_test.go @@ -94,7 +94,13 @@ func TestInMemoryUserDirectoryEnsureUserExistingCreatedAndBlocked(t *testing.T) t.Run(tt.name, func(t *testing.T) { t.Parallel() - got, err := directory.EnsureUserByEmail(context.Background(), tt.email) + got, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{ + Email: tt.email, + RegistrationContext: &ports.RegistrationContext{ + PreferredLanguage: "en", + TimeZone: "Europe/Kaliningrad", + }, + }) if err != nil { require.Failf(t, "test failed", "EnsureUserByEmail() returned error: %v", err) } diff --git a/authsession/production_hardening_concurrency_test.go b/authsession/production_hardening_concurrency_test.go index 72f6aef..7152ee3 100644 --- a/authsession/production_hardening_concurrency_test.go +++ b/authsession/production_hardening_concurrency_test.go @@ -138,11 +138,7 @@ func TestProductionHardeningConcurrentIdenticalConfirmsConvergeToOneActiveSessio }) challengeID, code := app.SendChallenge(t, gatewayCompatibilityEmail) - requestBody := map[string]string{ - "challenge_id": challengeID, - "code": code, - "client_public_key": gatewayCompatibilityClientPublicKey, - } + requestBody := gatewayCompatibilityConfirmRequest(challengeID, code, gatewayCompatibilityClientPublicKey) responses := make([]gatewayCompatibilityHTTPResponse, 2) start := make(chan struct{}) @@ -220,11 +216,11 @@ func TestProductionHardeningConcurrentConfirmAndRevokeAllKeepProjectionConsisten challengeID, code := app.SendChallenge(t, gatewayCompatibilityEmail) confirmResponseCh := make(chan gatewayCompatibilityHTTPResponse, 1) go func() { - confirmResponseCh <- gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": challengeID, - "code": code, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) + confirmResponseCh <- gatewayCompatibilityPostJSONValue( + t, + app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest(challengeID, code, gatewayCompatibilityClientPublicKey), + ) }() createdIDs := gate.WaitForCreates(t, 1) @@ -279,11 +275,11 @@ func TestProductionHardeningConcurrentBlockUserAndConfirmDoNotLeakActiveSession( confirmResponseCh := make(chan gatewayCompatibilityHTTPResponse, 1) go func() { - confirmResponseCh <- gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": challengeID, - "code": code, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) + confirmResponseCh <- gatewayCompatibilityPostJSONValue( + t, + app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest(challengeID, code, gatewayCompatibilityClientPublicKey), + ) }() createdIDs := gate.WaitForCreates(t, 1) @@ -324,11 +320,11 @@ func TestProductionHardeningConcurrentBlockUserAndConfirmDoNotLeakActiveSession( assert.NotEmpty(t, sendBody.ChallengeID) assert.Len(t, app.mailSender.RecordedAttempts(), 1) - followupConfirm := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": sendBody.ChallengeID, - "code": gatewayCompatibilityCode, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) + followupConfirm := gatewayCompatibilityPostJSONValue( + t, + app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest(sendBody.ChallengeID, gatewayCompatibilityCode, gatewayCompatibilityClientPublicKey), + ) assert.Equal(t, http.StatusForbidden, followupConfirm.StatusCode) assert.JSONEq(t, `{"error":{"code":"blocked_by_policy","message":"authentication is blocked by policy"}}`, followupConfirm.Body) } diff --git a/authsession/production_hardening_test.go b/authsession/production_hardening_test.go index 35e1546..6355105 100644 --- a/authsession/production_hardening_test.go +++ b/authsession/production_hardening_test.go @@ -426,11 +426,11 @@ func (a *hardeningApp) CreateSessionThroughPublicFlow(t *testing.T) string { t.Helper() challengeID, code := a.SendChallenge(t, gatewayCompatibilityEmail) - response := gatewayCompatibilityPostJSONValue(t, a.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": challengeID, - "code": code, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) + response := gatewayCompatibilityPostJSONValue( + t, + a.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest(challengeID, code, gatewayCompatibilityClientPublicKey), + ) assert.Equal(t, http.StatusOK, response.StatusCode) var body struct { @@ -557,22 +557,22 @@ func TestProductionHardeningConfirmRetryRepairsProjectionAfterProcessRestart(t * }) challengeID, code := failingApp.SendChallenge(t, gatewayCompatibilityEmail) - firstConfirm := gatewayCompatibilityPostJSONValue(t, failingApp.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": challengeID, - "code": code, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) + firstConfirm := gatewayCompatibilityPostJSONValue( + t, + failingApp.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest(challengeID, code, gatewayCompatibilityClientPublicKey), + ) assert.Equal(t, http.StatusServiceUnavailable, firstConfirm.StatusCode) assert.False(t, env.GatewayCacheExists(context.Background(), "device-session-1")) failingApp.Close() healthyApp := newHardeningApp(t, env, hardeningAppOptions{}) - secondConfirm := gatewayCompatibilityPostJSONValue(t, healthyApp.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": challengeID, - "code": code, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) + secondConfirm := gatewayCompatibilityPostJSONValue( + t, + healthyApp.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest(challengeID, code, gatewayCompatibilityClientPublicKey), + ) assert.Equal(t, http.StatusOK, secondConfirm.StatusCode) var body struct { @@ -743,21 +743,21 @@ func TestProductionHardeningExpiredChallengeReturnsExpiredDuringGraceAndNotFound require.NoError(t, record.Validate()) require.NoError(t, app.challengeStore.Create(context.Background(), record)) - firstConfirm := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": "challenge-expired", - "code": gatewayCompatibilityCode, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) + firstConfirm := gatewayCompatibilityPostJSONValue( + t, + app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest("challenge-expired", gatewayCompatibilityCode, gatewayCompatibilityClientPublicKey), + ) assert.Equal(t, http.StatusGone, firstConfirm.StatusCode) assert.JSONEq(t, `{"error":{"code":"challenge_expired","message":"challenge expired"}}`, firstConfirm.Body) env.FastForward(t, 5*time.Minute+time.Second) - secondConfirm := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": "challenge-expired", - "code": gatewayCompatibilityCode, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) + secondConfirm := gatewayCompatibilityPostJSONValue( + t, + app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest("challenge-expired", gatewayCompatibilityCode, gatewayCompatibilityClientPublicKey), + ) assert.Equal(t, http.StatusNotFound, secondConfirm.StatusCode) assert.JSONEq(t, `{"error":{"code":"challenge_not_found","message":"challenge not found"}}`, secondConfirm.Body) } diff --git a/authsession/user_service_rest_compatibility_test.go b/authsession/user_service_rest_compatibility_test.go index d47b44d..6265a34 100644 --- a/authsession/user_service_rest_compatibility_test.go +++ b/authsession/user_service_rest_compatibility_test.go @@ -72,21 +72,21 @@ func TestUserServiceRESTCompatibilityPublicConfirmUsesEnsureOutcomes(t *testing. createdChallengeID := harness.sendChallengeID(t, "created@example.com") blockedChallengeID := harness.sendChallengeID(t, "blocked@example.com") - existing := gatewayCompatibilityPostJSONValue(t, harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": existingChallengeID, - "code": userServiceRESTCompatibilityCode, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) - created := gatewayCompatibilityPostJSONValue(t, harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": createdChallengeID, - "code": userServiceRESTCompatibilityCode, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) - blocked := gatewayCompatibilityPostJSONValue(t, harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ - "challenge_id": blockedChallengeID, - "code": userServiceRESTCompatibilityCode, - "client_public_key": gatewayCompatibilityClientPublicKey, - }) + existing := gatewayCompatibilityPostJSONValue( + t, + harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest(existingChallengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey), + ) + created := gatewayCompatibilityPostJSONValue( + t, + harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest(createdChallengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey), + ) + blocked := gatewayCompatibilityPostJSONValue( + t, + harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", + gatewayCompatibilityConfirmRequest(blockedChallengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey), + ) assert.Equal(t, http.StatusOK, existing.StatusCode) assert.JSONEq(t, `{"device_session_id":"device-session-1"}`, existing.Body) @@ -326,13 +326,39 @@ func newUserServiceStubHandler(directory *userservice.StubDirectory) http.Handle writeUserServiceStubJSON(writer, http.StatusOK, map[string]bool{"exists": exists}) case request.Method == http.MethodPost && request.URL.Path == "/api/v1/internal/users/ensure-by-email": var input struct { - Email string `json:"email"` + Email string `json:"email"` + RegistrationContext *struct { + PreferredLanguage string `json:"preferred_language"` + TimeZone string `json:"time_zone"` + } `json:"registration_context"` } if !decodeUserServiceStubRequest(writer, request, &input) { return } - result, err := directory.EnsureUserByEmail(request.Context(), common.Email(input.Email)) + ensureInput := ports.EnsureUserInput{ + Email: common.Email(input.Email), + } + if input.RegistrationContext != nil { + ensureInput.RegistrationContext = &ports.RegistrationContext{ + PreferredLanguage: input.RegistrationContext.PreferredLanguage, + TimeZone: input.RegistrationContext.TimeZone, + } + } + if ensureInput.RegistrationContext == nil { + writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context must be present")) + return + } + if ensureInput.RegistrationContext.PreferredLanguage != "en" { + writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context.preferred_language must equal en during rollout")) + return + } + if ensureInput.RegistrationContext.TimeZone != gatewayCompatibilityTimeZone { + writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context.time_zone must match public confirm time_zone")) + return + } + + result, err := directory.EnsureUserByEmail(request.Context(), ensureInput) if err != nil { writeUserServiceStubError(writer, http.StatusInternalServerError, err) return diff --git a/gateway/README.md b/gateway/README.md index 414842a..d1473a0 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -109,11 +109,14 @@ The public REST listener read budgets are configured by: The public auth JSON contract uses a challenge-token flow: - `send-email-code` accepts `email` and returns `challenge_id`; -- `confirm-email-code` accepts `challenge_id`, `code`, and - `client_public_key`, then returns `device_session_id`. +- `confirm-email-code` accepts `challenge_id`, `code`, + `client_public_key`, and `time_zone`, then returns + `device_session_id`. `client_public_key` is the standard base64-encoded raw 32-byte Ed25519 public key for the device session being created. +`time_zone` is the client-selected IANA time zone name forwarded unchanged to +`Auth / Session Service`. These routes remain unauthenticated and delegate only through an injected `AuthServiceClient`. @@ -950,7 +953,7 @@ Auth / Session Service. The gateway contract is: - `SendEmailCode(email) -> challenge_id` -- `ConfirmEmailCode(challenge_id, code, client_public_key) -> device_session_id` +- `ConfirmEmailCode(challenge_id, code, client_public_key, time_zone) -> device_session_id` When no concrete implementation is wired, the gateway keeps the public routes available and returns a stable `503 service_unavailable` response instead of diff --git a/gateway/docs/examples.md b/gateway/docs/examples.md index 2dc3fc9..a23fc23 100644 --- a/gateway/docs/examples.md +++ b/gateway/docs/examples.md @@ -39,7 +39,8 @@ curl -X POST http://127.0.0.1:8080/api/v1/public/auth/confirm-email-code \ -d '{ "challenge_id": "challenge-123", "code": "123456", - "client_public_key": "11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no=" + "client_public_key": "11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no=", + "time_zone": "Europe/Kaliningrad" }' ``` diff --git a/gateway/docs/flows.md b/gateway/docs/flows.md index de39e00..40118eb 100644 --- a/gateway/docs/flows.md +++ b/gateway/docs/flows.md @@ -19,7 +19,7 @@ sequenceDiagram Client->>Gateway: POST /api/v1/public/auth/confirm-email-code Gateway->>Limiter: classify + rate-limit + body checks Limiter-->>Gateway: allowed - Gateway->>Auth: ConfirmEmailCode(challenge_id, code, client_public_key) + Gateway->>Auth: ConfirmEmailCode(challenge_id, code, client_public_key, time_zone) Auth-->>Gateway: device_session_id Gateway-->>Client: 200 {device_session_id} ``` diff --git a/gateway/internal/restapi/public_anti_abuse_test.go b/gateway/internal/restapi/public_anti_abuse_test.go index 74f7fef..e36eda0 100644 --- a/gateway/internal/restapi/public_anti_abuse_test.go +++ b/gateway/internal/restapi/public_anti_abuse_test.go @@ -18,7 +18,7 @@ func TestPublicAntiAbuseRejectsOversizedBodies(t *testing.T) { t.Parallel() oversizedJSONBody := `{"email":"` + strings.Repeat("a", 8200) + `@example.com"}` - oversizedConfirmJSONBody := `{"challenge_id":"` + strings.Repeat("c", 8300) + `","code":"123456","client_public_key":"key"}` + oversizedConfirmJSONBody := `{"challenge_id":"` + strings.Repeat("c", 8300) + `","code":"123456","client_public_key":"key","time_zone":"` + confirmEmailCodeTestTimeZone + `"}` tests := []struct { name string @@ -282,9 +282,9 @@ func TestPublicAntiAbuseConfirmEmailIdentityThrottle(t *testing.T) { } handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService}) - first := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`) - second := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`) - third := confirmEmailCodeRequest(`{"challenge_id":"challenge-456","code":"123456","client_public_key":"public-key-material"}`) + first := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`) + second := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`) + third := confirmEmailCodeRequest(`{"challenge_id":"challenge-456","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`) firstResp := httptest.NewRecorder() handler.ServeHTTP(firstResp, first) diff --git a/gateway/internal/restapi/public_auth.go b/gateway/internal/restapi/public_auth.go index ef30632..bb61c0f 100644 --- a/gateway/internal/restapi/public_auth.go +++ b/gateway/internal/restapi/public_auth.go @@ -79,6 +79,11 @@ type ConfirmEmailCodeInput struct { // ClientPublicKey is the standard base64-encoded raw 32-byte Ed25519 public // key that should be registered for the created device session. ClientPublicKey string `json:"client_public_key"` + + // TimeZone is the client-selected IANA time zone name forwarded to the + // Auth / Session Service as registration context for first-time user + // creation. + TimeZone string `json:"time_zone"` } // ConfirmEmailCodeResult describes the public REST and adapter payload @@ -391,6 +396,11 @@ func validateConfirmEmailCodeInput(input *ConfirmEmailCodeInput) error { return errors.New("client_public_key must not be empty") } + input.TimeZone = strings.TrimSpace(input.TimeZone) + if input.TimeZone == "" { + return errors.New("time_zone must not be empty") + } + return nil } diff --git a/gateway/internal/restapi/public_auth_test.go b/gateway/internal/restapi/public_auth_test.go index ae68a43..6854657 100644 --- a/gateway/internal/restapi/public_auth_test.go +++ b/gateway/internal/restapi/public_auth_test.go @@ -16,6 +16,8 @@ import ( "github.com/stretchr/testify/require" ) +const confirmEmailCodeTestTimeZone = "Europe/Kaliningrad" + func TestSendEmailCodeHandlerSuccess(t *testing.T) { t.Parallel() @@ -59,7 +61,7 @@ func TestConfirmEmailCodeHandlerSuccess(t *testing.T) { req := httptest.NewRequest( http.MethodPost, "/api/v1/public/auth/confirm-email-code", - strings.NewReader(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material "}`), + strings.NewReader(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material ","time_zone":" `+confirmEmailCodeTestTimeZone+` "}`), ) req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() @@ -75,6 +77,7 @@ func TestConfirmEmailCodeHandlerSuccess(t *testing.T) { ChallengeID: "challenge-123", Code: "123456", ClientPublicKey: "public-key-material", + TimeZone: confirmEmailCodeTestTimeZone, }, authService.confirmEmailCodeInput) assert.True(t, authService.confirmEmailCodeRouteClassOK) assert.Equal(t, PublicRouteClassPublicAuth, authService.confirmEmailCodeRouteClass) @@ -113,12 +116,21 @@ func TestPublicAuthHandlersRejectInvalidRequests(t *testing.T) { { name: "confirm email empty code", target: "/api/v1/public/auth/confirm-email-code", - body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material"}`, + body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_request","message":"code must not be empty"}}`, wantSendCalls: 0, wantConfirmCalls: 0, }, + { + name: "confirm email empty time zone", + target: "/api/v1/public/auth/confirm-email-code", + body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":" "}`, + wantStatus: http.StatusBadRequest, + wantBody: `{"error":{"code":"invalid_request","message":"time_zone must not be empty"}}`, + wantSendCalls: 0, + wantConfirmCalls: 0, + }, } for _, tt := range tests { @@ -159,7 +171,7 @@ func TestPublicAuthHandlersMapAdapterErrors(t *testing.T) { { name: "auth service projected bad request", target: "/api/v1/public/auth/confirm-email-code", - body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`, + body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`, authClient: &recordingAuthServiceClient{ confirmEmailCodeErr: &AuthServiceError{ StatusCode: http.StatusBadRequest, @@ -187,7 +199,7 @@ func TestPublicAuthHandlersMapAdapterErrors(t *testing.T) { { name: "auth service projected gateway normalizes blank gateway error fields", target: "/api/v1/public/auth/confirm-email-code", - body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`, + body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`, authClient: &recordingAuthServiceClient{ confirmEmailCodeErr: &AuthServiceError{ StatusCode: http.StatusBadGateway, @@ -253,7 +265,7 @@ func TestDefaultAuthServiceReturnsServiceUnavailable(t *testing.T) { name: "confirm email code", method: http.MethodPost, target: "/api/v1/public/auth/confirm-email-code", - body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`, + body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`, wantStatus: http.StatusServiceUnavailable, wantBody: `{"error":{"code":"service_unavailable","message":"auth service is unavailable"}}`, }, @@ -325,7 +337,7 @@ func TestPublicAuthLogsDoNotContainSensitiveFields(t *testing.T) { req := httptest.NewRequest( http.MethodPost, "/api/v1/public/auth/confirm-email-code", - strings.NewReader(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`), + strings.NewReader(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"`+confirmEmailCodeTestTimeZone+`"}`), ) req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml index 3a542f8..980a6ed 100644 --- a/gateway/openapi.yaml +++ b/gateway/openapi.yaml @@ -192,8 +192,10 @@ paths: description: | Completes a previously issued `challenge_id`, sends the verification `code`, and registers the standard base64-encoded raw 32-byte Ed25519 - `client_public_key` for the new device session. The response returns - the created `device_session_id`. + `client_public_key` for the new device session. The caller must also + supply the client-selected IANA `time_zone`, which the gateway forwards + unchanged to the Auth / Session Service. The response returns the + created `device_session_id`. This route is unauthenticated and classified as `public_auth`. Public REST anti-abuse applies a per-IP bucket derived from @@ -221,6 +223,7 @@ paths: challenge_id: challenge-123 code: "123456" client_public_key: base64-encoded-raw-ed25519-public-key + time_zone: Europe/Kaliningrad responses: "200": description: The device session was created by the Auth / Session Service. @@ -296,6 +299,7 @@ components: - challenge_id - code - client_public_key + - time_zone properties: challenge_id: type: string @@ -306,6 +310,12 @@ components: client_public_key: type: string description: Standard base64-encoded raw 32-byte Ed25519 public key registered for the new device session. + time_zone: + type: string + description: | + Client-selected IANA time zone name forwarded to the Auth / + Session Service as registration context for first-time user + creation. ConfirmEmailCodeResponse: type: object additionalProperties: false diff --git a/user/PLAN.md b/user/PLAN.md new file mode 100644 index 0000000..96983bd --- /dev/null +++ b/user/PLAN.md @@ -0,0 +1,644 @@ +# User Service Implementation Plan + +## Planning Principles + +This plan is aligned with the current repository architecture and is written +for an experienced middle-level Go developer implementing an internal trusted +microservice. + +Execution priorities: + +- preserve the frozen auth and geo ownership boundaries +- keep user state authoritative in one service +- prefer explicit command behavior over generic patch behavior +- keep synchronous read paths simple for auth and lobby +- separate current effective state from append-only or historical records where + fast reads matter +- keep the first version storage-agnostic at the domain boundary even if Redis + is the initial backend + +## Stage 01 — Freeze Vocabulary, Contracts, and Cross-Service Ownership + +### Goal + +Remove naming ambiguity and freeze the service boundary before implementation. + +### Tasks + +- Freeze the regular-user-only scope of `User Service`. +- Freeze that system-admin identity is out of scope and belongs to later + `Admin Service`. +- Freeze the self-service vocabulary: + - `GetMyAccount` + - `UpdateMyProfile` + - `UpdateMySettings` +- Freeze that `race_name` replaces `display_name`. +- Freeze the current ownership split for `declared_country`: + - current value in `User Service` + - workflow and history in `Geo Profile Service` +- Freeze the auth-facing internal REST endpoints already reserved by + `Auth / Session Service`. +- Freeze the need for create-only registration context on + `EnsureUserByEmail`. + +### Deliverables + +- service README with stable terminology +- short internal ADR or equivalent note for `declared_country` ownership split +- short internal ADR or equivalent note for regular-user versus admin identity + split + +### Exit Criteria + +- no unresolved naming conflict remains around `race_name`, + `declared_country`, entitlement, sanction, or limit semantics +- no service boundary question remains open for auth, lobby, or geo + +### Targeted Tests + +- none yet beyond documentation review + +## Stage 02 — Define Domain Entities and Redis-Backed Logical State + +### Goal + +Describe the persistent state clearly enough that storage adapters can be built +without revisiting core semantics. + +### Tasks + +- Define logical entities for: + - user account + - race-name reservation + - blocked e-mail subject + - entitlement period record + - current entitlement snapshot + - sanction record + - limit record +- Freeze required fields, timestamps, and identifiers for each entity. +- Decide Redis logical key layout and lookup indexes without leaking them into + the domain layer. +- Freeze deterministic pagination keys for admin listing. +- Define how active/effective evaluation works for sanctions and limits. + +### Deliverables + +- domain entity definitions +- storage design notes for Redis keys and secondary indexes +- active/effective evaluation rules + +### Exit Criteria + +- every required read and mutation can map to a clear logical entity set +- Redis adapters can be implemented directly from the frozen logical model + +### Targeted Tests + +- domain validation tests for required fields +- tests for effective-state evaluation of active versus expired records + +## Stage 03 — Implement Auth-Facing Resolution, Ensure, Existence, and E-Mail Blocking + +### Goal + +Provide the minimum trusted API needed by `Auth / Session Service`. + +### Tasks + +- Implement: + - resolve by e-mail + - ensure by e-mail + - exists by user id + - block by user id + - block by e-mail +- Preserve exact route shapes already reserved by the auth REST client. +- Implement the separate blocked-email-subject model. +- Make `BlockByEmail` idempotent for both existing-user and no-user cases. +- Ensure `ResolveByEmail` and `EnsureUserByEmail` both respect blocked-email + subjects. + +### Deliverables + +- trusted internal REST handlers for auth-facing endpoints +- domain services for resolution and block behavior +- Redis-backed storage for user existence and blocked-email subjects + +### Exit Criteria + +- auth can distinguish `existing`, `creatable`, and `blocked` +- blocked e-mail subjects prevent user creation before a user exists +- `BlockByUserID` and `BlockByEmail` are idempotent + +### Targeted Tests + +- resolve existing/creatable/blocked by e-mail +- ensure existing/created/blocked outcomes +- blocked e-mail subject prevents creation before user record exists +- block by user id on unknown user returns not found +- repeated block calls stay idempotent + +## Stage 04 — Add New-User Creation Context from Auth + +### Goal + +Support first-login user creation with initial settings captured at confirm +time. + +### Tasks + +- Extend `EnsureUserByEmail` contract with create-only registration context: + - `preferred_language` + - `time_zone` +- Validate `preferred_language` as BCP 47. +- Validate `time_zone` as IANA TZ name. +- Generate initial `race_name` in `player-` form during creation. +- Initialize the newly created user with: + - free entitlement + - no active sanctions + - no custom limits +- Ignore registration context for existing users. +- Document required follow-up changes in `gateway` and `authsession`. + +### Deliverables + +- extended ensure-by-email request model +- create-user domain service +- generated-race-name helper + +### Exit Criteria + +- first successful ensure-create path can fully initialize a new user +- existing-user ensure does not overwrite language or time zone + +### Targeted Tests + +- new user created with generated `race_name`, derived `preferred_language`, + and required client `time_zone` +- existing user ensure ignores create-only registration context +- invalid BCP 47 or IANA inputs are rejected on create path + +## Stage 05 — Implement Self-Service Account Read and Split Profile/Settings Mutations + +### Goal + +Expose the minimal authenticated account surface routed by `Edge Gateway`. + +### Tasks + +- Implement `GetMyAccount`. +- Implement `UpdateMyProfile` for `race_name` only. +- Implement `UpdateMySettings` for: + - `preferred_language` + - `time_zone` +- Ensure `GetMyAccount` returns: + - account identity fields + - current entitlement snapshot + - active sanctions + - active effective limits + - read-only `declared_country` +- Reject attempts to mutate `email` or `declared_country` through self-service + flows. +- Enforce `profile_update_block` sanction on both self-service mutations. + +### Deliverables + +- authenticated application services for account read and updates +- gateway-facing handler or adapter contracts for future routing +- DTOs for account aggregate and mutation requests + +### Exit Criteria + +- authenticated users can read current account state in one aggregate +- profile and settings changes are clearly separated +- self-service updates cannot mutate forbidden fields + +### Targeted Tests + +- `GetMyAccount` returns current entitlement, active sanctions, active limits, + and read-only `declared_country` +- `UpdateMyProfile` cannot change email or `declared_country` +- `UpdateMySettings` validates BCP 47 and IANA values +- active `profile_update_block` denies both update flows + +## Stage 06 — Implement race_name Uniqueness Policy Behind a Dedicated Interface + +### Goal + +Keep `race_name` uniqueness strict and replaceable. + +### Tasks + +- Introduce a dedicated race-name policy interface. +- Implement canonicalization for uniqueness checks: + - case-insensitive folding + - confusable anti-fraud normalization +- Add Redis-backed reservation storage for canonicalized keys. +- Preserve original casing for stored and returned `race_name`. +- Ensure rename flow handles reservation swap safely. +- Keep the interface narrow so a future shared name-catalog service can replace + the local implementation. + +### Deliverables + +- race-name policy interface +- local normalization implementation +- reservation adapter and conflict handling + +### Exit Criteria + +- no two users can hold conflicting `race_name` values under the frozen policy +- self-service rename is atomic with respect to uniqueness reservation + +### Targeted Tests + +- uniqueness rejects case-insensitive collisions +- uniqueness rejects common anti-fraud-confusable collisions +- rename releases the old reservation only after the new one is secured +- failed reservation backend causes mutation to fail closed + +## Stage 07 — Implement Entitlement History Plus Materialized Current Snapshot + +### Goal + +Support both auditability and fast synchronous entitlement reads. + +### Tasks + +- Implement period-based entitlement history records. +- Implement a materialized current entitlement snapshot. +- Define the v1 plan catalog: + - `free` + - `paid_monthly` + - `paid_yearly` + - `paid_lifetime` +- Implement explicit trusted entitlement commands: + - grant paid access + - extend paid access + - revoke paid access +- Update current snapshot transactionally with each successful entitlement + mutation. +- Ensure the default new-user path creates the correct free snapshot. + +### Deliverables + +- entitlement domain model +- history store +- current snapshot store +- trusted entitlement command handlers + +### Exit Criteria + +- current effective entitlement is always readable without replaying history +- history and snapshot stay consistent across supported mutation paths + +### Targeted Tests + +- entitlement period mutations update the materialized current snapshot + correctly +- free default is created for new users +- extending or revoking access preserves deterministic current-state behavior + +## Stage 08 — Implement Sanctions and Limit Records with Active/Effective Evaluation + +### Goal + +Support negative policy and quota overrides without scattering policy logic into +consumers. + +### Tasks + +- Implement sanction records with optional expiry. +- Implement limit records with numeric values and optional expiry. +- Freeze v1 sanction catalog: + - `login_block` + - `private_game_create_block` + - `private_game_manage_block` + - `game_join_block` + - `profile_update_block` +- Freeze v1 limit catalog: + - `max_owned_private_games` + - `max_active_private_games` + - `max_pending_public_applications` + - `max_pending_private_join_requests` + - `max_pending_private_invites_sent` + - `max_active_game_memberships` +- Implement active/effective evaluation with current time. +- Implement trusted explicit commands to apply/remove sanctions and set/remove + limits. + +### Deliverables + +- sanction model and store +- limit model and store +- effective-state evaluator +- trusted mutation handlers + +### Exit Criteria + +- active sanctions and active limits can be read consistently from one user + account view +- expired or removed records are not treated as active + +### Targeted Tests + +- active sanctions appear in account reads +- expired sanctions and limits stop affecting effective state +- applying and removing sanctions/limits is idempotent where appropriate + +## Stage 09 — Implement Lobby Eligibility Snapshot API + +### Goal + +Give `Game Lobby` one synchronous read that contains everything it needs for +user-level access decisions. + +### Tasks + +- Design and implement one trusted query by `user_id`. +- Return: + - existence + - current entitlement snapshot + - active lobby-relevant sanctions + - effective lobby-relevant limits + - derived booleans for lobby decisions +- Keep the response read-optimized so lobby does not need multiple dependent + calls back into `User Service`. +- Define deterministic not-found behavior. + +### Deliverables + +- lobby eligibility query endpoint +- response DTO +- mapping from entitlement/sanction/limit state to derived eligibility fields + +### Exit Criteria + +- `Game Lobby` can decide create/join/manage eligibility from one read +- no extra fan-out to other user sub-queries is required + +### Targeted Tests + +- lobby eligibility snapshot reflects paid status, sanctions, and limits +- unknown user returns stable not-found behavior +- derived booleans remain consistent with raw effective state + +## Stage 10 — Implement Geo declared_country Sync Command + +### Goal + +Support the current-country denormalization path owned by `Geo Profile Service`. + +### Tasks + +- Implement one explicit trusted command to sync current `declared_country`. +- Validate ISO alpha-2 input. +- Ensure the command updates only the current value on the user account. +- Do not add country history behavior to `User Service`. +- Preserve explicit not-found behavior for unknown `user_id`. +- Emit the corresponding auxiliary declared-country change event after a + successful commit. + +### Deliverables + +- geo-facing sync endpoint +- application service for country sync +- event publication on successful mutation + +### Exit Criteria + +- geo can synchronize current `declared_country` without introducing hidden + history in `User Service` +- unknown users are rejected deterministically + +### Targeted Tests + +- geo country sync changes only current `declared_country` +- invalid country codes are rejected +- country sync emits the correct auxiliary event after commit + +## Stage 11 — Implement Admin Lookup, Filtered Listing, and Explicit Trusted Mutations + +### Goal + +Provide the operational surface required by future `Admin Service` and manual +operations. + +### Tasks + +- Implement exact reads by: + - `user_id` + - normalized `email` + - exact `race_name` +- Implement paginated listing with richer filters: + - paid/free state + - paid expiry + - current `declared_country` + - sanction code + - limit code + - eligibility markers +- Freeze deterministic ordering for the listing. +- Implement the explicit trusted command surface for: + - entitlement grant/extend/revoke + - sanction apply/remove + - limit set/remove + - declared-country sync +- Preserve audit metadata on every trusted mutation. + +### Deliverables + +- admin/internal read endpoints +- filtered listing endpoint +- explicit trusted mutation endpoints + +### Exit Criteria + +- future `Admin Service` can operate fully through this trusted API without + needing direct storage access +- list filtering and pagination are deterministic + +### Targeted Tests + +- admin listing filters behave deterministically +- exact lookups by `user_id`, email, and `race_name` resolve the correct user +- every trusted mutation preserves actor and reason metadata + +## Stage 12 — Add Per-Domain-Area Async Events and Observability + +### Goal + +Make production behavior observable without treating events as the source of +truth. + +### Tasks + +- Publish per-domain-area events for: + - profile changes + - settings changes + - entitlement changes + - sanction changes + - limit changes + - declared-country changes +- Add structured logs for trusted mutations and critical failures. +- Add metrics for: + - auth-facing resolution outcomes + - user creation outcomes + - race-name reservation conflicts + - entitlement mutation outcomes + - sanction and limit mutation outcomes + - event publication failures +- Add tracing spans on synchronous internal request paths where useful. + +### Deliverables + +- event publisher integration +- structured logging hooks +- metrics and tracing instrumentation + +### Exit Criteria + +- mutation flows are observable in production without ad hoc logging +- event publication failure does not compromise source-of-truth persistence + +### Targeted Tests + +- async event publication failure does not lose source-of-truth state +- event payloads include minimum required metadata +- observability hooks do not change business behavior + +## Stage 13 — Add Contract Tests Against Auth, Lobby, and Geo Expectations + +### Goal + +Verify the service not only in isolation, but against the internal contracts it +must satisfy for other services. + +### Tasks + +- Add compatibility tests against the frozen auth-facing REST contract. +- Add compatibility tests for the future ensure-by-email registration context. +- Add lobby eligibility snapshot contract tests. +- Add geo country-sync contract tests. +- Add account aggregate tests matching gateway-routed user expectations. +- Add tests for deterministic admin listing filters and ordering. + +### Deliverables + +- cross-service contract test suite +- test fixtures for auth/lobby/geo integration expectations + +### Exit Criteria + +- no ambiguity remains about service behavior expected by auth, lobby, or geo +- regressions in reserved internal contract shapes are caught automatically + +### Targeted Tests + +- new user created on first successful confirm with generated `race_name`, + derived `preferred_language`, and required client `time_zone` +- existing user confirm ignores create-only registration context +- blocked e-mail subject prevents user creation before a user record exists +- `GetMyAccount` returns current entitlement, active sanctions, active limits, + and read-only `declared_country` +- lobby eligibility snapshot reflects paid status, sanctions, and limits +- geo country sync changes only current `declared_country` + +## Stage 14 — Add Rollout Notes for Gateway/Auth/OpenAPI Updates and Shared geoip + +### Goal + +Prepare the surrounding platform changes required for the service to work in +its intended end-to-end form. + +### Tasks + +- Document the required `gateway` public `confirm-email-code` addition of + `time_zone`. +- Document the required `authsession` public OpenAPI mirror change. +- Document the required `authsession -> user` ensure contract extension for + create-only registration context. +- Document the required shared `pkg/geoip` package for gateway and geo. +- Document README follow-up updates needed in `gateway` and `geoprofile`. +- Define rollout order so the cross-service contract changes do not land in an + unsafe sequence. + +### Deliverables + +- rollout checklist +- dependency order notes +- cross-repo or cross-module follow-up ticket list + +### Exit Criteria + +- the implementation can be integrated into surrounding services without + rediscovering hidden dependencies +- no required upstream or downstream change is left implicit + +### Targeted Tests + +- documentation review only + +## Recommended First Working Slice + +The smallest useful end-to-end slice is: + +1. Stage 01 +2. Stage 02 +3. Stage 03 +4. Stage 04 + +This slice makes it possible to support auth-driven user creation and blocking +before the rest of the service surface exists. + +## Recommended Second Slice + +The next highest-value slice is: + +1. Stage 05 +2. Stage 06 +3. Stage 07 +4. Stage 08 +5. Stage 09 + +This slice gives the platform usable account reads, self-service profile and +settings updates, and the lobby eligibility integration. + +## Final Acceptance Criteria + +The first production-capable v1 of `User Service` should satisfy all of the +following: + +- new users can be created through auth with generated `race_name`, derived + `preferred_language`, and required client `time_zone` +- existing-user auth confirm ignores create-only registration context +- blocked e-mail subjects prevent new-user creation before a user record exists +- `race_name` uniqueness rejects case-insensitive and anti-fraud-confusable + collisions +- `GetMyAccount` returns current entitlement, active sanctions, active limits, + and read-only `declared_country` +- `UpdateMyProfile` cannot change email or `declared_country` +- `UpdateMySettings` validates BCP 47 and IANA values +- entitlement period mutations update the materialized current snapshot + correctly +- lobby eligibility snapshot reflects paid status, sanctions, and limits +- geo `declared_country` sync changes only current account state +- admin listing filters and ordering are deterministic +- async event publication failure does not lose source-of-truth state + +## Implementation Order Summary + +Recommended implementation order: + +1. freeze vocabulary and ownership +2. define domain entities and logical storage +3. build auth-facing resolution and blocking +4. add new-user creation context +5. build self-service account read and updates +6. add race-name uniqueness policy +7. build entitlement history and current snapshot +8. build sanctions and limits +9. add lobby eligibility snapshot +10. add geo country sync +11. add admin reads, listing, and mutations +12. add events and observability +13. add cross-service contract tests +14. document and sequence rollout dependencies diff --git a/user/README.md b/user/README.md new file mode 100644 index 0000000..b0fa087 --- /dev/null +++ b/user/README.md @@ -0,0 +1,801 @@ +# User Service + +## Context and Purpose + +`User Service` is the internal source of truth for regular Galaxy Plus platform +users. + +The service exists to solve six closely related problems: + +- Own the durable platform user account identified by `user_id`. +- Store the current editable self-service profile and settings of a user. +- Materialize the current effective entitlement state used by the rest of the + platform. +- Store user-specific sanctions and limit overrides that affect access + decisions. +- Expose synchronous trusted APIs needed by `Auth / Session Service`, + `Game Lobby`, `Geo Profile Service`, and future administrative tooling. +- Publish auxiliary user-domain change events without turning events into the + source of truth. + +The service is intentionally the owner of regular user identity only. +System-administrator identity is outside this service and belongs to the later +`Admin Service` architecture. + +## Explicit Non-Goals + +The following are intentionally out of scope for this service: + +- Authentication challenges, device sessions, or request-signing state. +- System-administrator identity or administrator role management. +- Ownership of game membership, invites, roster, or per-game moderation. +- Automatic billing computation or payment-provider integration in v1. +- History of `declared_country` changes. +- Geo-IP lookup or country-review workflow logic. +- Direct public unauthenticated exposure. +- Using async events as the authoritative representation of user state. + +## Place in the Existing Microservice System + +`User Service` operates inside the trusted internal platform and integrates +with: + +- `Edge Gateway` +- `Auth / Session Service` +- `Game Lobby` +- `Geo Profile Service` +- `Admin Service` later +- `Billing Service` later +- internal event bus + +`Edge Gateway` routes authenticated user-facing account operations to this +service. + +`Auth / Session Service` uses this service to resolve, create, and block users +during the public e-mail-code login flow. + +`Game Lobby` uses this service for synchronous eligibility checks that depend +on current entitlement, sanctions, and limit state. + +`Geo Profile Service` remains the owner of country-change workflow and history, +but synchronizes the latest effective `declared_country` into this service. + +`Admin Service` will later use the trusted internal read and mutation APIs +defined here. Administrator accounts themselves still do not belong to +`User Service`. + +`Billing Service` is future-only in v1 and will later feed entitlement +outcomes through the trusted entitlement mutation path defined here. + +The event bus is an auxiliary propagation channel and not the source of truth. + +## Responsibility Boundaries + +`User Service` owns: + +- regular platform `user_id` +- login/contact e-mail stored on the user account +- `race_name` +- `preferred_language` +- `time_zone` +- current effective `declared_country` +- current effective entitlement snapshot +- entitlement history records +- active and historical user sanctions +- active and historical user-specific limit overrides +- blocked e-mail subjects that may exist before any user record exists +- synchronous trusted reads used for auth, lobby, geo, and admin workflows +- auxiliary user-domain events + +`User Service` does not own: + +- system-admin accounts +- `device_session` or revoke state +- full payment history +- game ownership, game membership, or game-level bans +- `declared_country` history or approval workflow +- per-request country observations + +## High-Level Architecture + +```mermaid +flowchart LR + Gateway[Edge Gateway] + Auth[Auth / Session Service] + Lobby[Game Lobby] + Geo[Geo Profile Service] + Admin[Admin Service] + Billing[Billing Service] + User[User Service] + Redis[Redis] + Bus[Event Bus] + + Gateway --> User + Auth --> User + Lobby --> User + Geo --> User + Admin --> User + Billing --> User + User --> Redis + User --> Bus +``` + +## Semantic Model + +The service works with several core concepts. + +### User Account + +The user account is the canonical regular-user aggregate. + +Required logical fields: + +- `user_id` +- normalized `email` +- `race_name` +- `preferred_language` +- `time_zone` +- current effective `declared_country` +- creation timestamp +- last update timestamp + +Important rules: + +- `email` is the primary login/contact identifier for end users. +- `email` is not directly editable through self-service profile updates. +- future e-mail change is a separate confirm-based workflow and is not part of + v1 `User Service` mutations. +- `declared_country` is readable in account views but writable only through the + trusted geo sync path. + +### race_name + +`race_name` is the user-facing account name. + +Properties: + +- It is globally unique. +- It is not an identity key. Internal identity remains `user_id`, and end-user + login identity remains `email`. +- It is stored and returned in the original user-provided casing. +- Uniqueness is enforced through a dedicated policy boundary rather than by + naive string equality. + +The uniqueness policy must at minimum: + +- compare case-insensitively +- reject common confusable substitutions used for impersonation, such as + `I` versus `1`, `O` versus `0`, and `B` versus `8` +- remain replaceable behind a dedicated interface because a future shared name + catalog service is expected + +### preferred_language and time_zone + +`preferred_language` and `time_zone` are explicit user settings, not inferred +runtime facts. + +Properties: + +- `preferred_language` uses BCP 47 language tags. +- `time_zone` uses IANA time zone names. +- both values exist on every created user in v1 +- both values are editable later through self-service settings mutation + +Initial creation rules: + +- `preferred_language` is supplied to `User Service` through auth create-only + registration context +- the value is derived by `Edge Gateway` from local geoip country plus local + country-to-language mapping +- when that lookup cannot determine a language, gateway falls back to `en` +- `time_zone` is supplied by the client in public `confirm-email-code` + +`User Service` does not perform its own geo lookup for this purpose. + +### declared_country + +`declared_country` is the latest effective user-declared country. + +Properties: + +- It uses ISO 3166-1 alpha-2. +- It is the read-optimized current value only. +- It is owned for storage by `User Service`. +- It is owned for mutation workflow and history by `Geo Profile Service`. + +This split is intentional: + +- reads of current account state go to `User Service` +- reads of review workflow and country history go to `Geo Profile Service` + +### Entitlement + +Entitlement describes the paid/free access state of a user account. + +The plan catalog fixed for v1 is: + +- `free` +- `paid_monthly` +- `paid_yearly` +- `paid_lifetime` + +The service stores both: + +- immutable or append-only entitlement period history records +- a materialized current effective entitlement snapshot for synchronous reads + +The current effective snapshot is not computed on every request. It is updated +when trusted entitlement mutations succeed. + +Period history records store: + +- `user_id` +- `plan_code` +- `source` +- actor identity or actor type +- `reason_code` +- `starts_at` +- optional `ends_at` +- creation timestamp + +Current effective snapshot stores at minimum: + +- `user_id` +- current `plan_code` +- effective paid/free state +- effective period bounds when applicable +- source metadata needed by operations and admin reads +- last recomputation timestamp + +In v1, entitlement mutations come from explicit trusted admin/internal +commands. Later, `Billing Service` uses the same mutation path. + +### Sanctions + +Sanctions are negative policy records that may deny or restrict access +regardless of entitlement state. + +The initial sanction set for v1 is: + +- `login_block` +- `private_game_create_block` +- `private_game_manage_block` +- `game_join_block` +- `profile_update_block` + +Each sanction record stores: + +- `user_id` +- `sanction_code` +- scope +- `reason_code` +- actor identity or actor type +- `applied_at` +- optional `expires_at` +- optional removal metadata if later removed + +Sanctions are typed records rather than inline booleans so the service can keep +auditability and deterministic active-state evaluation. + +### User-Specific Limits + +User-specific limits are count-based override records that shape access and +eligibility decisions. + +The initial limit set for v1 is: + +- `max_owned_private_games` +- `max_active_private_games` +- `max_pending_public_applications` +- `max_pending_private_join_requests` +- `max_pending_private_invites_sent` +- `max_active_game_memberships` + +Each limit record stores: + +- `user_id` +- `limit_code` +- numeric value +- `reason_code` +- actor identity or actor type +- `applied_at` +- optional `expires_at` +- optional removal metadata if later removed + +Limit rules: + +- limits are count-based only in v1 +- limits are user-specific overrides, not the global default catalog itself +- effective eligibility combines entitlement-derived defaults with active + user-specific overrides + +### Blocked E-Mail Subject + +`User Service` must support blocking an e-mail subject before any user account +exists. + +This requires a separate blocked-email-subject model. + +Required logical fields: + +- normalized `email` +- `reason_code` +- block timestamp +- optional actor metadata when available +- optional expiry or removal metadata if policy later requires it +- optional resolved `user_id` when the e-mail already belongs to an existing + user + +This model exists to support `Auth / Session Service` flows such as +`BlockByEmail` and `ResolveByEmail` before user creation. + +## Data Ownership Rules + +The ownership split is intentional and must remain stable. + +- `User Service` owns regular user identity and current effective account + state. +- `Auth / Session Service` owns login challenge and session lifecycle state. +- `Game Lobby` owns game membership and game-specific moderation. +- `Geo Profile Service` owns geo workflow and `declared_country` history. +- `Admin Service` later owns administrator identity and UI orchestration. + +In particular: + +- no service other than `Geo Profile Service` should mutate + `declared_country` +- no service other than `User Service` should create or edit regular user + profile/settings records +- no other service should maintain its own source of truth for current + entitlements + +## User-Facing Interface Model + +User-facing traffic reaches `User Service` only through authenticated gateway +routing. + +The v1 aggregate query is: + +- `GetMyAccount` + +The v1 self-service mutations are: + +- `UpdateMyProfile` +- `UpdateMySettings` + +### GetMyAccount + +`GetMyAccount` returns one read-optimized account aggregate for the currently +authenticated regular user. + +The aggregate should include at minimum: + +- `user_id` +- `email` +- `race_name` +- `preferred_language` +- `time_zone` +- current `declared_country` +- current entitlement snapshot +- active sanctions +- active effective limits +- account timestamps needed by clients + +`declared_country` is read-only in this aggregate. + +### UpdateMyProfile + +`UpdateMyProfile` updates self-service profile fields only. + +Editable fields in v1: + +- `race_name` + +Rules: + +- e-mail cannot be changed here +- `declared_country` cannot be changed here +- `race_name` must pass global uniqueness policy before commit +- active `profile_update_block` sanction rejects the mutation + +### UpdateMySettings + +`UpdateMySettings` updates self-service settings only. + +Editable fields in v1: + +- `preferred_language` +- `time_zone` + +Rules: + +- values are validated as BCP 47 and IANA formats +- active `profile_update_block` sanction rejects the mutation + +## Trusted Internal API Model + +All service-to-service integration in v1 is documented as trusted JSON REST. + +### Auth-Facing Contract + +The auth-facing contract is already reserved by `Auth / Session Service` and +must remain stable. + +Frozen endpoints: + +- `POST /api/v1/internal/user-resolutions/by-email` +- `GET /api/v1/internal/users/{user_id}/exists` +- `POST /api/v1/internal/users/ensure-by-email` +- `POST /api/v1/internal/users/{user_id}/block` +- `POST /api/v1/internal/user-blocks/by-email` + +Auth-facing behavior: + +- resolve by e-mail returns `existing`, `creatable`, or `blocked` +- ensure by e-mail returns `existing`, `created`, or `blocked` +- block by user id and block by e-mail are idempotent +- blocked e-mail subjects are respected even when no user exists yet + +`EnsureUserByEmail` is extended for v1 user creation context. + +Recommended request shape: + +```json +{ + "email": "pilot@example.com", + "registration_context": { + "preferred_language": "en", + "time_zone": "Europe/Berlin" + } +} +``` + +Rules for `registration_context`: + +- it is used only when the user is created +- it is ignored for an existing user +- it must not overwrite settings of an existing user +- it is required by the future auth contract because first successful confirm + may create the user + +### Lobby-Facing Eligibility Snapshot + +`Game Lobby` needs one synchronous query by `user_id`. + +Purpose: + +- determine whether the user currently may create or join game flows +- obtain effective quotas relevant to lobby decisions + +The response should include at minimum: + +- whether the user exists +- current entitlement snapshot +- active sanctions relevant to lobby actions +- effective limit values relevant to lobby actions +- derived booleans such as whether private-game creation is currently allowed + +This query is intentionally one read-optimized snapshot rather than multiple +smaller cross-service round trips. + +### Geo-Facing Declared Country Sync + +`Geo Profile Service` needs one explicit trusted command to synchronize the +current effective `declared_country`. + +Required behavior: + +- update only the current `declared_country` value in `User Service` +- not create or manage country history here +- fail explicitly on unknown `user_id` +- remain synchronous so geo workflow can decide whether its own version record + becomes effective + +### Admin/Internal Reads + +The trusted admin/internal read surface must support: + +- exact lookup by `user_id` +- exact lookup by normalized `email` +- exact lookup by exact `race_name` +- paginated listing with filters + +The v1 listing filters must support at minimum: + +- paid/free state +- paid expiry window +- current `declared_country` +- active sanction code +- active limit code +- relevant eligibility markers + +Listing must use deterministic pagination and stable ordering. +Recommended default ordering is newest first by `created_at`, with `user_id` +used as the deterministic tiebreaker. + +### Admin/Internal Mutations + +Trusted mutations remain explicit-command based. + +The minimum command vocabulary in v1 is: + +- grant paid access +- extend paid access +- revoke paid access +- apply sanction +- remove sanction +- set limit +- remove limit +- sync declared country + +These are intentionally explicit commands rather than one generic patch API. +The service should preserve reason and actor metadata on every trusted +administrative mutation. + +## New User Creation Flow + +```mermaid +sequenceDiagram + participant Client + participant Gateway + participant Auth as Auth / Session Service + participant User as User Service + + Client->>Gateway: confirm-email-code(code, client_public_key, time_zone) + Gateway->>Gateway: local geoip lookup and country-to-language mapping + Gateway->>Auth: confirm-email-code(..., time_zone) + Auth->>User: ensure-by-email(email, registration_context) + alt user already exists + User-->>Auth: existing user_id + else new user + User->>User: create user with generated race_name + User->>User: initialize language, time zone, free entitlement + User-->>Auth: created user_id + end + Auth-->>Gateway: device_session_id + Gateway-->>Client: device_session_id +``` + +New-user defaults: + +- generated `race_name` in `player-` form +- `preferred_language` from gateway-derived registration context +- `time_zone` from client-provided registration context +- `free` entitlement +- no active sanctions +- no custom limit overrides + +## Interface Between Entitlement, Sanction, and Limit Evaluation + +The service must keep these three layers separate. + +- entitlement provides the base paid/free access state +- sanctions can deny actions regardless of entitlement +- user-specific limits can narrow or override numeric quotas + +This means: + +- a paid user may still be denied private-game creation by sanction +- a non-blocked user may still be quota-limited by effective count limits +- lobby checks should consume one effective snapshot rather than reimplementing + this evaluation itself + +## Events + +Events are auxiliary notifications only. They are not the source of truth. + +The service should emit per-domain-area events for: + +- profile changes +- settings changes +- entitlement changes +- sanction changes +- limit changes +- declared-country changes + +Recommended event classes: + +- `user.profile.changed` +- `user.settings.changed` +- `user.entitlement.changed` +- `user.sanction.changed` +- `user.limit.changed` +- `user.declared_country.changed` + +Each event should include at minimum: + +- `user_id` +- event timestamp +- mutation source +- correlation or request metadata when available +- enough event-specific detail to identify the changed domain area + +Loss of an event must not lose the authoritative business state. + +## Data Entities + +This section defines the core logical entities. These are domain entities, not +mandatory final physical Redis key names. + +### User Account Record + +Required logical fields: + +- `user_id` +- normalized `email` +- `race_name` +- `preferred_language` +- `time_zone` +- current `declared_country` +- `created_at` +- `updated_at` + +### race_name Reservation + +Required logical fields: + +- canonical uniqueness key produced by the race-name policy +- referenced `user_id` +- original stored `race_name` +- reservation timestamp + +This entity exists so uniqueness policy stays replaceable and explicit. + +### Blocked E-Mail Subject Entity + +Required logical fields: + +- normalized `email` +- `reason_code` +- block status +- `blocked_at` +- optional resolved `user_id` + +### Entitlement Period Record + +Required logical fields: + +- `user_id` +- `plan_code` +- `source` +- actor metadata +- `reason_code` +- `starts_at` +- optional `ends_at` +- record creation timestamp + +### Current Entitlement Snapshot + +Required logical fields: + +- `user_id` +- effective `plan_code` +- paid/free state +- effective period bounds +- source metadata +- snapshot update timestamp + +### Sanction Record + +Required logical fields: + +- `user_id` +- `sanction_code` +- scope +- `reason_code` +- actor metadata +- `applied_at` +- optional `expires_at` +- current status metadata + +### Limit Record + +Required logical fields: + +- `user_id` +- `limit_code` +- numeric value +- `reason_code` +- actor metadata +- `applied_at` +- optional `expires_at` +- current status metadata + +## Failure and Degradation Model + +The service is synchronous for critical reads and mutations. + +### Auth Dependency Failure + +If `User Service` is unavailable during auth flows: + +- `Auth / Session Service` must fail the affected operation explicitly +- no user should be created partially without source-of-truth persistence + +### Event Publication Failure + +If event publication fails: + +- the source-of-truth mutation still remains committed +- failure is logged and metered +- downstream consumers recover from direct reads if needed + +### race_name Uniqueness Backend Failure + +If the dedicated race-name uniqueness policy backend fails: + +- self-service profile update must fail closed +- new-user creation must fail explicitly rather than create ambiguous names + +### Geo Sync Failure + +If `Geo Profile Service` cannot synchronize `declared_country` into +`User Service`: + +- geo must treat the change as not yet effective +- `User Service` must not create hidden partial country history + +## Minimal Initial API Surface + +The minimum useful v1 API surface is: + +- gateway-routed authenticated: + - `GetMyAccount` + - `UpdateMyProfile` + - `UpdateMySettings` +- trusted internal auth: + - resolve by e-mail + - ensure by e-mail + - exists by user id + - block by user id + - block by e-mail +- trusted internal lobby: + - get user eligibility snapshot +- trusted internal geo: + - sync current `declared_country` +- trusted internal admin: + - exact user reads + - filtered user listing + - entitlement mutations + - sanction mutations + - limit mutations + +## Cross-Service Follow-Up Dependencies + +The service design here depends on later follow-up work in other modules. + +Required follow-up items: + +- `Edge Gateway` public `confirm-email-code` contract must add required + `time_zone`. +- `Auth / Session Service` public OpenAPI must mirror the same `time_zone` + addition. +- `Auth / Session Service -> User Service` ensure-by-email contract must add + create-only registration context with `preferred_language` and `time_zone`. +- a shared `pkg/geoip` package must be introduced for `Edge Gateway` and + `Geo Profile Service` +- `gateway/README.md` should later document the local geoip dependency used for + initial language derivation +- `geoprofile/README.md` should later document the shared `pkg/geoip` + dependency explicitly alongside its own local geo lookup + +## Design Trade-Offs Accepted by This Architecture + +- Current entitlement is materialized for fast reads instead of computed from + history on each request. +- User-specific limits are count-based only in v1 to keep evaluation simple. +- `race_name` uniqueness is stricter than plain case-insensitive comparison to + reduce impersonation risk. +- `User Service` stores only the latest effective `declared_country` while geo + owns the workflow and version history. +- Explicit trusted commands are preferred over generic patch semantics so + administrative changes remain auditable and predictable. + +## Implementation Readiness Statement + +This service specification is intended to be implementation-ready for a first +production-capable internal version. + +The main remaining work is not product ambiguity inside `User Service`, but +follow-up cross-service contract changes in `gateway`, `authsession`, and the +future shared `pkg/geoip` package. diff --git a/user/openapi.yaml b/user/openapi.yaml new file mode 100644 index 0000000..4f23e04 --- /dev/null +++ b/user/openapi.yaml @@ -0,0 +1,1376 @@ +openapi: 3.0.3 +info: + title: Galaxy User Service Internal REST API + version: v1 + description: | + This specification documents the planned trusted internal REST contract of + `galaxy/user`. + + Scope: + - auth-facing user resolution, ensure, existence, and subject blocking + - gateway-facing authenticated account reads and self-service mutations + - lobby-facing eligibility snapshots + - geo-facing declared-country synchronization + - admin/internal reads, filtered listing, and explicit mutation commands + + This specification is internal REST only. It intentionally does not + describe public edge transport, gateway gRPC, or any future asynchronous + event payloads. +servers: + - url: http://localhost:8091 + description: Example local internal listener for User Service. +tags: + - name: AuthIntegration + description: Trusted auth-facing user ownership and block-policy endpoints. + - name: MyAccount + description: Gateway-facing authenticated account queries and self-service mutations. + - name: LobbyIntegration + description: Trusted lobby-facing synchronous eligibility reads. + - name: GeoIntegration + description: Trusted geo-facing declared-country synchronization. + - name: AdminUsers + description: Trusted administrative lookup, listing, and explicit mutation commands. +paths: + /api/v1/internal/user-resolutions/by-email: + post: + tags: + - AuthIntegration + operationId: resolveUserByEmail + summary: Resolve one e-mail address without creating a user + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserResolutionByEmailRequest" + responses: + "200": + description: Current coarse user-resolution state for the e-mail subject. + content: + application/json: + schema: + $ref: "#/components/schemas/UserResolutionByEmailResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/exists: + get: + tags: + - AuthIntegration + operationId: userExistsByID + summary: Check whether a stable user identifier exists + parameters: + - $ref: "#/components/parameters/UserIDPath" + responses: + "200": + description: Existence check result for the supplied `user_id`. + content: + application/json: + schema: + $ref: "#/components/schemas/UserExistsResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/ensure-by-email: + post: + tags: + - AuthIntegration + operationId: ensureUserByEmail + summary: Resolve, create, or block one e-mail subject + description: | + Returns an existing user for `email`, creates a new regular platform + user when registration is allowed, or returns a blocked outcome when + policy denies the flow. + + `registration_context` is create-only. Implementations must ignore it + for existing users and must not overwrite settings of an already + existing account. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EnsureByEmailRequest" + responses: + "200": + description: Ensure-user outcome for the supplied `email`. + content: + application/json: + schema: + $ref: "#/components/schemas/EnsureByEmailResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/block: + post: + tags: + - AuthIntegration + operationId: blockUserByID + summary: Block one user by stable user identifier + parameters: + - $ref: "#/components/parameters/UserIDPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BlockUserByIDRequest" + responses: + "200": + description: The block mutation applied or the subject was already blocked. + content: + application/json: + schema: + $ref: "#/components/schemas/BlockMutationResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/user-blocks/by-email: + post: + tags: + - AuthIntegration + operationId: blockUserByEmail + summary: Block one e-mail subject even when no user exists yet + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BlockUserByEmailRequest" + responses: + "200": + description: The block mutation applied or the subject was already blocked. + content: + application/json: + schema: + $ref: "#/components/schemas/BlockMutationResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/account: + get: + tags: + - MyAccount + operationId: getMyAccount + summary: Read one authenticated regular-user account aggregate + parameters: + - $ref: "#/components/parameters/UserIDPath" + responses: + "200": + description: Read-optimized account aggregate for the supplied `user_id`. + content: + application/json: + schema: + $ref: "#/components/schemas/GetMyAccountResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/profile: + post: + tags: + - MyAccount + operationId: updateMyProfile + summary: Update self-service profile fields + parameters: + - $ref: "#/components/parameters/UserIDPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMyProfileRequest" + responses: + "200": + description: Updated account aggregate after the profile mutation commits. + content: + application/json: + schema: + $ref: "#/components/schemas/GetMyAccountResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/settings: + post: + tags: + - MyAccount + operationId: updateMySettings + summary: Update self-service settings fields + parameters: + - $ref: "#/components/parameters/UserIDPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMySettingsRequest" + responses: + "200": + description: Updated account aggregate after the settings mutation commits. + content: + application/json: + schema: + $ref: "#/components/schemas/GetMyAccountResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/eligibility: + get: + tags: + - LobbyIntegration + operationId: getUserEligibility + summary: Read one synchronous lobby-facing eligibility snapshot + description: | + Returns a read-optimized snapshot for lobby decisions. Unknown users are + represented as `exists=false` instead of `404`. + parameters: + - $ref: "#/components/parameters/UserIDPath" + responses: + "200": + description: Eligibility snapshot for the supplied `user_id`. + content: + application/json: + schema: + $ref: "#/components/schemas/UserEligibilityResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/declared-country/sync: + post: + tags: + - GeoIntegration + operationId: syncDeclaredCountry + summary: Synchronize the current effective declared country + parameters: + - $ref: "#/components/parameters/UserIDPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SyncDeclaredCountryRequest" + responses: + "200": + description: Declared-country synchronization applied successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/DeclaredCountrySyncResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}: + get: + tags: + - AdminUsers + operationId: getUserByID + summary: Read one user by stable user identifier + parameters: + - $ref: "#/components/parameters/UserIDPath" + responses: + "200": + description: Exact user lookup result for the supplied `user_id`. + content: + application/json: + schema: + $ref: "#/components/schemas/UserLookupResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/user-lookups/by-email: + post: + tags: + - AdminUsers + operationId: getUserByEmail + summary: Read one user by normalized e-mail + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserLookupByEmailRequest" + responses: + "200": + description: Exact user lookup result for the supplied `email`. + content: + application/json: + schema: + $ref: "#/components/schemas/UserLookupResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/user-lookups/by-race-name: + post: + tags: + - AdminUsers + operationId: getUserByRaceName + summary: Read one user by exact race name + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserLookupByRaceNameRequest" + responses: + "200": + description: Exact user lookup result for the supplied `race_name`. + content: + application/json: + schema: + $ref: "#/components/schemas/UserLookupResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users: + get: + tags: + - AdminUsers + operationId: listUsers + summary: List users with deterministic pagination and rich filters + parameters: + - $ref: "#/components/parameters/PageSize" + - $ref: "#/components/parameters/PageToken" + - name: paid_state + in: query + description: Filter by current free or paid state. + schema: + type: string + enum: + - free + - paid + - name: paid_expires_before + in: query + description: Filter to users whose paid entitlement expires before this RFC 3339 timestamp. + schema: + type: string + format: date-time + - name: paid_expires_after + in: query + description: Filter to users whose paid entitlement expires after this RFC 3339 timestamp. + schema: + type: string + format: date-time + - name: declared_country + in: query + description: Filter by the current effective declared country. + schema: + $ref: "#/components/schemas/CountryCode" + - name: sanction_code + in: query + description: Filter by one active sanction code. + schema: + $ref: "#/components/schemas/SanctionCode" + - name: limit_code + in: query + description: Filter by one active limit code. + schema: + $ref: "#/components/schemas/LimitCode" + - name: can_login + in: query + description: Filter by the derived login eligibility marker. + schema: + type: boolean + - name: can_create_private_game + in: query + description: Filter by the derived private-game creation eligibility marker. + schema: + type: boolean + - name: can_join_game + in: query + description: Filter by the derived game-join eligibility marker. + schema: + type: boolean + responses: + "200": + description: Deterministically ordered page of users. + content: + application/json: + schema: + $ref: "#/components/schemas/UserListResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/entitlements/grant: + post: + tags: + - AdminUsers + operationId: grantEntitlement + summary: Grant a new entitlement period + parameters: + - $ref: "#/components/parameters/UserIDPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GrantEntitlementRequest" + responses: + "200": + description: Entitlement grant applied successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/EntitlementCommandResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/entitlements/extend: + post: + tags: + - AdminUsers + operationId: extendEntitlement + summary: Extend the current entitlement period + parameters: + - $ref: "#/components/parameters/UserIDPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ExtendEntitlementRequest" + responses: + "200": + description: Entitlement extension applied successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/EntitlementCommandResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/entitlements/revoke: + post: + tags: + - AdminUsers + operationId: revokeEntitlement + summary: Revoke the effective paid entitlement + parameters: + - $ref: "#/components/parameters/UserIDPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RevokeEntitlementRequest" + responses: + "200": + description: Entitlement revocation applied successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/EntitlementCommandResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/sanctions/apply: + post: + tags: + - AdminUsers + operationId: applySanction + summary: Apply one sanction record + parameters: + - $ref: "#/components/parameters/UserIDPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ApplySanctionRequest" + responses: + "200": + description: Sanction application applied successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/SanctionCommandResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/sanctions/remove: + post: + tags: + - AdminUsers + operationId: removeSanction + summary: Remove one active sanction record + parameters: + - $ref: "#/components/parameters/UserIDPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RemoveSanctionRequest" + responses: + "200": + description: Sanction removal applied successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/SanctionCommandResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/limits/set: + post: + tags: + - AdminUsers + operationId: setLimit + summary: Set one active user-specific limit record + parameters: + - $ref: "#/components/parameters/UserIDPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetLimitRequest" + responses: + "200": + description: User-specific limit set successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/LimitCommandResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/users/{user_id}/limits/remove: + post: + tags: + - AdminUsers + operationId: removeLimit + summary: Remove one active user-specific limit record + parameters: + - $ref: "#/components/parameters/UserIDPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RemoveLimitRequest" + responses: + "200": + description: User-specific limit removal applied successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/LimitCommandResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/SubjectNotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" +components: + parameters: + UserIDPath: + name: user_id + in: path + required: true + description: Stable regular-user identifier owned by User Service. + schema: + $ref: "#/components/schemas/UserID" + PageSize: + name: page_size + in: query + description: Maximum number of users returned in one page. + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + PageToken: + name: page_token + in: query + description: Opaque deterministic pagination cursor returned by the previous page. + schema: + type: string + schemas: + UserID: + type: string + description: Stable regular-user identifier. + minLength: 1 + Email: + type: string + format: email + description: Normalized login and contact e-mail address. + RaceName: + type: string + description: | + Stored race name preserving the user-selected casing after successful + uniqueness checks. + minLength: 1 + maxLength: 64 + LanguageTag: + type: string + description: BCP 47 language tag. + minLength: 1 + maxLength: 32 + TimeZoneName: + type: string + description: IANA time zone name. + minLength: 1 + maxLength: 128 + CountryCode: + type: string + description: ISO 3166-1 alpha-2 country code. + pattern: "^[A-Z]{2}$" + UserResolutionKind: + type: string + enum: + - existing + - creatable + - blocked + EnsureUserOutcome: + type: string + enum: + - existing + - created + - blocked + BlockUserOutcome: + type: string + enum: + - blocked + - already_blocked + PlanCode: + type: string + enum: + - free + - paid_monthly + - paid_yearly + - paid_lifetime + SanctionCode: + type: string + enum: + - login_block + - private_game_create_block + - private_game_manage_block + - game_join_block + - profile_update_block + LimitCode: + type: string + enum: + - max_owned_private_games + - max_active_private_games + - max_pending_public_applications + - max_pending_private_join_requests + - max_pending_private_invites_sent + - max_active_game_memberships + ActorRef: + type: object + additionalProperties: false + required: + - type + properties: + type: + type: string + description: Machine-readable actor type such as `admin`, `service`, or `billing`. + id: + type: string + description: Optional stable actor identifier. + RegistrationContext: + type: object + additionalProperties: false + required: + - preferred_language + - time_zone + properties: + preferred_language: + $ref: "#/components/schemas/LanguageTag" + description: | + Create-only initial preferred language. During the current rollout + phase `Auth / Session Service` sends a temporary `"en"` default + until gateway geoip-based language derivation is deployed. + time_zone: + $ref: "#/components/schemas/TimeZoneName" + description: Create-only initial IANA time zone name. + UserResolutionByEmailRequest: + type: object + additionalProperties: false + required: + - email + properties: + email: + $ref: "#/components/schemas/Email" + UserResolutionByEmailResponse: + type: object + additionalProperties: false + required: + - kind + properties: + kind: + $ref: "#/components/schemas/UserResolutionKind" + user_id: + $ref: "#/components/schemas/UserID" + block_reason_code: + type: string + description: Present only for `kind=blocked`. + UserExistsResponse: + type: object + additionalProperties: false + required: + - exists + properties: + exists: + type: boolean + EnsureByEmailRequest: + type: object + additionalProperties: false + required: + - email + properties: + email: + $ref: "#/components/schemas/Email" + registration_context: + $ref: "#/components/schemas/RegistrationContext" + EnsureByEmailResponse: + type: object + additionalProperties: false + required: + - outcome + properties: + outcome: + $ref: "#/components/schemas/EnsureUserOutcome" + user_id: + $ref: "#/components/schemas/UserID" + block_reason_code: + type: string + description: Present only for `outcome=blocked`. + BlockUserByIDRequest: + type: object + additionalProperties: false + required: + - reason_code + properties: + reason_code: + type: string + BlockUserByEmailRequest: + type: object + additionalProperties: false + required: + - email + - reason_code + properties: + email: + $ref: "#/components/schemas/Email" + reason_code: + type: string + BlockMutationResponse: + type: object + additionalProperties: false + required: + - outcome + properties: + outcome: + $ref: "#/components/schemas/BlockUserOutcome" + user_id: + $ref: "#/components/schemas/UserID" + EntitlementSnapshot: + type: object + additionalProperties: false + required: + - plan_code + - is_paid + - source + - starts_at + - updated_at + properties: + plan_code: + $ref: "#/components/schemas/PlanCode" + is_paid: + type: boolean + source: + type: string + actor: + $ref: "#/components/schemas/ActorRef" + reason_code: + type: string + starts_at: + type: string + format: date-time + ends_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ActiveSanction: + type: object + additionalProperties: false + required: + - sanction_code + - scope + - reason_code + - applied_at + properties: + sanction_code: + $ref: "#/components/schemas/SanctionCode" + scope: + type: string + reason_code: + type: string + actor: + $ref: "#/components/schemas/ActorRef" + applied_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + ActiveLimit: + type: object + additionalProperties: false + required: + - limit_code + - value + - reason_code + - applied_at + properties: + limit_code: + $ref: "#/components/schemas/LimitCode" + value: + type: integer + minimum: 0 + reason_code: + type: string + actor: + $ref: "#/components/schemas/ActorRef" + applied_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + AccountView: + type: object + additionalProperties: false + required: + - user_id + - email + - race_name + - preferred_language + - time_zone + - entitlement + - active_sanctions + - active_limits + - created_at + - updated_at + properties: + user_id: + $ref: "#/components/schemas/UserID" + email: + $ref: "#/components/schemas/Email" + race_name: + $ref: "#/components/schemas/RaceName" + preferred_language: + $ref: "#/components/schemas/LanguageTag" + time_zone: + $ref: "#/components/schemas/TimeZoneName" + declared_country: + $ref: "#/components/schemas/CountryCode" + entitlement: + $ref: "#/components/schemas/EntitlementSnapshot" + active_sanctions: + type: array + items: + $ref: "#/components/schemas/ActiveSanction" + active_limits: + type: array + items: + $ref: "#/components/schemas/ActiveLimit" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + GetMyAccountResponse: + type: object + additionalProperties: false + required: + - account + properties: + account: + $ref: "#/components/schemas/AccountView" + UpdateMyProfileRequest: + type: object + additionalProperties: false + required: + - race_name + properties: + race_name: + $ref: "#/components/schemas/RaceName" + UpdateMySettingsRequest: + type: object + additionalProperties: false + required: + - preferred_language + - time_zone + properties: + preferred_language: + $ref: "#/components/schemas/LanguageTag" + time_zone: + $ref: "#/components/schemas/TimeZoneName" + EligibilityMarkers: + type: object + additionalProperties: false + required: + - can_login + - can_create_private_game + - can_manage_private_game + - can_join_game + - can_update_profile + properties: + can_login: + type: boolean + can_create_private_game: + type: boolean + can_manage_private_game: + type: boolean + can_join_game: + type: boolean + can_update_profile: + type: boolean + UserEligibilityResponse: + type: object + additionalProperties: false + required: + - exists + - user_id + - markers + properties: + exists: + type: boolean + user_id: + $ref: "#/components/schemas/UserID" + entitlement: + $ref: "#/components/schemas/EntitlementSnapshot" + active_sanctions: + type: array + items: + $ref: "#/components/schemas/ActiveSanction" + effective_limits: + type: array + items: + $ref: "#/components/schemas/ActiveLimit" + markers: + $ref: "#/components/schemas/EligibilityMarkers" + SyncDeclaredCountryRequest: + type: object + additionalProperties: false + required: + - declared_country + properties: + declared_country: + $ref: "#/components/schemas/CountryCode" + DeclaredCountrySyncResponse: + type: object + additionalProperties: false + required: + - user_id + - declared_country + - updated_at + properties: + user_id: + $ref: "#/components/schemas/UserID" + declared_country: + $ref: "#/components/schemas/CountryCode" + updated_at: + type: string + format: date-time + UserAdminView: + allOf: + - $ref: "#/components/schemas/AccountView" + UserLookupByEmailRequest: + type: object + additionalProperties: false + required: + - email + properties: + email: + $ref: "#/components/schemas/Email" + UserLookupByRaceNameRequest: + type: object + additionalProperties: false + required: + - race_name + properties: + race_name: + $ref: "#/components/schemas/RaceName" + UserLookupResponse: + type: object + additionalProperties: false + required: + - user + properties: + user: + $ref: "#/components/schemas/UserAdminView" + UserListResponse: + type: object + additionalProperties: false + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/UserAdminView" + next_page_token: + type: string + GrantEntitlementRequest: + type: object + additionalProperties: false + required: + - plan_code + - source + - reason_code + - actor + - starts_at + properties: + plan_code: + $ref: "#/components/schemas/PlanCode" + source: + type: string + reason_code: + type: string + actor: + $ref: "#/components/schemas/ActorRef" + starts_at: + type: string + format: date-time + ends_at: + type: string + format: date-time + ExtendEntitlementRequest: + type: object + additionalProperties: false + required: + - source + - reason_code + - actor + - ends_at + properties: + source: + type: string + reason_code: + type: string + actor: + $ref: "#/components/schemas/ActorRef" + ends_at: + type: string + format: date-time + RevokeEntitlementRequest: + type: object + additionalProperties: false + required: + - source + - reason_code + - actor + properties: + source: + type: string + reason_code: + type: string + actor: + $ref: "#/components/schemas/ActorRef" + EntitlementCommandResponse: + type: object + additionalProperties: false + required: + - user_id + - entitlement + properties: + user_id: + $ref: "#/components/schemas/UserID" + entitlement: + $ref: "#/components/schemas/EntitlementSnapshot" + ApplySanctionRequest: + type: object + additionalProperties: false + required: + - sanction_code + - scope + - reason_code + - actor + - applied_at + properties: + sanction_code: + $ref: "#/components/schemas/SanctionCode" + scope: + type: string + reason_code: + type: string + actor: + $ref: "#/components/schemas/ActorRef" + applied_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + RemoveSanctionRequest: + type: object + additionalProperties: false + required: + - sanction_code + - reason_code + - actor + properties: + sanction_code: + $ref: "#/components/schemas/SanctionCode" + reason_code: + type: string + actor: + $ref: "#/components/schemas/ActorRef" + SanctionCommandResponse: + type: object + additionalProperties: false + required: + - user_id + - active_sanctions + properties: + user_id: + $ref: "#/components/schemas/UserID" + active_sanctions: + type: array + items: + $ref: "#/components/schemas/ActiveSanction" + SetLimitRequest: + type: object + additionalProperties: false + required: + - limit_code + - value + - reason_code + - actor + - applied_at + properties: + limit_code: + $ref: "#/components/schemas/LimitCode" + value: + type: integer + minimum: 0 + reason_code: + type: string + actor: + $ref: "#/components/schemas/ActorRef" + applied_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + RemoveLimitRequest: + type: object + additionalProperties: false + required: + - limit_code + - reason_code + - actor + properties: + limit_code: + $ref: "#/components/schemas/LimitCode" + reason_code: + type: string + actor: + $ref: "#/components/schemas/ActorRef" + LimitCommandResponse: + type: object + additionalProperties: false + required: + - user_id + - active_limits + properties: + user_id: + $ref: "#/components/schemas/UserID" + active_limits: + type: array + items: + $ref: "#/components/schemas/ActiveLimit" + ErrorResponse: + type: object + additionalProperties: false + required: + - error + properties: + error: + $ref: "#/components/schemas/ErrorBody" + ErrorBody: + type: object + additionalProperties: false + required: + - code + - message + properties: + code: + type: string + message: + type: string + responses: + InvalidRequestError: + description: Request body, path, or query fields are invalid. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + invalidRequest: + value: + error: + code: invalid_request + message: request is invalid + SubjectNotFoundError: + description: The referenced user or lookup subject does not exist. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + subjectNotFound: + value: + error: + code: subject_not_found + message: subject not found + ConflictError: + description: The requested mutation conflicts with current source-of-truth state. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + conflict: + value: + error: + code: conflict + message: request conflicts with current state + InternalError: + description: Internal User Service error. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + internalError: + value: + error: + code: internal_error + message: internal server error + ServiceUnavailableError: + description: User Service is temporarily unable to serve the request safely. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + unavailable: + value: + error: + code: service_unavailable + message: service is unavailable