feat: support time_zone for user registration context
This commit is contained in:
+10
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}'
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -44,6 +44,7 @@ func TestExecuteRecordsInvalidCodeMetricForThrottledChallenge(t *testing.T) {
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
TimeZone: confirmEmailCodeTimeZone,
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}'
|
||||
```
|
||||
|
||||
|
||||
@@ -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}
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
+644
@@ -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-<shortid>` 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
|
||||
+801
@@ -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-<shortid>` 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.
|
||||
+1376
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user