diff --git a/.github/workflows/sdks-go.yml b/.github/workflows/sdks-go.yml
index bf64df9..b80cae6 100644
--- a/.github/workflows/sdks-go.yml
+++ b/.github/workflows/sdks-go.yml
@@ -48,7 +48,7 @@ jobs:
if: matrix.os == 'ubuntu-latest'
run: |
pip install pytest
- pip install -e sdks/py
+ pip install -e ../py
# --- Build + Vet ---
diff --git a/.github/workflows/sdks-py.yml b/.github/workflows/sdks-py.yml
index 445f7fc..28e82f3 100644
--- a/.github/workflows/sdks-py.yml
+++ b/.github/workflows/sdks-py.yml
@@ -42,10 +42,10 @@ jobs:
# --- Lint ---
- name: Lint (ruff check)
- run: ruff check src/ tests/
+ run: ruff check src/loza/ tests/
- name: Format check (ruff format)
- run: ruff format --check src/ tests/
+ run: ruff format --check src/loza/ tests/
# --- Type check (enable when mypy is configured in pyproject.toml) ---
# Uncomment the steps below once [tool.mypy] is added to pyproject.toml
diff --git a/.github/workflows/sdks-rs.yml b/.github/workflows/sdks-rs.yml
index 61fa033..f959a40 100644
--- a/.github/workflows/sdks-rs.yml
+++ b/.github/workflows/sdks-rs.yml
@@ -102,7 +102,7 @@ jobs:
- name: Install conformance dependencies
run: |
pip install pytest
- pip install -e sdks/py
+ pip install -e ../py
- name: Conformance runner
working-directory: spec
diff --git a/.github/workflows/spec-ci.yml b/.github/workflows/spec-ci.yml
index c36b2fc..8e11b37 100644
--- a/.github/workflows/spec-ci.yml
+++ b/.github/workflows/spec-ci.yml
@@ -51,6 +51,12 @@ jobs:
with:
go-version-file: sdks/go/go.mod
- uses: dtolnay/rust-toolchain@stable
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ - name: Install JavaScript SDK dependencies
+ run: npm ci
+ working-directory: sdks/js
- name: Install conformance dependencies
run: python -m pip install --upgrade pip jsonschema pytest
- name: Install Python SDK
diff --git a/.github/workflows/spec-validate.yml b/.github/workflows/spec-validate.yml
index e4edc4f..19d092c 100644
--- a/.github/workflows/spec-validate.yml
+++ b/.github/workflows/spec-validate.yml
@@ -21,6 +21,15 @@ jobs:
with:
python-version: '3.11'
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+
+ - name: Install JavaScript SDK dependencies
+ run: npm ci
+ working-directory: sdks/js
+
- name: Install dependencies
run: |
python -m pip install --upgrade pip
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8a9cb03..b7e71f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,22 @@ All notable changes to the LOZA project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/0.2.0/).
+## [0.3.0] - 2026-08-14
+
+### Security
+
+- **Collector-scoped API keys**: Bearer API keys can now bind directly to one
+ configured collector with explicit `events:read`, `events:write`,
+ `events:delete`, or `project:admin` permissions and required environments.
+ Canonical collector routes reject mismatched collector, environment, and
+ permission scopes by default.
+
+### Changed
+
+- **Authorization configuration**: Documented scoped API-key configuration and
+ canonical `/collectors/{collector}/...` usage. Legacy unscoped API keys
+ remain restricted to explicit root-route migration paths.
+
## [0.2.6] - 2026-05-30
### Security
diff --git a/README.md b/README.md
index 3070601..3a29584 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -22,11 +22,11 @@
-
-
+
+
-
-
+
+
@@ -420,7 +420,7 @@ flowchart TD
| Language | Command |
| ----------------------- | ------------------------------------------------ |
-| Go | `go get github.com/astraive/loza/sdks/go@v0.2.6` |
+| Go | `go get github.com/astraive/loza/sdks/go@v0.3.0` |
| Python | `pip install loza` |
| Rust | `cargo add loza` |
| JavaScript / TypeScript | `npm install @astraive/loza` |
diff --git a/cli/loza-cli.yaml b/cli/loza-cli.yaml
index a15e4a7..a2d2cb0 100644
--- a/cli/loza-cli.yaml
+++ b/cli/loza-cli.yaml
@@ -1,6 +1,6 @@
name: loza-cli
kind: cli
-version: 0.2.6
+version: 0.3.0
description: Local operator and developer CLI for LOZA
license: MIT
repository: https://github.com/Astraive/loza
diff --git a/collector/cmd/loza-collector/config.go b/collector/cmd/loza-collector/config.go
index 53ec375..64582e6 100644
--- a/collector/cmd/loza-collector/config.go
+++ b/collector/cmd/loza-collector/config.go
@@ -18,8 +18,9 @@ import (
)
var (
- configIdentPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
- configPathPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`)
+ configIdentPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
+ configPathPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`)
+ collectorSlugPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,62})?$`)
)
type fileConfig = collectorconfig.Config
@@ -565,34 +566,52 @@ func resolveAuthConfig(fc fileConfig) (string, []collectorAuthKey, error) {
default:
return "", nil, fmt.Errorf("auth.keys[%d].kind must be pub or sec", i)
}
- if kind == auth.KeyKindPublic {
- hasOrigin := false
- for _, origin := range key.AllowedOrigins {
- if strings.TrimSpace(origin) != "" {
- hasOrigin = true
- break
- }
- }
- if !hasOrigin {
- return "", nil, fmt.Errorf("auth.keys[%d].allowed_origins must not be empty for public keys", i)
+ if kind == auth.KeyKindPublic && !hasNonEmptyString(key.AllowedOrigins) {
+ return "", nil, fmt.Errorf("auth.keys[%d].allowed_origins must not be empty for public keys", i)
+ }
+
+ mode, explicitMode, err := resolveCredentialMode(key.Mode, kind, fmt.Sprintf("auth.keys[%d].mode", i))
+ if err != nil {
+ return "", nil, err
+ }
+ var roles []auth.Role
+ if len(key.Roles) > 0 || explicitMode {
+ roles, err = resolveCredentialRoles(key.Roles, mode, explicitMode, fmt.Sprintf("auth.keys[%d].roles", i))
+ if err != nil {
+ return "", nil, err
}
}
- roles := make([]auth.Role, len(key.Roles))
- for j, rawRole := range key.Roles {
- role := auth.Role(strings.TrimSpace(rawRole))
- if !isKnownAuthRole(role) {
- return "", nil, fmt.Errorf("auth.keys[%d].roles[%d] %q is not recognized", i, j, rawRole)
+ collector := strings.TrimSpace(key.Collector)
+ if (collector == "") != (len(key.Permissions) == 0) {
+ return "", nil, fmt.Errorf("auth.keys[%d].collector and auth.keys[%d].permissions must be configured together", i, i)
+ }
+ var permissions []auth.Permission
+ allowedEnvs := append([]string(nil), key.AllowedEnvs...)
+ if collector != "" {
+ if !collectorSlugPattern.MatchString(collector) {
+ return "", nil, fmt.Errorf("auth.keys[%d].collector must be a valid collector slug", i)
+ }
+ permissions, err = resolveCollectorKeyPermissions(key.Permissions, i)
+ if err != nil {
+ return "", nil, err
+ }
+ allowedEnvs, err = resolveCollectorKeyEnvironments(key.AllowedEnvs, i)
+ if err != nil {
+ return "", nil, err
}
- roles[j] = role
}
+
keys = append(keys, collectorAuthKey{
name: strings.TrimSpace(key.Name),
keyID: keyID,
secret: secret,
kind: kind,
+ mode: mode,
roles: roles,
- allowedEnvs: append([]string(nil), key.AllowedEnvs...),
+ collector: collector,
+ permissions: permissions,
+ allowedEnvs: allowedEnvs,
allowedServices: append([]string(nil), key.AllowedServices...),
allowedOrigins: append([]string(nil), key.AllowedOrigins...),
allowedIPs: append([]string(nil), key.AllowedIPs...),
@@ -604,6 +623,272 @@ func resolveAuthConfig(fc fileConfig) (string, []collectorAuthKey, error) {
return serverSecret, keys, nil
}
+func resolveAuthTokens(fc fileConfig) ([]collectorAuthToken, error) {
+ tokens := make([]collectorAuthToken, 0, len(fc.Auth.Tokens))
+ seenTokenEnvs := make(map[string]struct{}, len(fc.Auth.Tokens))
+ for i, configured := range fc.Auth.Tokens {
+ tokenEnv := strings.TrimSpace(configured.TokenEnv)
+ if !configIdentPattern.MatchString(tokenEnv) {
+ return nil, fmt.Errorf("auth.tokens[%d].token_env must name an environment variable", i)
+ }
+ if _, exists := seenTokenEnvs[tokenEnv]; exists {
+ return nil, fmt.Errorf("auth.tokens[%d].token_env %q must be unique", i, tokenEnv)
+ }
+ seenTokenEnvs[tokenEnv] = struct{}{}
+ token := strings.TrimSpace(os.Getenv(tokenEnv))
+ if token == "" {
+ return nil, fmt.Errorf("auth.tokens[%d].token_env %q did not resolve to a non-empty value", i, tokenEnv)
+ }
+ if _, err := auth.ParseKey(token); err == nil {
+ return nil, fmt.Errorf("auth.tokens[%d].token_env must not contain a structured LOZA API key", i)
+ }
+
+ mode, _, err := resolveCredentialMode(configured.Mode, auth.KeyKindToken, fmt.Sprintf("auth.tokens[%d].mode", i))
+ if err != nil {
+ return nil, err
+ }
+ roles, err := resolveCredentialRoles(configured.Roles, mode, true, fmt.Sprintf("auth.tokens[%d].roles", i))
+ if err != nil {
+ return nil, err
+ }
+ if mode == auth.ModePublic && !hasNonEmptyString(configured.AllowedOrigins) {
+ return nil, fmt.Errorf("auth.tokens[%d].allowed_origins must not be empty for public credentials", i)
+ }
+
+ collector := strings.TrimSpace(configured.Collector)
+ if (collector == "") != (len(configured.Permissions) == 0) {
+ return nil, fmt.Errorf("auth.tokens[%d].collector and auth.tokens[%d].permissions must be configured together", i, i)
+ }
+ var permissions []auth.Permission
+ allowedEnvs := append([]string(nil), configured.AllowedEnvs...)
+ if collector != "" {
+ if !collectorSlugPattern.MatchString(collector) {
+ return nil, fmt.Errorf("auth.tokens[%d].collector must be a valid collector slug", i)
+ }
+ permissions, err = resolveCollectorPermissions(configured.Permissions, fmt.Sprintf("auth.tokens[%d].permissions", i))
+ if err != nil {
+ return nil, err
+ }
+ allowedEnvs, err = resolveCollectorEnvironments(configured.AllowedEnvs, fmt.Sprintf("auth.tokens[%d].allowed_envs", i))
+ if err != nil {
+ return nil, err
+ }
+ }
+ tokens = append(tokens, collectorAuthToken{
+ name: strings.TrimSpace(configured.Name),
+ token: token,
+ mode: mode,
+ roles: roles,
+ collector: collector,
+ permissions: permissions,
+ allowedEnvs: allowedEnvs,
+ allowedServices: trimStrings(configured.AllowedServices),
+ allowedOrigins: trimStrings(configured.AllowedOrigins),
+ allowedIPs: trimStrings(configured.AllowedIPs),
+ maxPayloadBytes: configured.MaxPayloadBytes,
+ maxRequestsPerMinute: configured.MaxRequestsPerMinute,
+ maxEventsPerMinute: configured.MaxEventsPerMinute,
+ })
+ }
+ return tokens, nil
+}
+
+func resolveCollectorAuthGrants(fc fileConfig) ([]string, []collectorAuthGrant, error) {
+ collectors := make([]string, 0, len(fc.Auth.Collectors))
+ knownCollectors := make(map[string]struct{}, len(fc.Auth.Collectors))
+ for i, configured := range fc.Auth.Collectors {
+ slug := configured.Slug
+ if !collectorSlugPattern.MatchString(slug) {
+ return nil, nil, fmt.Errorf("auth.collectors[%d].slug must be a valid collector slug", i)
+ }
+ if _, exists := knownCollectors[slug]; exists {
+ return nil, nil, fmt.Errorf("auth.collectors[%d].slug duplicates a configured collector", i)
+ }
+ knownCollectors[slug] = struct{}{}
+ collectors = append(collectors, slug)
+ }
+
+ defaultCollector := fc.Auth.DefaultCollector
+ if defaultCollector != "" {
+ if _, exists := knownCollectors[defaultCollector]; !exists {
+ return nil, nil, errors.New("auth.default_collector must name a configured collector")
+ }
+ }
+
+ grants := make([]collectorAuthGrant, 0, len(fc.Auth.Grants))
+ seenCredentialIDs := make(map[string]struct{}, len(fc.Auth.Grants))
+ for i, configured := range fc.Auth.Grants {
+ collector := configured.Collector
+ if _, exists := knownCollectors[collector]; !exists {
+ return nil, nil, fmt.Errorf("auth.grants[%d].collector must name a configured collector", i)
+ }
+
+ username := configured.Username
+ publicIDEnv := strings.TrimSpace(configured.PublicIDEnv)
+ if username != "" && publicIDEnv != "" {
+ return nil, nil, fmt.Errorf("auth.grants[%d] must configure either username/password_env or public_id_env", i)
+ }
+
+ var keyID, secret string
+ var kind auth.KeyKind
+ switch {
+ case publicIDEnv != "":
+ if configured.PasswordEnv != "" {
+ return nil, nil, fmt.Errorf("auth.grants[%d].password_env is not permitted for public grants", i)
+ }
+ if !configIdentPattern.MatchString(publicIDEnv) {
+ return nil, nil, fmt.Errorf("auth.grants[%d].public_id_env must name an environment variable", i)
+ }
+ publicID := strings.TrimSpace(os.Getenv(publicIDEnv))
+ if !auth.IsPublicAccessID(publicID) {
+ return nil, nil, fmt.Errorf("auth.grants[%d].public_id_env did not resolve to a valid public access ID", i)
+ }
+ keyID = publicID
+ kind = auth.KeyKindPublic
+ case username != "":
+ keyID = username
+ if !validConfigBasicUsername(username) {
+ return nil, nil, fmt.Errorf("auth.grants[%d].username is invalid", i)
+ }
+ passwordEnv := strings.TrimSpace(configured.PasswordEnv)
+ if !configIdentPattern.MatchString(passwordEnv) {
+ return nil, nil, fmt.Errorf("auth.grants[%d].password_env must name an environment variable", i)
+ }
+ secret = strings.TrimSpace(os.Getenv(passwordEnv))
+ if secret == "" {
+ return nil, nil, fmt.Errorf("auth.grants[%d].password_env did not resolve to a non-empty value", i)
+ }
+ kind = auth.KeyKindSecret
+ default:
+ return nil, nil, fmt.Errorf("auth.grants[%d] must configure username/password_env or public_id", i)
+ }
+ if _, exists := seenCredentialIDs[keyID]; exists {
+ return nil, nil, errors.New("configured credential identifiers must be unique")
+ }
+ seenCredentialIDs[keyID] = struct{}{}
+
+ permissions, err := resolveCollectorGrantPermissions(configured.Permissions, i)
+ if err != nil {
+ return nil, nil, err
+ }
+ environments, err := resolveCollectorGrantEnvironments(configured.AllowedEnvs, i)
+ if err != nil {
+ return nil, nil, err
+ }
+ if kind == auth.KeyKindPublic && !hasNonEmptyString(configured.AllowedOrigins) {
+ return nil, nil, fmt.Errorf("auth.grants[%d].allowed_origins must not be empty for public grants", i)
+ }
+
+ grants = append(grants, collectorAuthGrant{
+ name: strings.TrimSpace(configured.Name),
+ collector: collector,
+ keyID: keyID,
+ secret: secret,
+ kind: kind,
+ permissions: permissions,
+ allowedEnvs: environments,
+ allowedServices: trimStrings(configured.AllowedServices),
+ allowedOrigins: trimStrings(configured.AllowedOrigins),
+ allowedIPs: trimStrings(configured.AllowedIPs),
+ maxPayloadBytes: configured.MaxPayloadBytes,
+ maxRequestsPerMinute: configured.MaxRequestsPerMinute,
+ maxEventsPerMinute: configured.MaxEventsPerMinute,
+ })
+ }
+ return collectors, grants, nil
+}
+
+func validConfigBasicUsername(username string) bool {
+ if username == "" || strings.TrimSpace(username) != username || strings.Contains(username, ":") {
+ return false
+ }
+ for _, r := range username {
+ if r == ' ' || r == '\t' || r == '\r' || r == '\n' {
+ return false
+ }
+ }
+ return true
+}
+
+func resolveCollectorGrantPermissions(raw []string, grantIndex int) ([]auth.Permission, error) {
+ return resolveCollectorPermissions(raw, fmt.Sprintf("auth.grants[%d].permissions", grantIndex))
+}
+
+func resolveCollectorKeyPermissions(raw []string, keyIndex int) ([]auth.Permission, error) {
+ return resolveCollectorPermissions(raw, fmt.Sprintf("auth.keys[%d].permissions", keyIndex))
+}
+
+func resolveCollectorPermissions(raw []string, field string) ([]auth.Permission, error) {
+ if len(raw) == 0 {
+ return nil, fmt.Errorf("%s must not be empty", field)
+ }
+ permissions := make([]auth.Permission, 0, len(raw))
+ seen := make(map[auth.Permission]struct{}, len(raw))
+ for permissionIndex, value := range raw {
+ permission := auth.Permission(strings.TrimSpace(value))
+ switch permission {
+ case auth.PermEventsRead, auth.PermEventsWrite, auth.PermEventsDelete,
+ auth.PermLogsRead, auth.PermLogsWrite, auth.PermLogsEdit, auth.PermLogsDelete,
+ auth.PermProjectAdmin:
+ default:
+ return nil, fmt.Errorf("%s[%d] is not a collector grant permission", field, permissionIndex)
+ }
+ if _, exists := seen[permission]; exists {
+ return nil, fmt.Errorf("%s must not contain duplicates", field)
+ }
+ seen[permission] = struct{}{}
+ permissions = append(permissions, permission)
+ }
+ return permissions, nil
+}
+
+func resolveCollectorGrantEnvironments(raw []string, grantIndex int) ([]string, error) {
+ return resolveCollectorEnvironments(raw, fmt.Sprintf("auth.grants[%d].allowed_envs", grantIndex))
+}
+
+func resolveCollectorKeyEnvironments(raw []string, keyIndex int) ([]string, error) {
+ return resolveCollectorEnvironments(raw, fmt.Sprintf("auth.keys[%d].allowed_envs", keyIndex))
+}
+
+func resolveCollectorEnvironments(raw []string, field string) ([]string, error) {
+ if len(raw) == 0 {
+ return nil, fmt.Errorf("%s must not be empty", field)
+ }
+ environments := make([]string, 0, len(raw))
+ seen := make(map[string]struct{}, len(raw))
+ for environmentIndex, value := range raw {
+ environment := strings.TrimSpace(value)
+ if environment == "" {
+ return nil, fmt.Errorf("%s[%d] must not be empty", field, environmentIndex)
+ }
+ if _, exists := seen[environment]; exists {
+ return nil, fmt.Errorf("%s must not contain duplicates", field)
+ }
+ seen[environment] = struct{}{}
+ environments = append(environments, environment)
+ }
+ return environments, nil
+}
+
+func hasNonEmptyString(values []string) bool {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return true
+ }
+ }
+ return false
+}
+
+func trimStrings(values []string) []string {
+ trimmed := make([]string, 0, len(values))
+ for _, value := range values {
+ if value = strings.TrimSpace(value); value != "" {
+ trimmed = append(trimmed, value)
+ }
+ }
+ return trimmed
+}
+
func resolveConfigSecret(field, raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
@@ -625,14 +910,56 @@ func resolveConfigSecret(field, raw string) (string, error) {
return value, nil
}
-func isKnownAuthRole(role auth.Role) bool {
- switch role {
- case auth.RoleIngestPublic, auth.RoleIngestServer, auth.RoleIngestEnterprise,
- auth.RoleProjectReadonly, auth.RoleProjectOperator, auth.RoleProjectAdmin:
- return true
+func resolveCredentialMode(raw string, kind auth.KeyKind, field string) (auth.AccessMode, bool, error) {
+ value := strings.ToLower(strings.TrimSpace(raw))
+ if value == "" {
+ if kind == auth.KeyKindPublic {
+ return auth.ModePublic, false, nil
+ }
+ return auth.ModePrivate, false, nil
+ }
+
+ mode := auth.AccessMode(value)
+ switch mode {
+ case auth.ModePublic:
+ if kind == auth.KeyKindSecret {
+ return "", false, fmt.Errorf("%s public mode requires a public key or token", field)
+ }
+ case auth.ModePrivate:
+ if kind == auth.KeyKindPublic {
+ return "", false, fmt.Errorf("%s private mode requires a secret key or token", field)
+ }
default:
- return false
+ return "", false, fmt.Errorf("%s must be public or private", field)
+ }
+ return mode, true, nil
+}
+
+func resolveCredentialRoles(raw []string, mode auth.AccessMode, enforceMode bool, field string) ([]auth.Role, error) {
+ if len(raw) == 0 {
+ return nil, fmt.Errorf("%s must not be empty", field)
+ }
+ roles := make([]auth.Role, len(raw))
+ seen := make(map[auth.Role]struct{}, len(raw))
+ for i, rawRole := range raw {
+ role := auth.Role(strings.TrimSpace(rawRole))
+ if !auth.IsKnownRole(role) {
+ return nil, fmt.Errorf("%s[%d] %q is not recognized", field, i, rawRole)
+ }
+ if _, exists := seen[role]; exists {
+ return nil, fmt.Errorf("%s must not contain duplicates", field)
+ }
+ if enforceMode && !auth.RoleAllowedInMode(role, mode) {
+ return nil, fmt.Errorf("%s[%d] is not allowed for public credentials", field, i)
+ }
+ seen[role] = struct{}{}
+ roles[i] = role
}
+ return roles, nil
+}
+
+func isKnownAuthRole(role auth.Role) bool {
+ return auth.IsKnownRole(role)
}
func validateFileConfig(fc fileConfig) error {
@@ -654,10 +981,55 @@ func validateFileConfig(fc fileConfig) error {
if strings.TrimSpace(fc.Auth.Header) == "" {
return errors.New("auth.header must not be empty")
}
- authServerSecret, _, err := resolveAuthConfig(fc)
+ authServerSecret, authKeys, err := resolveAuthConfig(fc)
+ if err != nil {
+ return err
+ }
+ authTokens, err := resolveAuthTokens(fc)
+ if err != nil {
+ return err
+ }
+ authCollectors, authGrants, err := resolveCollectorAuthGrants(fc)
if err != nil {
return err
}
+ configuredCollectors := make(map[string]struct{}, len(authCollectors))
+ for _, collector := range authCollectors {
+ configuredCollectors[collector] = struct{}{}
+ }
+ for keyIndex, key := range authKeys {
+ if key.collector == "" {
+ continue
+ }
+ if _, exists := configuredCollectors[key.collector]; !exists {
+ return fmt.Errorf("auth.keys[%d].collector must name a configured collector", keyIndex)
+ }
+ }
+ for tokenIndex, token := range authTokens {
+ if token.collector == "" {
+ continue
+ }
+ if _, exists := configuredCollectors[token.collector]; !exists {
+ return fmt.Errorf("auth.tokens[%d].collector must name a configured collector", tokenIndex)
+ }
+ }
+ configuredCredentialIDs := make(map[string]struct{}, len(authKeys)+len(authGrants)+len(authTokens))
+ for _, key := range authKeys {
+ configuredCredentialIDs[key.keyID] = struct{}{}
+ }
+ for _, grant := range authGrants {
+ if _, exists := configuredCredentialIDs[grant.keyID]; exists {
+ return errors.New("configured credential identifiers must be unique")
+ }
+ configuredCredentialIDs[grant.keyID] = struct{}{}
+ }
+ for _, token := range authTokens {
+ tokenID := auth.TokenLookupID(token.token, []byte(authServerSecret))
+ if _, exists := configuredCredentialIDs[tokenID]; exists {
+ return errors.New("configured credential identifiers must be unique")
+ }
+ configuredCredentialIDs[tokenID] = struct{}{}
+ }
if fc.Auth.Enabled {
if authServerSecret == "" {
return errors.New("auth.enabled requires auth.server_secret")
@@ -673,8 +1045,8 @@ func validateFileConfig(fc fileConfig) error {
return fmt.Errorf("auth.value must be a valid LOZA API key: %w", err)
}
}
- if len(fc.Auth.Keys) == 0 && strings.TrimSpace(fc.Auth.Value) == "" {
- return errors.New("auth enabled but no configured key or valid legacy API key")
+ if len(fc.Auth.Keys) == 0 && len(fc.Auth.Grants) == 0 && len(fc.Auth.Tokens) == 0 && strings.TrimSpace(fc.Auth.Value) == "" {
+ return errors.New("auth enabled but no configured grant, key, token, or valid legacy API key")
}
}
if strings.TrimSpace(fc.Storage.EncryptionKey) == "" {
@@ -1057,10 +1429,18 @@ func validateComponentRegistry(reg collectorconfig.ComponentRegistryConfig) erro
func runtimeConfigFromFile(fc fileConfig) collectorConfig {
authServerSecret, authKeys, _ := resolveAuthConfig(fc)
+ authTokens, _ := resolveAuthTokens(fc)
+ authCollectors, authGrants, _ := resolveCollectorAuthGrants(fc)
+ authDefaultCollector := strings.TrimSpace(fc.Auth.DefaultCollector)
+ if len(authCollectors) == 0 {
+ // Configurations created before collector-scoped routes used the root
+ // API. Bind those legacy routes to an implicit default scope so they
+ // retain their documented behavior while keeping ownership explicit.
+ authDefaultCollector = "default"
+ }
return collectorConfig{
addr: fc.Collector.Addr,
readHeaderTimeout: fc.Collector.ReadHeaderTimeout,
- shutdownTimeout: fc.Collector.ShutdownTimeout,
maxBodyBytes: fc.Collector.MaxBodyBytes,
maxEventsPerRequest: fc.Collector.MaxEventsPerReq,
serverConfig: serverConfig{
@@ -1112,14 +1492,18 @@ func runtimeConfigFromFile(fc fileConfig) collectorConfig {
BatchLimit: fc.Collector.Server.GraphQL.BatchLimit,
},
},
- authEnabled: fc.Auth.Enabled,
- authAllowLocalDevKeys: fc.Auth.AllowLocalDevKeys,
- authServerSecret: authServerSecret,
- authCacheTTL: fc.Auth.CacheTTL,
- authNegativeCacheTTL: fc.Auth.NegativeCacheTTL,
- authKeys: authKeys,
- apiKeyHeader: fc.Auth.Header,
- apiKey: fc.Auth.Value,
+ authEnabled: fc.Auth.Enabled,
+ authAllowLocalDevKeys: fc.Auth.AllowLocalDevKeys,
+ authServerSecret: authServerSecret,
+ authCacheTTL: fc.Auth.CacheTTL,
+ authNegativeCacheTTL: fc.Auth.NegativeCacheTTL,
+ authDefaultCollector: authDefaultCollector,
+ authCollectors: authCollectors,
+ authGrants: authGrants,
+ authKeys: authKeys,
+ authTokens: authTokens,
+ apiKeyHeader: fc.Auth.Header,
+ apiKey: fc.Auth.Value,
rateLimitEnabled: fc.RateLimit.Enabled,
rateLimitRPS: fc.RateLimit.RPS,
rateLimitBurst: fc.RateLimit.Burst,
@@ -1359,10 +1743,12 @@ func parseConfigBool(raw string) (bool, error) {
}
func marshalPrintableConfig(fc fileConfig) ([]byte, error) {
+ redacted := fc
+
var out bytes.Buffer
enc := yaml.NewEncoder(&out)
enc.SetIndent(2)
- if err := enc.Encode(fc); err != nil {
+ if err := enc.Encode(redacted); err != nil {
_ = enc.Close()
return nil, err
}
@@ -1371,4 +1757,3 @@ func marshalPrintableConfig(fc fileConfig) ([]byte, error) {
}
return out.Bytes(), nil
}
-
diff --git a/collector/cmd/loza-collector/config_test.go b/collector/cmd/loza-collector/config_test.go
index cc43962..b842385 100644
--- a/collector/cmd/loza-collector/config_test.go
+++ b/collector/cmd/loza-collector/config_test.go
@@ -16,9 +16,9 @@ import (
func TestMain(m *testing.M) {
for key, value := range map[string]string{
"COLLECTOR_AUTH_SERVER_SECRET": "test-auth-server-secret",
- "COLLECTOR_INGEST_KEY_SECRET": "test-ingest-key-secret",
- "COLLECTOR_ADMIN_KEY_SECRET": "test-admin-key-secret",
- "LOZA_STORAGE_ENCRYPTION_KEY": "test-storage-encryption-key",
+ "COLLECTOR_INGEST_KEY_SECRET": "test-ingest-key-secret",
+ "COLLECTOR_ADMIN_KEY_SECRET": "test-admin-key-secret",
+ "LOZA_STORAGE_ENCRYPTION_KEY": "test-storage-encryption-key",
} {
if err := os.Setenv(key, value); err != nil {
panic(err)
@@ -159,6 +159,159 @@ storage:
if cfg.authKeys[0].secret != "test-ingest-key-secret" || cfg.authKeys[1].kind != auth.KeyKindSecret {
t.Fatalf("configured key secrets or kind were not resolved")
}
+ if cfg.authDefaultCollector != "default" {
+ t.Fatalf("legacy root routes must retain the implicit default collector, got %q", cfg.authDefaultCollector)
+ }
+}
+
+func TestLoadCollectorConfigFromArgsResolvesScopedConfiguredKey(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "collector.yaml")
+ raw := `
+auth:
+ enabled: true
+ server_secret: ${COLLECTOR_AUTH_SERVER_SECRET}
+ cache_ttl: 1m
+ negative_cache_ttl: 10s
+ collectors:
+ - slug: checkout
+ keys:
+ - name: checkout-writer
+ key_id: kcheckoutwriter
+ secret_env: COLLECTOR_INGEST_KEY_SECRET
+ kind: sec
+ collector: checkout
+ permissions: [events:write]
+ allowed_envs: [prod]
+storage:
+ encryption_key_env: LOZA_STORAGE_ENCRYPTION_KEY
+`
+ if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+ cfg, err := loadCollectorConfigFromArgs([]string{"-c", path})
+ if err != nil {
+ t.Fatalf("load scoped key configuration: %v", err)
+ }
+ if len(cfg.authKeys) != 1 || cfg.authKeys[0].collector != "checkout" {
+ t.Fatalf("scoped key was not carried to runtime: %+v", cfg.authKeys)
+ }
+ if got := cfg.authKeys[0].permissions; len(got) != 1 || got[0] != auth.PermEventsWrite {
+ t.Fatalf("scoped key permissions = %v, want events:write", got)
+ }
+ if len(cfg.authCollectors) != 1 || cfg.authCollectors[0] != "checkout" {
+ t.Fatalf("configured collector was not carried to runtime: %v", cfg.authCollectors)
+ }
+}
+
+func TestResolveCollectorAuthGrantsBuildsPrivateAndPublicCredentials(t *testing.T) {
+ publicID := "lx_pub_0123456789abcdefghijklmnopqrstuv"
+ t.Setenv("COLLECTOR_PRIVATE_GRANT_PASSWORD", "private-grant-password")
+ t.Setenv("COLLECTOR_PUBLIC_ACCESS_ID", publicID)
+ cfg := validFileConfig()
+ cfg.Auth.Collectors = []collectorconfig.AuthCollectorConfig{{Slug: "browser-events"}}
+ cfg.Auth.DefaultCollector = "browser-events"
+ cfg.Auth.Grants = []collectorconfig.AuthGrantConfig{
+ {
+ Name: "service",
+ Collector: "browser-events",
+ Username: "service-writer",
+ PasswordEnv: "COLLECTOR_PRIVATE_GRANT_PASSWORD",
+ Permissions: []string{"events:write"},
+ AllowedEnvs: []string{"prod"},
+ },
+ {
+ Name: "browser",
+ Collector: "browser-events",
+ PublicIDEnv: "COLLECTOR_PUBLIC_ACCESS_ID",
+ Permissions: []string{"events:write"},
+ AllowedEnvs: []string{"prod"},
+ AllowedOrigins: []string{"https://console.example.test"},
+ },
+ }
+
+ collectors, grants, err := resolveCollectorAuthGrants(cfg)
+ if err != nil {
+ t.Fatalf("resolve grants: %v", err)
+ }
+ if len(collectors) != 1 || collectors[0] != "browser-events" || len(grants) != 2 {
+ t.Fatalf("unexpected resolved grants: collectors=%v grants=%+v", collectors, grants)
+ }
+ if grants[0].kind != auth.KeyKindSecret || grants[0].secret != "private-grant-password" || grants[0].keyID != "service-writer" {
+ t.Fatalf("private grant was not resolved: %+v", grants[0])
+ }
+ if grants[1].kind != auth.KeyKindPublic || grants[1].secret != "" || grants[1].keyID != publicID {
+ t.Fatalf("public grant was not constructed as a passwordless capability: %+v", grants[1])
+ }
+}
+
+func TestResolveAuthConfigBuildsCollectorScopedKey(t *testing.T) {
+ cfg := validFileConfig()
+ cfg.Auth.Collectors = []collectorconfig.AuthCollectorConfig{{Slug: "orders"}}
+ cfg.Auth.Keys = []collectorconfig.AuthKeyConfig{{
+ Name: "orders-operator",
+ KeyID: "korders",
+ SecretEnv: "COLLECTOR_INGEST_KEY_SECRET",
+ Kind: "sec",
+ Collector: "orders",
+ Permissions: []string{"events:read", "events:write", "events:delete"},
+ AllowedEnvs: []string{"production"},
+ }}
+
+ _, keys, err := resolveAuthConfig(cfg)
+ if err != nil {
+ t.Fatalf("resolve scoped key: %v", err)
+ }
+ if len(keys) != 1 || keys[0].collector != "orders" {
+ t.Fatalf("scoped key collector was not resolved: %+v", keys)
+ }
+ if got := keys[0].permissions; len(got) != 3 || got[0] != auth.PermEventsRead || got[1] != auth.PermEventsWrite || got[2] != auth.PermEventsDelete {
+ t.Fatalf("scoped key permissions = %v, want read/write/delete", got)
+ }
+
+ cfg.Auth.Keys[0].Permissions = nil
+ if err := validateFileConfig(cfg); err == nil || !strings.Contains(err.Error(), "auth.keys[0].permissions") {
+ t.Fatalf("expected scoped key permissions rejection, got %v", err)
+ }
+ cfg.Auth.Keys[0].Permissions = []string{"events:write"}
+ cfg.Auth.Keys[0].Collector = "unknown"
+ if err := validateFileConfig(cfg); err == nil || !strings.Contains(err.Error(), "configured collector") {
+ t.Fatalf("expected unknown scoped key collector rejection, got %v", err)
+ }
+}
+
+func TestValidateFileConfigRejectsInvalidCollectorGrantConfiguration(t *testing.T) {
+ publicID := "lx_pub_0123456789abcdefghijklmnopqrstuv"
+ t.Setenv("COLLECTOR_PUBLIC_ACCESS_ID", publicID)
+ cfg := validFileConfig()
+ cfg.Auth.Collectors = []collectorconfig.AuthCollectorConfig{{Slug: "web"}, {Slug: "web"}}
+ if err := validateFileConfig(cfg); err == nil || !strings.Contains(err.Error(), "duplicates") {
+ t.Fatalf("expected duplicate collector rejection, got %v", err)
+ }
+
+ cfg = validFileConfig()
+ cfg.Auth.Collectors = []collectorconfig.AuthCollectorConfig{{Slug: "Not_valid"}}
+ if err := validateFileConfig(cfg); err == nil || !strings.Contains(err.Error(), "valid collector slug") {
+ t.Fatalf("expected invalid collector slug rejection, got %v", err)
+ }
+
+ cfg = validFileConfig()
+ cfg.Auth.Collectors = []collectorconfig.AuthCollectorConfig{{Slug: "web"}}
+ cfg.Auth.Grants = []collectorconfig.AuthGrantConfig{{
+ Collector: "unknown",
+ PublicIDEnv: "COLLECTOR_PUBLIC_ACCESS_ID",
+ Permissions: []string{"events:write"},
+ AllowedEnvs: []string{"prod"},
+ AllowedOrigins: []string{"https://console.example.test"},
+ }}
+ if err := validateFileConfig(cfg); err == nil || !strings.Contains(err.Error(), "configured collector") {
+ t.Fatalf("expected unknown collector rejection, got %v", err)
+ }
+
+ cfg.Auth.Grants[0].Collector = "web"
+ t.Setenv("COLLECTOR_PUBLIC_ACCESS_ID", "lx_pub_invalid")
+ if err := validateFileConfig(cfg); err == nil || strings.Contains(err.Error(), "lx_pub_invalid") {
+ t.Fatalf("expected redacted malformed public ID rejection, got %v", err)
+ }
}
func TestValidateFileConfigRejectsInvalidConfiguredAuth(t *testing.T) {
@@ -170,6 +323,7 @@ func TestValidateFileConfigRejectsInvalidConfiguredAuth(t *testing.T) {
cfg.Storage.EncryptionKey = "test-storage-encryption-key"
cfg.Auth.Keys = []collectorconfig.AuthKeyConfig{
{KeyID: "duplicate", SecretEnv: "COLLECTOR_INGEST_KEY_SECRET", Kind: "sec", Roles: []string{"collector_ingest_server"}},
+
{KeyID: "duplicate", SecretEnv: "COLLECTOR_ADMIN_KEY_SECRET", Kind: "sec", Roles: []string{"not-a-role"}},
}
if err := validateFileConfig(cfg); err == nil || !strings.Contains(err.Error(), "must be unique") {
@@ -193,6 +347,37 @@ func TestValidateFileConfigRejectsInvalidConfiguredAuth(t *testing.T) {
}
}
+func TestResolveAuthTokensBuildsRBACCredentials(t *testing.T) {
+ t.Setenv("COLLECTOR_ADMIN_TOKEN", "lxt_private_admin_token")
+ cfg := validFileConfig()
+ cfg.Auth.Tokens = []collectorconfig.AuthTokenConfig{{
+ Name: "admin-token",
+ TokenEnv: "COLLECTOR_ADMIN_TOKEN",
+ Mode: "private",
+ Roles: []string{"client"},
+ Collector: "logs",
+ Permissions: []string{"logs:write"},
+ AllowedEnvs: []string{"prod"},
+ }}
+
+ tokens, err := resolveAuthTokens(cfg)
+ if err != nil {
+ t.Fatalf("resolve token: %v", err)
+ }
+ if len(tokens) != 1 || tokens[0].mode != auth.ModePrivate || len(tokens[0].roles) != 1 || tokens[0].roles[0] != auth.RoleClient {
+ t.Fatalf("token RBAC configuration was not resolved: %+v", tokens)
+ }
+ if len(tokens[0].permissions) != 1 || tokens[0].permissions[0] != auth.PermLogsWrite {
+ t.Fatalf("token log scope was not resolved: %+v", tokens[0])
+ }
+
+ cfg.Auth.Tokens[0].Roles = []string{"admin"}
+ cfg.Auth.Tokens[0].Mode = "public"
+ if _, err := resolveAuthTokens(cfg); err == nil || !strings.Contains(err.Error(), "public credentials") {
+ t.Fatalf("expected privileged public token rejection, got %v", err)
+ }
+}
+
func TestValidateFileConfigRejectsInvalidValues(t *testing.T) {
cfg := validFileConfig()
cfg.DuckDB.Table = "events;drop"
diff --git a/collector/cmd/loza-collector/config_types.go b/collector/cmd/loza-collector/config_types.go
index 2d0abcf..94b44ff 100644
--- a/collector/cmd/loza-collector/config_types.go
+++ b/collector/cmd/loza-collector/config_types.go
@@ -30,7 +30,44 @@ type collectorAuthKey struct {
keyID string
secret string
kind auth.KeyKind
+ mode auth.AccessMode
roles []auth.Role
+ collector string
+ permissions []auth.Permission
+ allowedEnvs []string
+ allowedServices []string
+ allowedOrigins []string
+ allowedIPs []string
+ maxPayloadBytes int
+ maxRequestsPerMinute int
+ maxEventsPerMinute int
+}
+
+// collectorAuthGrant is the resolved runtime representation of a
+// collector-bound Basic credential.
+type collectorAuthGrant struct {
+ name string
+ collector string
+ keyID string
+ secret string
+ kind auth.KeyKind
+ permissions []auth.Permission
+ allowedEnvs []string
+ allowedServices []string
+ allowedOrigins []string
+ allowedIPs []string
+ maxPayloadBytes int
+ maxRequestsPerMinute int
+ maxEventsPerMinute int
+}
+
+type collectorAuthToken struct {
+ name string
+ token string
+ mode auth.AccessMode
+ roles []auth.Role
+ collector string
+ permissions []auth.Permission
allowedEnvs []string
allowedServices []string
allowedOrigins []string
@@ -55,6 +92,10 @@ type collectorConfig struct {
authCacheTTL time.Duration
authNegativeCacheTTL time.Duration
authKeys []collectorAuthKey
+ authTokens []collectorAuthToken
+ authDefaultCollector string
+ authCollectors []string
+ authGrants []collectorAuthGrant
apiKeyHeader string
apiKey string
rateLimitEnabled bool
diff --git a/collector/cmd/loza-collector/control_handlers.go b/collector/cmd/loza-collector/control_handlers.go
index d56c999..7700a7d 100644
--- a/collector/cmd/loza-collector/control_handlers.go
+++ b/collector/cmd/loza-collector/control_handlers.go
@@ -13,6 +13,7 @@ import (
"strings"
"time"
+ publichttp "github.com/astraive/loza/collector/server/http"
"github.com/rs/zerolog/log"
)
@@ -94,6 +95,9 @@ func (s *collectorState) handleSinkTest(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"})
return
}
+ if rejectUnsupportedScopedOperation(w, r) {
+ return
+ }
name := r.PathValue("name")
sink, ok := s.findSinkByName(name)
if !ok {
@@ -159,6 +163,10 @@ func (s *collectorState) handleQuery(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"})
return
}
+ if isCanonicalCollectorRoute(r) {
+ s.handleScopedQuery(w, r)
+ return
+ }
requestID := fmt.Sprintf("q_%d", time.Now().UTC().UnixNano())
var req queryRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
@@ -267,11 +275,96 @@ func (s *collectorState) handleQuery(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"columns": columns, "rows": result, "row_count": len(result)})
}
+// handleScopedQuery exposes a fixed, parameterized event-listing query. Raw
+// client SQL cannot prove collector ownership and is deliberately unavailable
+// on canonical collector routes.
+func (s *collectorState) handleScopedQuery(w http.ResponseWriter, r *http.Request) {
+ scope, ok := publichttp.AuthorizedCollectorFromContext(r.Context())
+ if !ok {
+ writeJSON(w, http.StatusForbidden, map[string]any{"error": "collector_scope_required"})
+ return
+ }
+ var req queryRequest
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid_query_request", "message": err.Error()})
+ return
+ }
+ if strings.TrimSpace(req.Query) != "" || strings.TrimSpace(req.SQL) != "" {
+ writeJSON(w, http.StatusBadRequest, map[string]any{"error": "scoped_raw_sql_unsupported"})
+ return
+ }
+ if req.Limit <= 0 || req.Limit > 1000 {
+ req.Limit = 1000
+ }
+ table, err := quoteSQLIdent(s.cfg.duckDBTable)
+ if err != nil {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "query_unavailable"})
+ return
+ }
+ db := s.queryDB
+ var closeDB func()
+ if db == nil {
+ db, err = sql.Open(s.cfg.duckDBDriver, s.cfg.duckDBPath)
+ if err != nil {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "query_unavailable"})
+ return
+ }
+ closeDB = func() { _ = db.Close() }
+ }
+ if closeDB != nil {
+ defer closeDB()
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+ query := fmt.Sprintf("SELECT * FROM %s WHERE %s = ? AND %s = ? LIMIT ?", table, collectorOwnershipColumn, environmentOwnershipColumn)
+ rows, err := db.QueryContext(ctx, query, scope.Name, scope.Environment, req.Limit)
+ if err != nil {
+ log.Error().Err(err).Msg("scoped query failed")
+ writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "query_failed"})
+ return
+ }
+ defer rows.Close()
+ columns, err := rows.Columns()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "query_failed"})
+ return
+ }
+ result := make([]map[string]any, 0)
+ for rows.Next() {
+ values := make([]any, len(columns))
+ ptrs := make([]any, len(columns))
+ for i := range values {
+ ptrs[i] = &values[i]
+ }
+ if err := rows.Scan(ptrs...); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "query_failed"})
+ return
+ }
+ row := make(map[string]any, len(columns))
+ for i, column := range columns {
+ if value, isBytes := values[i].([]byte); isBytes {
+ row[column] = string(value)
+ } else {
+ row[column] = values[i]
+ }
+ }
+ result = append(result, row)
+ }
+ if err := rows.Err(); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "query_failed"})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"columns": columns, "rows": result, "row_count": len(result)})
+}
+
func (s *collectorState) handleReplay(w http.ResponseWriter, r *http.Request) {
if !s.isAuthorized(r) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"})
return
}
+ if rejectUnsupportedScopedOperation(w, r) {
+ return
+ }
var req struct {
Events [][]byte `json:"events"`
Filter string `json:"filter"`
@@ -302,6 +395,9 @@ func (s *collectorState) handleDLQList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"})
return
}
+ if rejectUnsupportedScopedOperation(w, r) {
+ return
+ }
events, err := s.readDLQRecords()
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"events": []any{}, "error": err.Error()})
@@ -315,6 +411,9 @@ func (s *collectorState) handleDLQShow(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"})
return
}
+ if rejectUnsupportedScopedOperation(w, r) {
+ return
+ }
id := r.PathValue("id")
events, err := s.readDLQRecords()
if err != nil {
@@ -335,6 +434,9 @@ func (s *collectorState) handleDLQReplay(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"})
return
}
+ if rejectUnsupportedScopedOperation(w, r) {
+ return
+ }
id := r.PathValue("id")
events, err := s.readDLQRecords()
if err != nil {
@@ -364,6 +466,9 @@ func (s *collectorState) handleDLQReplayAll(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"})
return
}
+ if rejectUnsupportedScopedOperation(w, r) {
+ return
+ }
events, err := s.readDLQRecords()
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"accepted": 0, "replayed": 0, "error": err.Error()})
@@ -392,6 +497,9 @@ func (s *collectorState) handleDLQDelete(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "auth_failed"})
return
}
+ if rejectUnsupportedScopedOperation(w, r) {
+ return
+ }
id := r.PathValue("id")
events, err := s.readDLQRecords()
if err != nil {
diff --git a/collector/cmd/loza-collector/handlers.go b/collector/cmd/loza-collector/handlers.go
index d0d424d..4bf2647 100644
--- a/collector/cmd/loza-collector/handlers.go
+++ b/collector/cmd/loza-collector/handlers.go
@@ -16,6 +16,7 @@ import (
processing "github.com/astraive/loza/collector/internal/processing"
serverruntime "github.com/astraive/loza/collector/internal/server"
"github.com/astraive/loza/collector/internal/validation"
+ publichttp "github.com/astraive/loza/collector/server/http"
speccontract "github.com/astraive/loza/spec/generated/go/contract"
)
@@ -36,6 +37,54 @@ func ingestRejectResponse(requestID, code, message string, retryable bool) inges
}
}
+const (
+ collectorOwnershipColumn = "collector"
+ environmentOwnershipColumn = "environment"
+)
+
+func isCanonicalCollectorRoute(r *http.Request) bool {
+ return r.PathValue("collector") != ""
+}
+
+func canonicalCollectorScope(r *http.Request) (publichttp.AuthorizedCollector, bool) {
+ if !isCanonicalCollectorRoute(r) {
+ return publichttp.AuthorizedCollector{}, false
+ }
+ scope, ok := publichttp.AuthorizedCollectorFromContext(r.Context())
+ if !ok || strings.TrimSpace(scope.Name) == "" {
+ return publichttp.AuthorizedCollector{}, false
+ }
+ return scope, true
+}
+
+func rejectUnsupportedScopedOperation(w http.ResponseWriter, r *http.Request) bool {
+ if !isCanonicalCollectorRoute(r) {
+ return false
+ }
+ writeJSON(w, http.StatusBadRequest, map[string]any{"error": "scoped_operation_unsupported"})
+ return true
+}
+
+func stampAuthorizedOwnership(raw []byte, scope publichttp.AuthorizedCollector) ([]byte, *governanceError) {
+ var payload map[string]any
+ if err := json.Unmarshal(raw, &payload); err != nil {
+ return raw, &governanceError{Code: "invalid_json", Message: err.Error()}
+ }
+ if value, exists := payload[collectorOwnershipColumn]; exists && value != scope.Name {
+ return raw, &governanceError{Code: "ownership_conflict", Message: "event collector conflicts with authorized collector"}
+ }
+ if value, exists := payload[environmentOwnershipColumn]; exists && value != scope.Environment {
+ return raw, &governanceError{Code: "ownership_conflict", Message: "event environment conflicts with authorized environment"}
+ }
+ payload[collectorOwnershipColumn] = scope.Name
+ payload[environmentOwnershipColumn] = scope.Environment
+ stamped, err := json.Marshal(payload)
+ if err != nil {
+ return raw, &governanceError{Code: "event_rewrite_failed", Message: err.Error(), Retryable: true}
+ }
+ return stamped, nil
+}
+
func (s *collectorState) handleIngest(w http.ResponseWriter, r *http.Request) {
s.metrics.requestsTotal.Add(1)
requestID := newIngestRequestID()
@@ -52,6 +101,13 @@ func (s *collectorState) handleIngest(w http.ResponseWriter, r *http.Request) {
// Auth is handled by middleware (auth.Middleware + auth.RequirePermission).
// When auth is disabled, middleware is not applied and requests pass through.
+ scope, scoped := canonicalCollectorScope(r)
+ if isCanonicalCollectorRoute(r) && !scoped {
+ s.metrics.eventsRejected.Add(1)
+ writeJSON(w, http.StatusForbidden, ingestRejectResponse(requestID, "collector_scope_missing", "authorized collector scope is required", false))
+ return
+ }
+
if !isSupportedIngestContentType(r.Header.Get("Content-Type")) {
s.metrics.eventsRejected.Add(1)
writeJSON(w, http.StatusUnsupportedMediaType, ingestRejectResponse(requestID,
@@ -139,6 +195,16 @@ func (s *collectorState) handleIngest(w http.ResponseWriter, r *http.Request) {
raw = prepared
}
+ if scoped {
+ stamped, gerr := stampAuthorizedOwnership(raw, scope)
+ if gerr != nil {
+ s.metrics.eventsInvalid.Add(1)
+ resp.AddInvalid(i, eventID, gerr.Code, gerr.Message)
+ continue
+ }
+ raw = stamped
+ }
+
if s.cfg.reliabilityMode == "spool" || s.cfg.reliabilityMode == "hybrid" {
// For spool mode, deduplicate at ingest time to avoid unnecessary spooling
if s.dedupeEnabled() {
@@ -342,6 +408,9 @@ func (s *collectorState) handleMetrics(w http.ResponseWriter, r *http.Request) {
func (s *collectorState) handleTail(w http.ResponseWriter, r *http.Request) {
// Auth is handled by middleware.
+ if rejectUnsupportedScopedOperation(w, r) {
+ return
+ }
filters, err := serverruntime.ParseTailFilters(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
diff --git a/collector/cmd/loza-collector/handlers_deletion.go b/collector/cmd/loza-collector/handlers_deletion.go
index ece295c..5a01854 100644
--- a/collector/cmd/loza-collector/handlers_deletion.go
+++ b/collector/cmd/loza-collector/handlers_deletion.go
@@ -7,6 +7,8 @@ import (
"net/http"
"strings"
"time"
+
+ publichttp "github.com/astraive/loza/collector/server/http"
)
// escapeLIKE escapes LIKE metacharacters to prevent wildcard injection.
@@ -29,6 +31,13 @@ type DeletionResponse struct {
Timestamp time.Time `json:"timestamp"`
}
+func ownershipDeletePredicate(scope *publichttp.AuthorizedCollector) (string, []any) {
+ if scope == nil {
+ return "", nil
+ }
+ return ` AND collector = ? AND environment = ?`, []any{scope.Name, scope.Environment}
+}
+
// handleDeleteEvents deletes events based on query parameters.
// Supports: /events/by-tenant/{tenant_id}, /events/by-user/{user_id}, /events/{event_id}.
func (s *collectorState) handleDeleteEvents(w http.ResponseWriter, r *http.Request) {
@@ -51,6 +60,16 @@ func (s *collectorState) handleDeleteEvents(w http.ResponseWriter, r *http.Reque
return
}
+ var scope *publichttp.AuthorizedCollector
+ if isCanonicalCollectorRoute(r) {
+ authorizedScope, ok := canonicalCollectorScope(r)
+ if !ok {
+ writeJSON(w, http.StatusForbidden, map[string]any{"error": "collector_scope_missing"})
+ return
+ }
+ scope = &authorizedScope
+ }
+
// Verify that queryDB is available
if s.queryDB == nil {
logJSON("error", "deletion_unavailable", map[string]any{
@@ -87,7 +106,7 @@ func (s *collectorState) handleDeleteEvents(w http.ResponseWriter, r *http.Reque
})
return
}
- deletedCount, err = s.deleteEventsByTenant(r.Context(), tenantID)
+ deletedCount, err = s.deleteEventsByTenant(r.Context(), tenantID, scope)
deletionType = "by_tenant"
case strings.Contains(r.URL.Path, "/by-user/"):
@@ -99,14 +118,14 @@ func (s *collectorState) handleDeleteEvents(w http.ResponseWriter, r *http.Reque
})
return
}
- deletedCount, err = s.deleteEventsByUser(r.Context(), userID)
+ deletedCount, err = s.deleteEventsByUser(r.Context(), userID, scope)
deletionType = "by_user"
default:
// DELETE /events/{event_id}
if len(pathParts) > 1 && pathParts[len(pathParts)-1] != "" {
eventID := pathParts[len(pathParts)-1]
- deletedCount, err = s.deleteEvent(r.Context(), eventID)
+ deletedCount, err = s.deleteEvent(r.Context(), eventID, scope)
deletionType = "by_event_id"
} else {
writeJSON(w, http.StatusBadRequest, map[string]any{
@@ -147,15 +166,17 @@ func (s *collectorState) handleDeleteEvents(w http.ResponseWriter, r *http.Reque
})
}
-// deleteEventsByTenant deletes all events for a specific tenant
-func (s *collectorState) deleteEventsByTenant(ctx context.Context, tenantID string) (int64, error) {
+// deleteEventsByTenant deletes all events for a specific tenant.
+func (s *collectorState) deleteEventsByTenant(ctx context.Context, tenantID string, scope *publichttp.AuthorizedCollector) (int64, error) {
tableIdent, err := quoteSQLIdent(s.cfg.duckDBTable)
if err != nil {
return 0, err
}
- query := fmt.Sprintf(`DELETE FROM %s WHERE tenant_id = ?`, tableIdent)
+ predicate, ownershipArgs := ownershipDeletePredicate(scope)
+ query := fmt.Sprintf(`DELETE FROM %s WHERE tenant_id = ?%s`, tableIdent, predicate)
+ args := append([]any{tenantID}, ownershipArgs...)
- result, err := s.queryDB.ExecContext(ctx, query, tenantID)
+ result, err := s.queryDB.ExecContext(ctx, query, args...)
if err != nil {
return 0, err
}
@@ -168,8 +189,8 @@ func (s *collectorState) deleteEventsByTenant(ctx context.Context, tenantID stri
return rowsAffected, nil
}
-// deleteEventsByUser deletes all events for a specific user
-func (s *collectorState) deleteEventsByUser(ctx context.Context, userID string) (int64, error) {
+// deleteEventsByUser deletes all events for a specific user.
+func (s *collectorState) deleteEventsByUser(ctx context.Context, userID string, scope *publichttp.AuthorizedCollector) (int64, error) {
tableIdent, err := quoteSQLIdent(s.cfg.duckDBTable)
if err != nil {
return 0, err
@@ -181,9 +202,11 @@ func (s *collectorState) deleteEventsByUser(ctx context.Context, userID string)
return 0, err
}
}
- query := fmt.Sprintf(`DELETE FROM %s WHERE user_id = ? OR %s LIKE ? ESCAPE '\'`, tableIdent, rawIdent)
+ predicate, ownershipArgs := ownershipDeletePredicate(scope)
+ query := fmt.Sprintf(`DELETE FROM %s WHERE (user_id = ? OR %s LIKE ? ESCAPE '\')%s`, tableIdent, rawIdent, predicate)
+ args := append([]any{userID, fmt.Sprintf("%%\"user_id\":\"%s\"%%", escapeLIKE(userID))}, ownershipArgs...)
- result, err := s.queryDB.ExecContext(ctx, query, userID, fmt.Sprintf("%%\"user_id\":\"%s\"%%", escapeLIKE(userID)))
+ result, err := s.queryDB.ExecContext(ctx, query, args...)
if err != nil {
return 0, err
}
@@ -196,15 +219,17 @@ func (s *collectorState) deleteEventsByUser(ctx context.Context, userID string)
return rowsAffected, nil
}
-// deleteEvent deletes a specific event by ID
-func (s *collectorState) deleteEvent(ctx context.Context, eventID string) (int64, error) {
+// deleteEvent deletes a specific event by ID.
+func (s *collectorState) deleteEvent(ctx context.Context, eventID string, scope *publichttp.AuthorizedCollector) (int64, error) {
tableIdent, err := quoteSQLIdent(s.cfg.duckDBTable)
if err != nil {
return 0, err
}
- query := fmt.Sprintf(`DELETE FROM %s WHERE event_id = ?`, tableIdent)
+ predicate, ownershipArgs := ownershipDeletePredicate(scope)
+ query := fmt.Sprintf(`DELETE FROM %s WHERE event_id = ?%s`, tableIdent, predicate)
+ args := append([]any{eventID}, ownershipArgs...)
- result, err := s.queryDB.ExecContext(ctx, query, eventID)
+ result, err := s.queryDB.ExecContext(ctx, query, args...)
if err != nil {
return 0, err
}
diff --git a/collector/cmd/loza-collector/main_test.go b/collector/cmd/loza-collector/main_test.go
index b0c9047..08f6da4 100644
--- a/collector/cmd/loza-collector/main_test.go
+++ b/collector/cmd/loza-collector/main_test.go
@@ -71,6 +71,8 @@ func testCollectorConfig() collectorConfig {
cfg.retryMaxAttempts = 1
cfg.retryInitialBackoff = time.Millisecond
cfg.retryMaxBackoff = time.Millisecond
+ cfg.authDefaultCollector = "default"
+ cfg.authCollectors = []string{"default"}
return cfg
}
@@ -179,11 +181,11 @@ func TestConfiguredAdminKeyCanReadStatus(t *testing.T) {
cfg.authNegativeCacheTTL = time.Second
cfg.authKeys = []collectorAuthKey{
{
- name: "admin",
- keyID: "kadmin",
- secret: "adminsecret",
- kind: auth.KeyKindSecret,
- roles: []auth.Role{auth.RoleProjectAdmin},
+ name: "admin",
+ keyID: "kadmin",
+ secret: "adminsecret",
+ kind: auth.KeyKindSecret,
+ roles: []auth.Role{auth.RoleProjectAdmin},
},
}
state := &collectorState{
@@ -210,6 +212,64 @@ func TestConfiguredAdminKeyCanReadStatus(t *testing.T) {
}
}
+func TestConfiguredScopedKeyRestrictsCanonicalRoutes(t *testing.T) {
+ cfg := testCollectorConfig()
+ cfg.authEnabled = true
+ cfg.authServerSecret = "test-auth-server-secret"
+ cfg.authCacheTTL = time.Minute
+ cfg.authNegativeCacheTTL = time.Second
+ cfg.authCollectors = []string{"orders", "billing"}
+ cfg.authKeys = []collectorAuthKey{{
+ name: "orders-writer",
+ keyID: "korders",
+ secret: "orderssecret",
+ kind: auth.KeyKindSecret,
+ collector: "orders",
+ permissions: []auth.Permission{auth.PermEventsWrite},
+ allowedEnvs: []string{"production"},
+ }}
+ state := &collectorState{
+ cfg: cfg,
+ ingestSink: &fakeSink{},
+ rateLimiter: rate.NewLimiter(rate.Limit(1000), 1000),
+ }
+ state.ready.Store(true)
+ srv := httptest.NewServer(buildMux(state))
+ defer srv.Close()
+
+ request := func(method, path string) *http.Request {
+ req, err := http.NewRequest(method, srv.URL+path, strings.NewReader(`{"event":"scoped"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Authorization", "Bearer lx_sec_live_korders_orderssecret")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Loza-Env", "production")
+ return req
+ }
+ for _, tc := range []struct {
+ name string
+ method string
+ path string
+ want int
+ }{
+ {name: "authorized write", method: http.MethodPost, path: "/collectors/orders/events", want: http.StatusAccepted},
+ {name: "cross collector denied", method: http.MethodPost, path: "/collectors/billing/events", want: http.StatusForbidden},
+ {name: "ungranted read denied", method: http.MethodGet, path: "/collectors/orders/status", want: http.StatusForbidden},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ resp, err := http.DefaultClient.Do(request(tc.method, tc.path))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != tc.want {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, tc.want)
+ }
+ })
+ }
+}
+
func TestHandleIngestPartialSuccess(t *testing.T) {
sink := &fakeSink{}
cfg := testCollectorConfig()
diff --git a/collector/cmd/loza-collector/scoped_ownership_test.go b/collector/cmd/loza-collector/scoped_ownership_test.go
new file mode 100644
index 0000000..88a9ee9
--- /dev/null
+++ b/collector/cmd/loza-collector/scoped_ownership_test.go
@@ -0,0 +1,204 @@
+package main
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ publichttp "github.com/astraive/loza/collector/server/http"
+ _ "github.com/marcboeker/go-duckdb"
+)
+
+func scopedRequest(method, path, collector, environment, body string) *http.Request {
+ req := httptest.NewRequest(method, path, strings.NewReader(body))
+ req = req.WithContext(publichttp.WithAuthorizedCollector(req.Context(), collector, environment))
+ req.SetPathValue("collector", collector)
+ return req
+}
+
+func TestScopedIngestStampsAuthorizedOwnershipAndRejectsForgery(t *testing.T) {
+ cfg := testCollectorConfig()
+ cfg.rateLimitEnabled = false
+ sink := &fakeSink{}
+ state := &collectorState{
+ cfg: cfg,
+ ingestSink: sink,
+ }
+
+ accepted := scopedRequest(http.MethodPost, "/collectors/collector-a/events", "collector-a", "production", `{"event":"accepted"}`)
+ accepted.Header.Set("Content-Type", "application/json")
+ acceptedRecorder := httptest.NewRecorder()
+ state.handleIngest(acceptedRecorder, accepted)
+ if acceptedRecorder.Code != http.StatusAccepted {
+ t.Fatalf("expected scoped event to be accepted, got %d: %s", acceptedRecorder.Code, acceptedRecorder.Body.String())
+ }
+ if len(sink.events) != 1 {
+ t.Fatalf("expected one stored event, got %d", len(sink.events))
+ }
+ var stored map[string]any
+ if err := json.Unmarshal(sink.events[0], &stored); err != nil {
+ t.Fatalf("decode stored event: %v", err)
+ }
+ if stored[collectorOwnershipColumn] != "collector-a" || stored[environmentOwnershipColumn] != "production" {
+ t.Fatalf("stored event ownership = %#v, want collector-a/production", stored)
+ }
+
+ forged := scopedRequest(http.MethodPost, "/collectors/collector-a/events", "collector-a", "production", `{"event":"forged","collector":"collector-b","environment":"production"}`)
+ forged.Header.Set("Content-Type", "application/json")
+ forgedRecorder := httptest.NewRecorder()
+ state.handleIngest(forgedRecorder, forged)
+ if forgedRecorder.Code != http.StatusBadRequest {
+ t.Fatalf("expected forged ownership to be rejected, got %d: %s", forgedRecorder.Code, forgedRecorder.Body.String())
+ }
+ if len(sink.events) != 1 {
+ t.Fatalf("forged event was stored; got %d stored events", len(sink.events))
+ }
+}
+
+func TestScopedDeletionDoesNotRemoveAnotherCollectorsEvent(t *testing.T) {
+ db, err := sql.Open("duckdb", ":memory:")
+ if err != nil {
+ t.Fatalf("open duckdb: %v", err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`CREATE TABLE events (
+ event_id VARCHAR,
+ tenant_id VARCHAR,
+ user_id VARCHAR,
+ collector VARCHAR,
+ environment VARCHAR,
+ raw VARCHAR
+ )`); err != nil {
+ t.Fatalf("create events table: %v", err)
+ }
+ for _, collector := range []string{"collector-a", "collector-b"} {
+ if _, err := db.Exec(`INSERT INTO events (event_id, collector, environment) VALUES (?, ?, ?)`, "shared-event", collector, "production"); err != nil {
+ t.Fatalf("insert %s event: %v", collector, err)
+ }
+ }
+ state := &collectorState{
+ cfg: collectorConfig{
+ duckDBTable: "events",
+ },
+ queryDB: db,
+ }
+
+ req := scopedRequest(http.MethodDelete, "/collectors/collector-a/events/shared-event", "collector-a", "production", "")
+ rec := httptest.NewRecorder()
+ state.handleDeleteEvents(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected scoped deletion to succeed, got %d: %s", rec.Code, rec.Body.String())
+ }
+ var remainingA, remainingB int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM events WHERE collector = 'collector-a'`).Scan(&remainingA); err != nil {
+ t.Fatalf("count collector-a rows: %v", err)
+ }
+ if err := db.QueryRow(`SELECT COUNT(*) FROM events WHERE collector = 'collector-b'`).Scan(&remainingB); err != nil {
+ t.Fatalf("count collector-b rows: %v", err)
+ }
+ if remainingA != 0 || remainingB != 1 {
+ t.Fatalf("remaining rows collector-a=%d collector-b=%d, want 0 and 1", remainingA, remainingB)
+ }
+}
+
+func TestScopedRawSQLQueryIsRejected(t *testing.T) {
+ state := &collectorState{cfg: testCollectorConfig()}
+ req := scopedRequest(http.MethodPost, "/collectors/collector-a/query", "collector-a", "production", `{"sql":"SELECT * FROM events"}`)
+ rec := httptest.NewRecorder()
+
+ state.handleQuery(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected scoped raw SQL to be rejected, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ var response map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if response["error"] != "scoped_raw_sql_unsupported" {
+ t.Fatalf("error = %#v, want scoped_raw_sql_unsupported", response["error"])
+ }
+}
+
+func TestScopedQueryReadsOnlyAuthorizedOwnership(t *testing.T) {
+ db, err := sql.Open("duckdb", ":memory:")
+ if err != nil {
+ t.Fatalf("open duckdb: %v", err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`CREATE TABLE events (event_id VARCHAR, collector VARCHAR, environment VARCHAR)`); err != nil {
+ t.Fatalf("create events table: %v", err)
+ }
+ for _, row := range [][3]string{{"a-1", "collector-a", "production"}, {"b-1", "collector-b", "production"}} {
+ if _, err := db.Exec(`INSERT INTO events VALUES (?, ?, ?)`, row[0], row[1], row[2]); err != nil {
+ t.Fatalf("insert event: %v", err)
+ }
+ }
+ state := &collectorState{cfg: collectorConfig{duckDBTable: "events"}, queryDB: db}
+ rec := httptest.NewRecorder()
+ state.handleQuery(rec, scopedRequest(http.MethodPost, "/collectors/collector-a/query", "collector-a", "production", `{"limit":5}`))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected scoped read to succeed, got %d: %s", rec.Code, rec.Body.String())
+ }
+ if strings.Contains(rec.Body.String(), "b-1") || !strings.Contains(rec.Body.String(), "a-1") {
+ t.Fatalf("scoped query leaked or omitted rows: %s", rec.Body.String())
+ }
+}
+
+func TestEnsureSchemaMigratesOwnershipProjectionColumns(t *testing.T) {
+ db, err := sql.Open("duckdb", ":memory:")
+ if err != nil {
+ t.Fatalf("open duckdb: %v", err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`CREATE TABLE events (raw VARCHAR)`); err != nil {
+ t.Fatalf("create legacy events table: %v", err)
+ }
+
+ if err := ensureSchema(db, testCollectorConfig()); err != nil {
+ t.Fatalf("migrate schema: %v", err)
+ }
+
+ for _, column := range []string{collectorOwnershipColumn, environmentOwnershipColumn} {
+ var count int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'events' AND column_name = ?`, column).Scan(&count); err != nil {
+ t.Fatalf("check %s column: %v", column, err)
+ }
+ if count != 1 {
+ t.Fatalf("ownership column %q missing after migration", column)
+ }
+ }
+}
+
+func TestScopedTailAndReplayAreRejected(t *testing.T) {
+ state := &collectorState{cfg: testCollectorConfig()}
+ for _, tc := range []struct {
+ name string
+ method string
+ path string
+ body string
+ handle func(http.ResponseWriter, *http.Request)
+ }{
+ {name: "tail", method: http.MethodGet, path: "/collectors/collector-a/tail", handle: state.handleTail},
+ {name: "replay", method: http.MethodPost, path: "/collectors/collector-a/replay", body: `{"events":[]}`, handle: state.handleReplay},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ tc.handle(rec, scopedRequest(tc.method, tc.path, "collector-a", "production", tc.body))
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected scoped operation to be rejected, got %d: %s", rec.Code, rec.Body.String())
+ }
+ var response map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if response["error"] != "scoped_operation_unsupported" {
+ t.Fatalf("error = %#v, want scoped_operation_unsupported", response["error"])
+ }
+ })
+ }
+}
diff --git a/collector/cmd/loza-collector/server.go b/collector/cmd/loza-collector/server.go
index 7a9678c..6eaeac9 100644
--- a/collector/cmd/loza-collector/server.go
+++ b/collector/cmd/loza-collector/server.go
@@ -31,6 +31,8 @@ import (
)
func runCollector(cfg collectorConfig) error {
+ cfg = withOwnershipProjections(cfg)
+
var (
db *sql.DB
err error
@@ -459,6 +461,30 @@ func buildMux(state *collectorState) *http.ServeMux {
tailWSHandler := serverruntime.NewTailWebSocketHandler(state.cfg.serverConfig.HTTP, state)
var protector publichttp.RouteProtector
+ var authMW func(http.Handler) http.Handler
+ collectorProtector := func(next http.Handler, permission string, resolve publichttp.CollectorResolver, mode publichttp.CollectorRouteMode) http.Handler {
+ guard := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ collector, ok := resolve(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ environment := strings.TrimSpace(r.Header.Get("X-Loza-Env"))
+ if state.cfg.authEnabled && mode == publichttp.CanonicalCollectorRoute {
+ ac := auth.GetAuthContext(r.Context())
+ if ac == nil || !ac.AuthorizesCollector(collector, environment, auth.Permission(permission)) {
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+ }
+ ctx := publichttp.WithAuthorizedCollector(r.Context(), collector, environment)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ if state.cfg.authEnabled && mode == publichttp.CanonicalCollectorRoute {
+ return authMW(guard)
+ }
+ return guard
+ }
if state.cfg.authEnabled {
if state.keyStore == nil || state.keyCache == nil || len(state.serverSecret) == 0 {
if err := initializeAuthState(state); err != nil {
@@ -474,7 +500,7 @@ func buildMux(state *collectorState) *http.ServeMux {
trustedCIDRs = append(trustedCIDRs, ipNet)
}
- authMW := auth.Middleware(state.keyStore, state.keyCache, state.serverSecret,
+ authMW = auth.Middleware(state.keyStore, state.keyCache, state.serverSecret,
auth.WithAllowLocalDevKeys(state.cfg.authAllowLocalDevKeys),
auth.WithTrustedProxies(trustedCIDRs),
auth.WithRateLimiter(state.keyRateLimiter),
@@ -484,7 +510,19 @@ func buildMux(state *collectorState) *http.ServeMux {
}
}
- return publichttp.BuildMux(state.cfg.ingestPath, state.cfg.healthPath, state.cfg.readyPath, state.cfg.metricsPath, state.cfg.metricsPrometheus, state.metricsHandler(), tailWSHandler, state, protector)
+ return publichttp.BuildMux(
+ state.cfg.ingestPath,
+ state.cfg.healthPath,
+ state.cfg.readyPath,
+ state.cfg.metricsPath,
+ state.cfg.metricsPrometheus,
+ state.metricsHandler(),
+ tailWSHandler,
+ state,
+ protector,
+ collectorProtector,
+ state.cfg.authDefaultCollector,
+ )
}
func initializeAuthState(state *collectorState) error {
@@ -494,8 +532,8 @@ func initializeAuthState(state *collectorState) error {
if strings.TrimSpace(state.cfg.authServerSecret) == "" {
return errors.New("auth.enabled requires a resolved auth.server_secret")
}
- if len(state.cfg.authKeys) == 0 && strings.TrimSpace(state.cfg.apiKey) == "" {
- return errors.New("auth.enabled requires at least one configured key or auth.value")
+ if len(state.cfg.authKeys) == 0 && len(state.cfg.authTokens) == 0 && strings.TrimSpace(state.cfg.apiKey) == "" {
+ return errors.New("auth.enabled requires at least one configured key, token, or auth.value")
}
if state.cfg.authCacheTTL <= 0 || state.cfg.authNegativeCacheTTL <= 0 {
return errors.New("auth cache TTLs must be positive")
@@ -550,14 +588,28 @@ func (s *memoryKeyStore) RevokeKey(keyID string) error {
return nil
}
func newMemoryKeyStoreFromConfig(cfg collectorConfig, serverSecret []byte) *memoryKeyStore {
- store := &memoryKeyStore{keys: make(map[string]*auth.KeyRecord, len(cfg.authKeys)+1)}
+ store := &memoryKeyStore{keys: make(map[string]*auth.KeyRecord, len(cfg.authKeys)+len(cfg.authGrants)+len(cfg.authTokens)+1)}
for _, key := range cfg.authKeys {
+ var collectorGrants []auth.CollectorGrant
+ if key.collector != "" {
+ permissions := make(map[auth.Permission]bool, len(key.permissions))
+ for _, permission := range key.permissions {
+ permissions[permission] = true
+ }
+ collectorGrants = []auth.CollectorGrant{{
+ Collector: key.collector,
+ Environments: append([]string(nil), key.allowedEnvs...),
+ Permissions: permissions,
+ }}
+ }
store.keys[key.keyID] = &auth.KeyRecord{
ID: key.name,
KeyID: key.keyID,
SecretHash: auth.HashSecret(key.secret, serverSecret),
Kind: key.kind,
+ Mode: key.mode,
Roles: append([]auth.Role(nil), key.roles...),
+ CollectorGrants: collectorGrants,
AllowedEnvs: append([]string(nil), key.allowedEnvs...),
AllowedServices: append([]string(nil), key.allowedServices...),
AllowedOrigins: append([]string(nil), key.allowedOrigins...),
@@ -570,6 +622,67 @@ func newMemoryKeyStoreFromConfig(cfg collectorConfig, serverSecret []byte) *memo
store.keys[key.keyID].ID = key.keyID
}
}
+ for _, grant := range cfg.authGrants {
+ permissions := make(map[auth.Permission]bool, len(grant.permissions))
+ for _, permission := range grant.permissions {
+ permissions[permission] = true
+ }
+ store.keys[grant.keyID] = &auth.KeyRecord{
+ ID: grant.name,
+ KeyID: grant.keyID,
+ SecretHash: auth.HashSecret(grant.secret, serverSecret),
+ Kind: grant.kind,
+ CollectorGrants: []auth.CollectorGrant{{
+ Collector: grant.collector,
+ Environments: append([]string(nil), grant.allowedEnvs...),
+ Permissions: permissions,
+ }},
+ AllowedEnvs: append([]string(nil), grant.allowedEnvs...),
+ AllowedServices: append([]string(nil), grant.allowedServices...),
+ AllowedOrigins: append([]string(nil), grant.allowedOrigins...),
+ AllowedIPs: append([]string(nil), grant.allowedIPs...),
+ MaxPayloadBytes: grant.maxPayloadBytes,
+ MaxRequestsPerMinute: grant.maxRequestsPerMinute,
+ MaxEventsPerMinute: grant.maxEventsPerMinute,
+ }
+ if store.keys[grant.keyID].ID == "" {
+ store.keys[grant.keyID].ID = grant.keyID
+ }
+ }
+ for _, token := range cfg.authTokens {
+ tokenID := auth.TokenLookupID(token.token, serverSecret)
+ permissions := make(map[auth.Permission]bool, len(token.permissions))
+ for _, permission := range token.permissions {
+ permissions[permission] = true
+ }
+ var collectorGrants []auth.CollectorGrant
+ if token.collector != "" {
+ collectorGrants = []auth.CollectorGrant{{
+ Collector: token.collector,
+ Environments: append([]string(nil), token.allowedEnvs...),
+ Permissions: permissions,
+ }}
+ }
+ store.keys[tokenID] = &auth.KeyRecord{
+ ID: token.name,
+ KeyID: tokenID,
+ SecretHash: auth.HashSecret(token.token, serverSecret),
+ Kind: auth.KeyKindToken,
+ Mode: token.mode,
+ Roles: append([]auth.Role(nil), token.roles...),
+ CollectorGrants: collectorGrants,
+ AllowedEnvs: append([]string(nil), token.allowedEnvs...),
+ AllowedServices: append([]string(nil), token.allowedServices...),
+ AllowedOrigins: append([]string(nil), token.allowedOrigins...),
+ AllowedIPs: append([]string(nil), token.allowedIPs...),
+ MaxPayloadBytes: token.maxPayloadBytes,
+ MaxRequestsPerMinute: token.maxRequestsPerMinute,
+ MaxEventsPerMinute: token.maxEventsPerMinute,
+ }
+ if store.keys[tokenID].ID == "" {
+ store.keys[tokenID].ID = tokenID
+ }
+ }
if strings.TrimSpace(cfg.apiKey) == "" {
return store
@@ -594,7 +707,28 @@ func newMemoryKeyStoreFromConfig(cfg collectorConfig, serverSecret []byte) *memo
return store
}
+func withOwnershipProjections(cfg collectorConfig) collectorConfig {
+ schema := make(map[string]string, len(cfg.duckDBSchema)+2)
+ for column, path := range cfg.duckDBSchema {
+ schema[column] = path
+ }
+ schema[collectorOwnershipColumn] = collectorOwnershipColumn
+ schema[environmentOwnershipColumn] = environmentOwnershipColumn
+ cfg.duckDBSchema = schema
+
+ columnTypes := make(map[string]string, len(cfg.duckDBColumnTypes)+2)
+ for path, typ := range cfg.duckDBColumnTypes {
+ columnTypes[path] = typ
+ }
+ columnTypes[collectorOwnershipColumn] = "TEXT"
+ columnTypes[environmentOwnershipColumn] = "TEXT"
+ cfg.duckDBColumnTypes = columnTypes
+
+ return cfg
+}
+
func ensureSchema(db *sql.DB, cfg collectorConfig) error {
+ cfg = withOwnershipProjections(cfg)
columns := make([]string, 0, len(cfg.duckDBSchema)+1)
for col, path := range cfg.duckDBSchema {
colIdent, err := quoteSQLIdent(col)
diff --git a/collector/deploy/goreleaser.yml b/collector/deploy/goreleaser.yml
index 5b2b6f4..d3a4e01 100644
--- a/collector/deploy/goreleaser.yml
+++ b/collector/deploy/goreleaser.yml
@@ -1,5 +1,5 @@
project_name: loza
-version: 0.2.6
+version: 0.3.0
archives:
- format: tar.gz
builds:
diff --git a/collector/deploy/helm/loza/Chart.yaml b/collector/deploy/helm/loza/Chart.yaml
index 2906fda..efb33f1 100644
--- a/collector/deploy/helm/loza/Chart.yaml
+++ b/collector/deploy/helm/loza/Chart.yaml
@@ -2,8 +2,8 @@ apiVersion: v2
name: loza
description: Loza wide-event logging system
type: application
-version: 0.2.6
-appVersion: "0.2.6"
+version: 0.3.0
+appVersion: "0.3.0"
keywords:
- logging
- events
diff --git a/collector/deploy/helm/loza/values.yaml b/collector/deploy/helm/loza/values.yaml
index ac1dd15..cf5bdfc 100644
--- a/collector/deploy/helm/loza/values.yaml
+++ b/collector/deploy/helm/loza/values.yaml
@@ -15,7 +15,7 @@ collector:
image:
repository: loza
- tag: "0.2.6"
+ tag: "0.3.0"
digest: ""
pullPolicy: IfNotPresent
@@ -138,7 +138,7 @@ worker:
image:
repository: loza-worker
- tag: "0.2.6"
+ tag: "0.3.0"
digest: ""
pullPolicy: IfNotPresent
diff --git a/collector/deploy/k8s/collector-deployment.yaml b/collector/deploy/k8s/collector-deployment.yaml
index 54de015..b9f6dc6 100644
--- a/collector/deploy/k8s/collector-deployment.yaml
+++ b/collector/deploy/k8s/collector-deployment.yaml
@@ -23,7 +23,7 @@ spec:
fsGroup: 1000
containers:
- name: collector
- image: ghcr.io/astraive/loza:0.2.6
+ image: ghcr.io/astraive/loza:0.3.0
securityContext:
capabilities:
drop:
diff --git a/collector/internal/auth/context.go b/collector/internal/auth/context.go
index 908ee4e..ea6fbb3 100644
--- a/collector/internal/auth/context.go
+++ b/collector/internal/auth/context.go
@@ -4,6 +4,42 @@ import "context"
type authContextKey struct{}
+// CollectorGrant binds a principal to one named collector, its permitted
+// environments, and the collector-scoped actions it may perform.
+type CollectorGrant struct {
+ Collector string
+ Environments []string
+ Permissions map[Permission]bool
+}
+
+// permits reports whether this grant authorizes a specific collector request.
+// Empty collector or environment values are denied so callers cannot bypass
+// scope resolution by omitting one of the resource dimensions.
+func (g CollectorGrant) permits(collector, environment string, permission Permission) bool {
+ if collector == "" || environment == "" || g.Collector != collector || !g.Permissions[permission] {
+ return false
+ }
+ return contains(g.Environments, environment)
+}
+
+func cloneCollectorGrants(grants []CollectorGrant) []CollectorGrant {
+ if len(grants) == 0 {
+ return nil
+ }
+ cloned := make([]CollectorGrant, len(grants))
+ for i, grant := range grants {
+ cloned[i] = CollectorGrant{
+ Collector: grant.Collector,
+ Environments: append([]string(nil), grant.Environments...),
+ Permissions: make(map[Permission]bool, len(grant.Permissions)),
+ }
+ for permission, allowed := range grant.Permissions {
+ cloned[i].Permissions[permission] = allowed
+ }
+ }
+ return cloned
+}
+
// AuthContext holds the authenticated identity and authorization details
// for a request. Attached to the request context by the auth middleware.
type AuthContext struct {
@@ -12,19 +48,20 @@ type AuthContext struct {
APIKeyID string
KeyKind KeyKind
- Roles []Role
- Permissions map[Permission]bool
+ Roles []Role
+ Permissions map[Permission]bool
+ CollectorGrants []CollectorGrant
- AllowedEnvs []string
- AllowedServices []string
- AllowedOrigins []string
- AllowedIPs []string
- MaxPayloadBytes int
+ AllowedEnvs []string
+ AllowedServices []string
+ AllowedOrigins []string
+ AllowedIPs []string
+ MaxPayloadBytes int
MaxRequestsPerMinute int
- MaxEventsPerMinute int
- SamplingRate float64
- AllowPII bool
- AllowAttachments bool
+ MaxEventsPerMinute int
+ SamplingRate float64
+ AllowPII bool
+ AllowAttachments bool
}
// WithAuthContext attaches an AuthContext to the context.
@@ -59,3 +96,23 @@ func (ac *AuthContext) HasAnyPermission(perms ...Permission) bool {
}
return false
}
+
+// AuthorizesCollector verifies the complete server-resolved resource scope.
+// A principal without a matching collector grant is denied even when it holds
+// the underlying global permission. Role-bound credentials must satisfy both
+// their role and their resource scope; roleless legacy grants retain their
+// explicitly configured compatibility policy.
+func (ac *AuthContext) AuthorizesCollector(collector, environment string, permission Permission) bool {
+ if ac == nil {
+ return false
+ }
+ if len(ac.Roles) > 0 && !ac.HasPermission(permission) {
+ return false
+ }
+ for _, grant := range ac.CollectorGrants {
+ if grant.permits(collector, environment, permission) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/collector/internal/auth/context_test.go b/collector/internal/auth/context_test.go
new file mode 100644
index 0000000..a7ee982
--- /dev/null
+++ b/collector/internal/auth/context_test.go
@@ -0,0 +1,51 @@
+package auth
+
+import "testing"
+
+func TestAuthContextAuthorizesCollectorOnlyWithinGrantScope(t *testing.T) {
+ if (&AuthContext{}).AuthorizesCollector("payments", "prod", PermEventsWrite) {
+ t.Fatal("principal without collector binding must be denied")
+ }
+
+ context := &AuthContext{
+ CollectorGrants: []CollectorGrant{{
+ Collector: "payments",
+ Environments: []string{"prod"},
+ Permissions: map[Permission]bool{
+ PermEventsWrite: true,
+ },
+ }},
+ }
+
+ if !context.AuthorizesCollector("payments", "prod", PermEventsWrite) {
+ t.Fatal("expected matching collector grant to authorize request")
+ }
+ if context.AuthorizesCollector("analytics", "prod", PermEventsWrite) {
+ t.Fatal("cross-collector request must be denied")
+ }
+ if context.AuthorizesCollector("payments", "dev", PermEventsWrite) {
+ t.Fatal("ungranted environment must be denied")
+ }
+ if context.AuthorizesCollector("payments", "", PermEventsWrite) {
+ t.Fatal("missing environment must be denied")
+ }
+ if context.AuthorizesCollector("payments", "prod", PermEventsRead) {
+ t.Fatal("ungranted permission must be denied")
+ }
+}
+
+func TestAuthContextScopesCannotExceedCredentialRole(t *testing.T) {
+ context := &AuthContext{
+ Roles: []Role{RoleClient},
+ Permissions: ExpandRoles([]Role{RoleClient}),
+ CollectorGrants: []CollectorGrant{{
+ Collector: "logs",
+ Environments: []string{"prod"},
+ Permissions: map[Permission]bool{PermLogsDelete: true},
+ }},
+ }
+
+ if context.AuthorizesCollector("logs", "prod", PermLogsDelete) {
+ t.Fatal("client role must not escalate through a configured log scope")
+ }
+}
diff --git a/collector/internal/auth/keys.go b/collector/internal/auth/keys.go
index 6f966c2..5bd8da7 100644
--- a/collector/internal/auth/keys.go
+++ b/collector/internal/auth/keys.go
@@ -15,8 +15,39 @@ const (
KeyKindPublic KeyKind = "pub" // lx_pub_live_kxxx_yyyy
KeyKindSecret KeyKind = "sec" // lx_sec_live_kxxx_yyyy
KeyKindLocal KeyKind = "local" // lx_local_dev_yyyy (dev only)
+ KeyKindToken KeyKind = "token" // opaque Bearer token, stored by HMAC-derived ID
)
+const (
+ publicAccessIDPrefix = "lx_pub_"
+ minPublicAccessIDToken = 32
+ maxPublicAccessIDToken = 128
+)
+
+// IsPublicAccessID reports whether id is a valid opaque public Basic
+// credential. The random component is intentionally constrained to URL-safe
+// characters and a minimum entropy-bearing length; callers must never include
+// an invalid value in an error because the entire ID is a bearer capability.
+func IsPublicAccessID(id string) bool {
+ if !strings.HasPrefix(id, publicAccessIDPrefix) {
+ return false
+ }
+ token := id[len(publicAccessIDPrefix):]
+ if len(token) < minPublicAccessIDToken || len(token) > maxPublicAccessIDToken {
+ return false
+ }
+ for i := range len(token) {
+ c := token[i]
+ if !((c >= 'a' && c <= 'z') ||
+ (c >= 'A' && c <= 'Z') ||
+ (c >= '0' && c <= '9') ||
+ c == '-' || c == '_') {
+ return false
+ }
+ }
+ return true
+}
+
// ParsedKey holds the components of a parsed LOZA API key.
type ParsedKey struct {
Raw string
@@ -108,6 +139,13 @@ func HashSecret(secret string, serverSecret []byte) []byte {
return mac.Sum(nil)
}
+// TokenLookupID derives the opaque store identifier for a bearer token. The
+// token itself is never used as a map key or logged; the server secret makes
+// the identifier unguessable and prevents offline enumeration.
+func TokenLookupID(token string, serverSecret []byte) string {
+ return "tok_" + fmt.Sprintf("%x", HashSecret(token, serverSecret))
+}
+
// CompareSecret performs constant-time comparison of two secret hashes.
func CompareSecret(incoming, stored []byte) bool {
return subtle.ConstantTimeCompare(incoming, stored) == 1
diff --git a/collector/internal/auth/middleware.go b/collector/internal/auth/middleware.go
index 6147e4f..a32ab34 100644
--- a/collector/internal/auth/middleware.go
+++ b/collector/internal/auth/middleware.go
@@ -9,6 +9,7 @@ import (
"strings"
"sync"
"time"
+ "unicode"
)
// parsedCIDRNet holds a pre-parsed CIDR to avoid repeated net.ParseCIDR calls.
@@ -20,27 +21,30 @@ type parsedCIDRNet struct {
// cidrCache caches parsed CIDRs so they are only parsed once per unique string.
var cidrCache sync.Map // map[string]*parsedCIDRNet
-// KeyRecord holds the stored metadata for an API key.
+// KeyRecord holds the stored metadata for an API key or opaque bearer token.
type KeyRecord struct {
- ID string
- OrgID string
- ProjectID string
- KeyID string
- SecretHash []byte
- Kind KeyKind
- Roles []Role
- AllowedEnvs []string
- AllowedServices []string
- AllowedOrigins []string
- AllowedIPs []string
- MaxPayloadBytes int
+ ID string
+ OrgID string
+ ProjectID string
+ KeyID string
+ SecretHash []byte
+ Kind KeyKind
+ Mode AccessMode
+ Roles []Role
+ CollectorGrants []CollectorGrant
+
+ AllowedEnvs []string
+ AllowedServices []string
+ AllowedOrigins []string
+ AllowedIPs []string
+ MaxPayloadBytes int
MaxRequestsPerMinute int
- MaxEventsPerMinute int
- SamplingRate float64
- AllowPII bool
- AllowAttachments bool
- RevokedAt *time.Time
- ExpiresAt *time.Time
+ MaxEventsPerMinute int
+ SamplingRate float64
+ AllowPII bool
+ AllowAttachments bool
+ RevokedAt *time.Time
+ ExpiresAt *time.Time
}
// KeyStore is the interface for key storage backends.
@@ -79,8 +83,8 @@ func WithRateLimiter(rl *KeyRateLimiter) MiddlewareOption {
// — that's done per-route via RequirePermission.
//
// Validation flow:
-// 1. Parse Authorization header
-// 2. ParseKey → prefix, key_id, secret
+// 1. Parse Authorization header (Basic, Bearer, or X-API-Key)
+// 2. Map Basic username to key_id, or ParseKey for API-key credentials
// 3. Cache lookup by key_id
// 4. If cache miss: KeyStore.FindByKeyID()
// 5. Check revoked / expired
@@ -106,9 +110,9 @@ func Middleware(store KeyStore, cache *MemoryKeyCache, serverSecret []byte, opts
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- ac, errCode, errReason := authenticate(r, store, cache, serverSecret, rateLimiter, mc.allowLocalDevKeys, mc.trustedProxies)
+ ac, errCode, _ := authenticate(r, store, cache, serverSecret, rateLimiter, mc.allowLocalDevKeys, mc.trustedProxies)
if ac == nil {
- slog.Warn("auth_failure", "code", errCode, "reason", errReason, "path", r.URL.Path, "remote", r.RemoteAddr)
+ slog.Warn("auth_failure", "code", errCode, "path", r.URL.Path, "remote", r.RemoteAddr)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Auth-Failure-Code", "unauthorized")
w.Header().Set("X-Auth-Failure-Reason", "authentication required")
@@ -150,35 +154,58 @@ func MakeRouteProtector() func(next http.Handler, perm string) http.Handler {
}
func authenticate(r *http.Request, store KeyStore, cache *MemoryKeyCache, serverSecret []byte, rateLimiter *KeyRateLimiter, allowLocalDevKeys bool, trustedProxies []*net.IPNet) (*AuthContext, string, string) {
- // 1. Parse Authorization header
- raw := extractBearerToken(r)
- if raw == "" {
- return nil, "missing_token", "missing Authorization header"
- }
-
- // 2. Parse key
- parsed, err := ParseKey(raw)
- if err != nil {
- return nil, "invalid_key_format", err.Error()
- }
+ // 1. Parse Authorization header. Basic credentials use the username as
+ // the configured key ID and the password as the HMAC input. A public
+ // access ID is an intentionally passwordless bearer credential. Keep the
+ // existing Bearer/X-API-Key extraction path unchanged.
+ authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
+ var keyID, secret string
+ var keyKind KeyKind
+ isBasic := strings.HasPrefix(strings.ToLower(authHeader), "basic ")
+ if isBasic {
+ username, password, ok := r.BasicAuth()
+ if !ok || !validBasicUsername(username) {
+ return nil, "invalid_basic_auth", "invalid basic credentials"
+ }
+ keyID = username
+ secret = password
+ } else {
+ raw := extractBearerToken(r)
+ if raw == "" {
+ return nil, "missing_token", "missing Authorization header"
+ }
- // Handle local dev keys
- if parsed.Kind == KeyKindLocal {
- if !allowLocalDevKeys {
- slog.Warn("local dev key rejected (allow_local_dev_keys=false)", "key", parsed.Raw[:min(len(parsed.Raw), 20)]+"...")
- return nil, "local_dev_disabled", "local dev keys are not allowed in production mode"
+ // 2. Parse structured API keys. An unstructured Bearer credential is
+ // an opaque token: derive its server-secret-bound lookup ID instead
+ // of persisting or logging the raw token.
+ parsed, err := ParseKey(raw)
+ if err != nil {
+ keyID = TokenLookupID(raw, serverSecret)
+ secret = raw
+ keyKind = KeyKindToken
+ } else {
+ // Handle local dev keys
+ if parsed.Kind == KeyKindLocal {
+ if !allowLocalDevKeys {
+ slog.Warn("local dev key rejected (allow_local_dev_keys=false)")
+ return nil, "local_dev_disabled", "local dev keys are not allowed in production mode"
+ }
+ return &AuthContext{
+ KeyKind: KeyKindLocal,
+ Permissions: ExpandRoles([]Role{RoleIngestServer}),
+ Roles: []Role{RoleIngestServer},
+ }, "", ""
+ }
+ keyID = parsed.KeyID
+ secret = parsed.Secret
+ keyKind = parsed.Kind
}
- return &AuthContext{
- KeyKind: KeyKindLocal,
- Permissions: ExpandRoles([]Role{RoleIngestServer}),
- Roles: []Role{RoleIngestServer},
- }, "", ""
}
// 3. Cache lookup
var record *KeyRecord
if cache != nil {
- if cached, ok := cache.Get(parsed.KeyID); ok {
+ if cached, ok := cache.Get(keyID); ok {
record = cached
}
}
@@ -186,15 +213,30 @@ func authenticate(r *http.Request, store KeyStore, cache *MemoryKeyCache, server
// 4. Store lookup on cache miss
if record == nil {
var err error
- record, err = store.FindByKeyID(r.Context(), parsed.KeyID)
+ record, err = store.FindByKeyID(r.Context(), keyID)
if err != nil || record == nil {
if cache != nil {
- cache.SetNegative(parsed.KeyID)
+ cache.SetNegative(keyID)
}
return nil, "key_not_found", "invalid api key"
}
if cache != nil {
- cache.Set(parsed.KeyID, record, 0) // default TTL
+ cache.Set(keyID, record, 0) // default TTL
+ }
+ }
+
+ // Private Basic credentials require a password. A passwordless Basic
+ // credential is valid only for a syntactically valid public access ID.
+ if isBasic {
+ if record.Kind == KeyKindLocal {
+ return nil, "key_kind_mismatch", "api key kind mismatch"
+ }
+ if record.Kind == KeyKindPublic {
+ if secret != "" || !IsPublicAccessID(keyID) {
+ return nil, "invalid_basic_auth", "invalid basic credentials"
+ }
+ } else if secret == "" {
+ return nil, "invalid_basic_auth", "invalid basic credentials"
}
}
@@ -206,13 +248,13 @@ func authenticate(r *http.Request, store KeyStore, cache *MemoryKeyCache, server
return nil, "key_expired", "api key has expired"
}
- // 6. Verify prefix matches
- if string(record.Kind) != string(parsed.Kind) {
+ // 6. Verify prefix matches for API-key credentials.
+ if !isBasic && string(record.Kind) != string(keyKind) {
return nil, "key_kind_mismatch", "api key kind mismatch"
}
// 7. Verify secret hash
- incomingHash := HashSecret(parsed.Secret, serverSecret)
+ incomingHash := HashSecret(secret, serverSecret)
if !CompareSecret(incomingHash, record.SecretHash) {
return nil, "invalid_secret", "invalid api key"
}
@@ -220,22 +262,23 @@ func authenticate(r *http.Request, store KeyStore, cache *MemoryKeyCache, server
// 8. Build AuthContext
permissions := ExpandRoles(record.Roles)
ac := &AuthContext{
- OrgID: record.OrgID,
- ProjectID: record.ProjectID,
- APIKeyID: record.KeyID,
- KeyKind: record.Kind,
- Roles: record.Roles,
- Permissions: permissions,
- AllowedEnvs: record.AllowedEnvs,
- AllowedServices: record.AllowedServices,
- AllowedOrigins: record.AllowedOrigins,
- AllowedIPs: record.AllowedIPs,
- MaxPayloadBytes: record.MaxPayloadBytes,
+ OrgID: record.OrgID,
+ ProjectID: record.ProjectID,
+ APIKeyID: record.KeyID,
+ KeyKind: record.Kind,
+ Roles: record.Roles,
+ Permissions: permissions,
+ CollectorGrants: cloneCollectorGrants(record.CollectorGrants),
+ AllowedEnvs: record.AllowedEnvs,
+ AllowedServices: record.AllowedServices,
+ AllowedOrigins: record.AllowedOrigins,
+ AllowedIPs: record.AllowedIPs,
+ MaxPayloadBytes: record.MaxPayloadBytes,
MaxRequestsPerMinute: record.MaxRequestsPerMinute,
- MaxEventsPerMinute: record.MaxEventsPerMinute,
- SamplingRate: record.SamplingRate,
- AllowPII: record.AllowPII,
- AllowAttachments: record.AllowAttachments,
+ MaxEventsPerMinute: record.MaxEventsPerMinute,
+ SamplingRate: record.SamplingRate,
+ AllowPII: record.AllowPII,
+ AllowAttachments: record.AllowAttachments,
}
// Public key strict defaults
@@ -292,6 +335,18 @@ func authenticate(r *http.Request, store KeyStore, cache *MemoryKeyCache, server
return ac, "", ""
}
+func validBasicUsername(username string) bool {
+ if username == "" || strings.TrimSpace(username) != username {
+ return false
+ }
+ for _, r := range username {
+ if r == ':' || unicode.IsSpace(r) {
+ return false
+ }
+ }
+ return true
+}
+
func extractBearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
diff --git a/collector/internal/auth/middleware_test.go b/collector/internal/auth/middleware_test.go
index d9547c8..e886f7c 100644
--- a/collector/internal/auth/middleware_test.go
+++ b/collector/internal/auth/middleware_test.go
@@ -23,29 +23,29 @@ func newTestStore(serverSecret []byte) *testKeyStore {
return &testKeyStore{
keys: map[string]*KeyRecord{
"ksec1": {
- ID: "id_sec1",
- OrgID: "org_1",
- ProjectID: "proj_1",
- KeyID: "ksec1",
- SecretHash: secHash,
- Kind: KeyKindSecret,
- Roles: []Role{RoleIngestServer},
- AllowedEnvs: []string{"prod", "staging"},
- AllowedServices: []string{"checkout-api"},
- MaxPayloadBytes: 262144,
+ ID: "id_sec1",
+ OrgID: "org_1",
+ ProjectID: "proj_1",
+ KeyID: "ksec1",
+ SecretHash: secHash,
+ Kind: KeyKindSecret,
+ Roles: []Role{RoleIngestServer},
+ AllowedEnvs: []string{"prod", "staging"},
+ AllowedServices: []string{"checkout-api"},
+ MaxPayloadBytes: 262144,
MaxRequestsPerMinute: 1000,
},
"kpub1": {
- ID: "id_pub1",
- OrgID: "org_1",
- ProjectID: "proj_1",
- KeyID: "kpub1",
- SecretHash: pubHash,
- Kind: KeyKindPublic,
- Roles: []Role{RoleIngestPublic},
- AllowedEnvs: []string{"prod"},
- AllowedOrigins: []string{"https://app.example.com"},
- MaxPayloadBytes: 65536,
+ ID: "id_pub1",
+ OrgID: "org_1",
+ ProjectID: "proj_1",
+ KeyID: "kpub1",
+ SecretHash: pubHash,
+ Kind: KeyKindPublic,
+ Roles: []Role{RoleIngestPublic},
+ AllowedEnvs: []string{"prod"},
+ AllowedOrigins: []string{"https://app.example.com"},
+ MaxPayloadBytes: 65536,
MaxRequestsPerMinute: 100,
},
"krevoked": {
@@ -104,6 +104,83 @@ func TestMiddleware_ValidSecretKey(t *testing.T) {
}
}
+func TestMiddleware_ValidBasicAuth(t *testing.T) {
+ serverSecret := []byte("test-server-secret")
+ store := newTestStore(serverSecret)
+ cache := NewMemoryKeyCache(10*time.Second, 5*time.Second)
+ defer cache.Close()
+
+ mw := Middleware(store, cache, serverSecret)
+ handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ac := GetAuthContext(r.Context())
+ if ac == nil {
+ t.Fatal("expected auth context")
+ }
+ if ac.APIKeyID != "ksec1" {
+ t.Errorf("APIKeyID = %q, want %q", ac.APIKeyID, "ksec1")
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("POST", "/events", nil)
+ req.SetBasicAuth("ksec1", "testsecret")
+ req.Header.Set("X-Loza-Env", "prod")
+ req.Header.Set("X-Loza-Service", "checkout-api")
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
+
+func TestMiddleware_BasicAuthWrongPassword(t *testing.T) {
+ serverSecret := []byte("test-server-secret")
+ store := newTestStore(serverSecret)
+ cache := NewMemoryKeyCache(10*time.Second, 5*time.Second)
+ defer cache.Close()
+
+ mw := Middleware(store, cache, serverSecret)
+ handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("handler should not be called")
+ }))
+
+ req := httptest.NewRequest("POST", "/events", nil)
+ req.SetBasicAuth("ksec1", "wrong-password")
+ req.Header.Set("X-Loza-Env", "prod")
+ req.Header.Set("X-Loza-Service", "checkout-api")
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
+ }
+}
+
+func TestMiddleware_BasicAuthUnknownUsername(t *testing.T) {
+ serverSecret := []byte("test-server-secret")
+ store := newTestStore(serverSecret)
+ cache := NewMemoryKeyCache(10*time.Second, 5*time.Second)
+ defer cache.Close()
+
+ mw := Middleware(store, cache, serverSecret)
+ handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("handler should not be called")
+ }))
+
+ req := httptest.NewRequest("POST", "/events", nil)
+ req.SetBasicAuth("unknown-user", "testsecret")
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
+ }
+}
+
func TestMiddleware_ValidPublicKey(t *testing.T) {
serverSecret := []byte("test-server-secret")
store := newTestStore(serverSecret)
@@ -343,3 +420,36 @@ func TestRequirePermission_NoAuthContext(t *testing.T) {
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
+
+func TestMiddleware_PrivateTokenCarriesRBACPermissions(t *testing.T) {
+ serverSecret := []byte("test-server-secret")
+ token := "lxt_opaque_private_token_for_admin"
+ tokenID := TokenLookupID(token, serverSecret)
+ store := &testKeyStore{keys: map[string]*KeyRecord{
+ tokenID: {
+ ID: tokenID,
+ KeyID: tokenID,
+ SecretHash: HashSecret(token, serverSecret),
+ Kind: KeyKindToken,
+ Mode: ModePrivate,
+ Roles: []Role{RoleAdmin},
+ },
+ }}
+ cache := NewMemoryKeyCache(time.Minute, time.Second)
+ defer cache.Close()
+
+ handler := Middleware(store, cache, serverSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !GetAuthContext(r.Context()).HasPermission(PermLogsDelete) {
+ t.Fatal("admin token must carry logs:delete")
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ request := httptest.NewRequest(http.MethodDelete, "/logs/1", nil)
+ request.Header.Set("Authorization", "Bearer "+token)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, request)
+ if recorder.Code != http.StatusNoContent {
+ t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent)
+ }
+}
diff --git a/collector/internal/auth/roles.go b/collector/internal/auth/roles.go
index 0fcacc5..9ac18dd 100644
--- a/collector/internal/auth/roles.go
+++ b/collector/internal/auth/roles.go
@@ -9,6 +9,8 @@ const (
PermEventsDelete Permission = "events:delete"
PermLogsWrite Permission = "logs:write"
PermLogsRead Permission = "logs:read"
+ PermLogsEdit Permission = "logs:edit"
+ PermLogsDelete Permission = "logs:delete"
PermTracesWrite Permission = "traces:write"
PermTracesRead Permission = "traces:read"
PermMetricsWrite Permission = "metrics:write"
@@ -21,6 +23,15 @@ const (
PermProjectAdmin Permission = "project:admin"
)
+// AccessMode describes whether a credential can be exposed to an untrusted
+// client. Public credentials are intentionally restricted to RoleClient.
+type AccessMode string
+
+const (
+ ModePublic AccessMode = "public"
+ ModePrivate AccessMode = "private"
+)
+
// Role represents a named bundle of permissions.
type Role string
@@ -31,11 +42,35 @@ const (
RoleProjectReadonly Role = "project_readonly"
RoleProjectOperator Role = "project_operator"
RoleProjectAdmin Role = "project_admin"
+ RoleUser Role = "user"
+ RoleClient Role = "client"
+ RoleAdmin Role = "admin"
+ RoleSuperAdmin Role = "superadmin"
)
// rolePermissions maps each role to its allowed permissions.
// Default-deny: unknown roles get no permissions.
var rolePermissions = map[Role][]Permission{
+ RoleUser: {
+ PermLogsRead,
+ },
+ RoleClient: {
+ PermLogsRead,
+ PermLogsWrite,
+ },
+ RoleAdmin: {
+ PermLogsRead,
+ PermLogsWrite,
+ PermLogsEdit,
+ PermLogsDelete,
+ },
+ RoleSuperAdmin: {
+ PermLogsRead,
+ PermLogsWrite,
+ PermLogsEdit,
+ PermLogsDelete,
+ PermProjectAdmin,
+ },
RoleIngestPublic: {
PermEventsWrite,
PermHeartbeatWrite,
@@ -113,3 +148,24 @@ func ExpandRoles(roles []Role) map[Permission]bool {
}
return result
}
+
+// IsKnownRole reports whether role resolves to an explicit, default-deny
+// permission bundle.
+func IsKnownRole(role Role) bool {
+ _, ok := rolePermissions[role]
+ return ok
+}
+
+// RoleAllowedInMode keeps bearer credentials safe for browser use: public
+// credentials may grant only the least-privileged client role. Private
+// credentials may use every known role.
+func RoleAllowedInMode(role Role, mode AccessMode) bool {
+ switch mode {
+ case ModePublic:
+ return role == RoleClient
+ case ModePrivate:
+ return IsKnownRole(role)
+ default:
+ return false
+ }
+}
diff --git a/collector/internal/auth/roles_test.go b/collector/internal/auth/roles_test.go
new file mode 100644
index 0000000..21da910
--- /dev/null
+++ b/collector/internal/auth/roles_test.go
@@ -0,0 +1,45 @@
+package auth
+
+import "testing"
+
+func TestProductRolesExposeLogCapabilities(t *testing.T) {
+ tests := []struct {
+ role Role
+ allowed []Permission
+ notAllowed []Permission
+ }{
+ {RoleUser, []Permission{PermLogsRead}, []Permission{PermLogsWrite, PermLogsEdit, PermLogsDelete}},
+ {RoleClient, []Permission{PermLogsRead, PermLogsWrite}, []Permission{PermLogsEdit, PermLogsDelete}},
+ {RoleAdmin, []Permission{PermLogsRead, PermLogsWrite, PermLogsEdit, PermLogsDelete}, nil},
+ {RoleSuperAdmin, []Permission{PermLogsRead, PermLogsWrite, PermLogsEdit, PermLogsDelete, PermProjectAdmin}, nil},
+ }
+
+ for _, tt := range tests {
+ t.Run(string(tt.role), func(t *testing.T) {
+ for _, permission := range tt.allowed {
+ if !tt.role.HasPermission(permission) {
+ t.Errorf("%s must permit %s", tt.role, permission)
+ }
+ }
+ for _, permission := range tt.notAllowed {
+ if tt.role.HasPermission(permission) {
+ t.Errorf("%s must not permit %s", tt.role, permission)
+ }
+ }
+ })
+ }
+}
+
+func TestPublicModeRejectsPrivilegedRoles(t *testing.T) {
+ if !RoleAllowedInMode(RoleClient, ModePublic) {
+ t.Fatal("client must be usable by public credentials")
+ }
+ for _, role := range []Role{RoleUser, RoleAdmin, RoleSuperAdmin} {
+ if RoleAllowedInMode(role, ModePublic) {
+ t.Errorf("public mode must reject %s", role)
+ }
+ }
+ if !RoleAllowedInMode(RoleSuperAdmin, ModePrivate) {
+ t.Fatal("private mode must support superadmin")
+ }
+}
diff --git a/collector/internal/config/schema.go b/collector/internal/config/schema.go
index 810d92a..d6f2b30 100644
--- a/collector/internal/config/schema.go
+++ b/collector/internal/config/schema.go
@@ -13,30 +13,30 @@ import (
)
type Config struct {
- Version string `yaml:"version"`
- Collector CollectorConfig `yaml:"collector"`
- Auth AuthConfig `yaml:"auth"`
- RateLimit RateLimitConfig `yaml:"rate_limit"`
- Routes RoutesConfig `yaml:"routes"`
- Storage StorageConfig `yaml:"storage"`
- DuckDB DuckDBConfig `yaml:"duckdb"`
- Retention RetentionConfig `yaml:"retention"`
- Kafka KafkaConfig `yaml:"kafka"`
- Worker WorkerConfig `yaml:"worker"`
- Logging LoggingConfig `yaml:"logging"`
- Metrics MetricsConfig `yaml:"metrics"`
- Reliability ReliabilityConfig `yaml:"reliability"`
- Limits LimitsConfig `yaml:"limits"`
- Identity IdentityConfig `yaml:"identity"`
- Privacy PrivacyConfig `yaml:"privacy"`
- Components ComponentRegistryConfig `yaml:"components"`
- Retry RetryConfig `yaml:"retry"`
- DeadLetter DeadLetterConfig `yaml:"dead_letter"`
- Fanout FanoutConfig `yaml:"fanout"`
- Dedupe DedupeConfig `yaml:"dedupe"`
- Schema SchemaGovernanceConfig `yaml:"schema_governance"`
- CortexBridge CortexBridgeConfig `yaml:"cortex_bridge"`
- EventBus EventBusConfig `yaml:"eventbus"`
+ Version string `yaml:"version"`
+ Collector CollectorConfig `yaml:"collector"`
+ Auth AuthConfig `yaml:"auth"`
+ RateLimit RateLimitConfig `yaml:"rate_limit"`
+ Routes RoutesConfig `yaml:"routes"`
+ Storage StorageConfig `yaml:"storage"`
+ DuckDB DuckDBConfig `yaml:"duckdb"`
+ Retention RetentionConfig `yaml:"retention"`
+ Kafka KafkaConfig `yaml:"kafka"`
+ Worker WorkerConfig `yaml:"worker"`
+ Logging LoggingConfig `yaml:"logging"`
+ Metrics MetricsConfig `yaml:"metrics"`
+ Reliability ReliabilityConfig `yaml:"reliability"`
+ Limits LimitsConfig `yaml:"limits"`
+ Identity IdentityConfig `yaml:"identity"`
+ Privacy PrivacyConfig `yaml:"privacy"`
+ Components ComponentRegistryConfig `yaml:"components"`
+ Retry RetryConfig `yaml:"retry"`
+ DeadLetter DeadLetterConfig `yaml:"dead_letter"`
+ Fanout FanoutConfig `yaml:"fanout"`
+ Dedupe DedupeConfig `yaml:"dedupe"`
+ Schema SchemaGovernanceConfig `yaml:"schema_governance"`
+ CortexBridge CortexBridgeConfig `yaml:"cortex_bridge"`
+ EventBus EventBusConfig `yaml:"eventbus"`
}
type CollectorConfig struct {
@@ -99,23 +99,77 @@ type GraphQLConfig struct {
}
type AuthConfig struct {
- Enabled bool `yaml:"enabled"`
- Header string `yaml:"header"`
- ValueEnv string `yaml:"value_env"`
- Value string `yaml:"value"`
- AllowLocalDevKeys bool `yaml:"allow_local_dev_keys"`
- ServerSecret string `yaml:"server_secret"`
- CacheTTL time.Duration `yaml:"cache_ttl"`
- NegativeCacheTTL time.Duration `yaml:"negative_cache_ttl"`
- Keys []AuthKeyConfig `yaml:"keys"`
+ Enabled bool `yaml:"enabled"`
+ Header string `yaml:"header"`
+ ValueEnv string `yaml:"value_env"`
+ Value string `yaml:"value"`
+ AllowLocalDevKeys bool `yaml:"allow_local_dev_keys"`
+ ServerSecret string `yaml:"server_secret"`
+ CacheTTL time.Duration `yaml:"cache_ttl"`
+ NegativeCacheTTL time.Duration `yaml:"negative_cache_ttl"`
+ DefaultCollector string `yaml:"default_collector"`
+ Collectors []AuthCollectorConfig `yaml:"collectors"`
+ Grants []AuthGrantConfig `yaml:"grants"`
+ Keys []AuthKeyConfig `yaml:"keys"`
+ Tokens []AuthTokenConfig `yaml:"tokens"`
+}
+
+// AuthCollectorConfig declares a collector that may be targeted by a grant.
+// Slugs are validated by the command configuration loader.
+type AuthCollectorConfig struct {
+ Slug string `yaml:"slug"`
+}
+
+// AuthGrantConfig binds one credential to one named collector. A private grant
+// uses username and password_env; a public grant resolves an opaque public ID
+// from public_id_env with no password. Public access IDs are bearer capabilities
+// and must not be logged or included in validation errors.
+type AuthGrantConfig struct {
+ Name string `yaml:"name"`
+ Collector string `yaml:"collector"`
+ Username string `yaml:"username"`
+ PasswordEnv string `yaml:"password_env"`
+ PublicIDEnv string `yaml:"public_id_env"`
+ Permissions []string `yaml:"permissions"`
+ AllowedEnvs []string `yaml:"allowed_envs"`
+ AllowedServices []string `yaml:"allowed_services"`
+ AllowedOrigins []string `yaml:"allowed_origins"`
+ AllowedIPs []string `yaml:"allowed_ips"`
+ MaxPayloadBytes int `yaml:"max_payload_bytes"`
+ MaxRequestsPerMinute int `yaml:"max_requests_per_minute"`
+ MaxEventsPerMinute int `yaml:"max_events_per_minute"`
}
+// AuthKeyConfig defines an API-key credential. Collector and Permissions bind
+// the key to one collector resource for canonical data-plane routes. Both must
+// be supplied together; unscoped keys are retained only for legacy root routes.
type AuthKeyConfig struct {
Name string `yaml:"name"`
KeyID string `yaml:"key_id"`
SecretEnv string `yaml:"secret_env"`
Kind string `yaml:"kind"`
+ Mode string `yaml:"mode"`
+ Roles []string `yaml:"roles"`
+ Collector string `yaml:"collector"`
+ Permissions []string `yaml:"permissions"`
+ AllowedEnvs []string `yaml:"allowed_envs"`
+ AllowedServices []string `yaml:"allowed_services"`
+ AllowedOrigins []string `yaml:"allowed_origins"`
+ AllowedIPs []string `yaml:"allowed_ips"`
+ MaxPayloadBytes int `yaml:"max_payload_bytes"`
+ MaxRequestsPerMinute int `yaml:"max_requests_per_minute"`
+ MaxEventsPerMinute int `yaml:"max_events_per_minute"`
+}
+
+// AuthTokenConfig defines an opaque Bearer token. Token values are sourced
+// only from an environment variable and are stored by an HMAC-derived ID.
+type AuthTokenConfig struct {
+ Name string `yaml:"name"`
+ TokenEnv string `yaml:"token_env"`
+ Mode string `yaml:"mode"`
Roles []string `yaml:"roles"`
+ Collector string `yaml:"collector"`
+ Permissions []string `yaml:"permissions"`
AllowedEnvs []string `yaml:"allowed_envs"`
AllowedServices []string `yaml:"allowed_services"`
AllowedOrigins []string `yaml:"allowed_origins"`
@@ -145,30 +199,30 @@ type StorageConfig struct {
}
type DuckDBConfig struct {
- Path string `yaml:"path"`
- Driver string `yaml:"driver"`
- Table string `yaml:"table"`
- RawColumn string `yaml:"raw_column"`
- StoreRaw bool `yaml:"store_raw"`
- CheckpointOnShutdown bool `yaml:"checkpoint_on_shutdown"`
- CheckpointInterval time.Duration `yaml:"checkpoint_interval"`
- MaxOpenConns int `yaml:"max_open_conns"`
- MaxIdleConns int `yaml:"max_idle_conns"`
- BatchSize int `yaml:"batch_size"`
- FlushInterval time.Duration `yaml:"flush_interval"`
- WriterLoop bool `yaml:"writer_loop"`
- WriterQueueSize int `yaml:"writer_queue_size"`
+ Path string `yaml:"path"`
+ Driver string `yaml:"driver"`
+ Table string `yaml:"table"`
+ RawColumn string `yaml:"raw_column"`
+ StoreRaw bool `yaml:"store_raw"`
+ CheckpointOnShutdown bool `yaml:"checkpoint_on_shutdown"`
+ CheckpointInterval time.Duration `yaml:"checkpoint_interval"`
+ MaxOpenConns int `yaml:"max_open_conns"`
+ MaxIdleConns int `yaml:"max_idle_conns"`
+ BatchSize int `yaml:"batch_size"`
+ FlushInterval time.Duration `yaml:"flush_interval"`
+ WriterLoop bool `yaml:"writer_loop"`
+ WriterQueueSize int `yaml:"writer_queue_size"`
// UseAppender enables DuckDB Appender API for higher throughput when available.
- UseAppender bool `yaml:"use_appender"`
+ UseAppender bool `yaml:"use_appender"`
// WriteTimeout bounds background writes when no caller deadline is present.
- WriteTimeout time.Duration `yaml:"write_timeout"`
+ WriteTimeout time.Duration `yaml:"write_timeout"`
// RetryAttempts controls retries for transient DB errors.
- RetryAttempts int `yaml:"retry_attempts"`
+ RetryAttempts int `yaml:"retry_attempts"`
// RetryBackoff is the backoff between retry attempts.
- RetryBackoff time.Duration `yaml:"retry_backoff"`
- Export DuckDBExport `yaml:"export"`
- Schema map[string]string `yaml:"schema"`
- ColumnTypes map[string]string `yaml:"column_types"`
+ RetryBackoff time.Duration `yaml:"retry_backoff"`
+ Export DuckDBExport `yaml:"export"`
+ Schema map[string]string `yaml:"schema"`
+ ColumnTypes map[string]string `yaml:"column_types"`
}
type DuckDBExport struct {
@@ -424,14 +478,14 @@ type CortexBridgeConfig struct {
}
type EventBusConfig struct {
- Type string `yaml:"type"`
- Topic string `yaml:"topic"`
- DLQTopic string `yaml:"dlq_topic"`
- ConsumerGroup string `yaml:"consumer_group"`
- Memory EBMemoryConfig `yaml:"memory"`
- Redis EBRedisConfig `yaml:"redis"`
- NATS EBNATSConfig `yaml:"nats"`
- Kafka EBKafkaConfig `yaml:"kafka"`
+ Type string `yaml:"type"`
+ Topic string `yaml:"topic"`
+ DLQTopic string `yaml:"dlq_topic"`
+ ConsumerGroup string `yaml:"consumer_group"`
+ Memory EBMemoryConfig `yaml:"memory"`
+ Redis EBRedisConfig `yaml:"redis"`
+ NATS EBNATSConfig `yaml:"nats"`
+ Kafka EBKafkaConfig `yaml:"kafka"`
}
type EBMemoryConfig struct {
diff --git a/collector/internal/version/version.go b/collector/internal/version/version.go
index 178f038..3babf31 100644
--- a/collector/internal/version/version.go
+++ b/collector/internal/version/version.go
@@ -6,7 +6,7 @@ import (
"sync"
)
-const fallbackVersion = "0.2.6"
+const fallbackVersion = "0.3.0"
var (
once sync.Once
diff --git a/collector/loza-collector.defaults.yaml b/collector/loza-collector.defaults.yaml
index a0bbe59..c9d19e4 100644
--- a/collector/loza-collector.defaults.yaml
+++ b/collector/loza-collector.defaults.yaml
@@ -42,12 +42,14 @@ auth:
key_id: kingest
secret_env: COLLECTOR_INGEST_KEY_SECRET
kind: sec
+ mode: private
roles:
- collector_ingest_server
- name: collector-admin
key_id: kadmin
secret_env: COLLECTOR_ADMIN_KEY_SECRET
kind: sec
+ mode: private
roles:
- project_admin
diff --git a/collector/loza.yaml b/collector/loza.yaml
index 0131f5e..2871414 100644
--- a/collector/loza.yaml
+++ b/collector/loza.yaml
@@ -1,6 +1,6 @@
name: loza-collector
kind: docker
-version: 0.2.6
+version: 0.3.0
description: High-performance event collector with DuckDB storage
license: MIT
repository: https://github.com/Astraive/loza
diff --git a/collector/server/http/server.go b/collector/server/http/server.go
index a3a8ec0..a5c419f 100644
--- a/collector/server/http/server.go
+++ b/collector/server/http/server.go
@@ -1,6 +1,8 @@
package httpserver
import (
+ "context"
+ "encoding/json"
"net/http"
"os"
"strings"
@@ -51,31 +53,103 @@ type PublicHandlerSet interface {
// The perm string is the required permission name (e.g., "events:write").
type RouteProtector func(next http.Handler, perm string) http.Handler
-// BuildMux constructs the HTTP route table. When protect is non-nil, protected
-// routes are wrapped with per-route permission checks. Public routes (health,
-// ready, version, status) are never wrapped.
-func BuildMux(ingestPath, healthPath, readyPath, metricsPath string, metricsEnabled bool, metricsHandler http.Handler, tailWebSocketHandler http.Handler, handlers PublicHandlerSet, protect RouteProtector) *http.ServeMux {
+// CollectorResolver resolves the target collector for a data-plane request.
+// It returns false when the request has no authorized collector mapping.
+type CollectorResolver func(*http.Request) (string, bool)
+
+// CollectorRouteMode identifies whether a data route uses canonical resource
+// authorization or its explicitly configured legacy default mapping.
+type CollectorRouteMode uint8
+
+const (
+ CanonicalCollectorRoute CollectorRouteMode = iota
+ LegacyCollectorRoute
+)
+
+// CollectorRouteProtector authorizes a route after resolving its collector.
+// Implementations must attach the resolved collector and environment only after
+// authorization succeeds. Canonical routes require collector grants; legacy
+// routes retain the configured default collector's compatibility policy.
+type CollectorRouteProtector func(next http.Handler, perm string, resolve CollectorResolver, mode CollectorRouteMode) http.Handler
+
+// AuthorizedCollector is the server-authorized resource scope for a request.
+// It is never populated from event or query payload fields.
+type AuthorizedCollector struct {
+ Name string
+ Environment string
+}
+
+type authorizedCollectorContextKey struct{}
+
+// WithAuthorizedCollector attaches an already-authorized collector scope.
+func WithAuthorizedCollector(ctx context.Context, collector, environment string) context.Context {
+ return context.WithValue(ctx, authorizedCollectorContextKey{}, AuthorizedCollector{
+ Name: collector,
+ Environment: environment,
+ })
+}
+
+// AuthorizedCollectorFromContext returns the collector scope authorized for the
+// current request. It is absent before route authorization succeeds.
+func AuthorizedCollectorFromContext(ctx context.Context) (AuthorizedCollector, bool) {
+ scope, ok := ctx.Value(authorizedCollectorContextKey{}).(AuthorizedCollector)
+ return scope, ok
+}
+
+// BuildMux constructs the HTTP route table. Collector data routes are
+// registered under /collectors/{collector}/... and passed through
+// collectorProtect. Legacy routes are registered only when defaultCollector is
+// explicitly configured, and are bound to that collector by the resolver.
+// Public routes (health, ready, version) are never wrapped.
+func BuildMux(ingestPath, healthPath, readyPath, metricsPath string, metricsEnabled bool, metricsHandler http.Handler, tailWebSocketHandler http.Handler, handlers PublicHandlerSet, protect RouteProtector, collectorProtect CollectorRouteProtector, defaultCollector string) *http.ServeMux {
mux := http.NewServeMux()
+ defaultCollector = strings.TrimSpace(defaultCollector)
+ scopedOperationUnsupported := func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": "scoped_operation_unsupported"})
+ }
- // Helper: register a route, optionally protected
- route := func(method, pattern string, h http.HandlerFunc, perm string) {
- if protect != nil && perm != "" {
- mux.Handle(method+" "+pattern, protect(h, perm))
- } else {
- mux.HandleFunc(method+" "+pattern, h)
+ // registerDataRoute registers the canonical collector-scoped endpoint and,
+ // only during an explicit migration, its legacy default-collector equivalent.
+ registerDataRoute := func(method, pattern string, canonical, legacy http.Handler, routePerm, collectorPerm string) {
+ wrap := func(h http.Handler, resolve CollectorResolver, mode CollectorRouteMode) http.Handler {
+ next := h
+ if collectorProtect != nil {
+ next = collectorProtect(next, collectorPerm, resolve, mode)
+ }
+ // The historical permission vocabulary remains only on explicitly
+ // configured legacy routes. Canonical routes use collector grants.
+ if mode == LegacyCollectorRoute && protect != nil && routePerm != "" {
+ next = protect(next, routePerm)
+ }
+ return next
+ }
+
+ mux.Handle(method+" /collectors/{collector}"+pattern, wrap(canonical, func(r *http.Request) (string, bool) {
+ collector := strings.TrimSpace(r.PathValue("collector"))
+ return collector, collector != ""
+ }, CanonicalCollectorRoute))
+ if defaultCollector != "" {
+ mux.Handle(method+" "+pattern, wrap(legacy, func(*http.Request) (string, bool) {
+ return defaultCollector, true
+ }, LegacyCollectorRoute))
}
}
+ dataRoute := func(method, pattern string, h http.Handler, routePerm, collectorPerm string) {
+ registerDataRoute(method, pattern, h, h, routePerm, collectorPerm)
+ }
// ── Ingest (events:write) ────────────────────────────────────────────
if ingestPath != "" && ingestPath != "/events" && ingestPath != "/ingest" {
- route("POST", ingestPath, handlers.HandleIngest, "events:write")
+ dataRoute("POST", ingestPath, http.HandlerFunc(handlers.HandleIngest), "events:write", "events:write")
}
- route("POST", "/events", handlers.HandleIngest, "events:write")
- route("POST", "/ingest", handlers.HandleIngest, "events:write")
- route("POST", "/events/batch", handlers.HandleIngest, "events:write")
- route("POST", "/events/ndjson", handlers.HandleIngest, "events:write")
- route("POST", "/validate", handlers.HandleValidate, "schema:read")
- route("POST", "/otlp/logs", handlers.HandleOTLPLogs, "logs:write")
+ dataRoute("POST", "/events", http.HandlerFunc(handlers.HandleIngest), "events:write", "events:write")
+ dataRoute("POST", "/ingest", http.HandlerFunc(handlers.HandleIngest), "events:write", "events:write")
+ dataRoute("POST", "/events/batch", http.HandlerFunc(handlers.HandleIngest), "events:write", "events:write")
+ dataRoute("POST", "/events/ndjson", http.HandlerFunc(handlers.HandleIngest), "events:write", "events:write")
+ dataRoute("POST", "/validate", http.HandlerFunc(handlers.HandleValidate), "schema:read", "events:read")
+ registerDataRoute("POST", "/otlp/logs", http.HandlerFunc(scopedOperationUnsupported), http.HandlerFunc(handlers.HandleOTLPLogs), "logs:write", "logs:write")
// ── Public (no auth) ─────────────────────────────────────────────────
if healthPath != "" && healthPath != "/health" && healthPath != "/healthz" {
@@ -89,56 +163,74 @@ func BuildMux(ingestPath, healthPath, readyPath, metricsPath string, metricsEnab
mux.HandleFunc("GET /ready", handlers.HandleReady)
mux.HandleFunc("GET /readyz", handlers.HandleReady)
mux.HandleFunc("GET /version", handlers.HandleVersion)
- route("GET", "/status", handlers.HandleStatus, "status:read")
+ dataRoute("GET", "/status", http.HandlerFunc(handlers.HandleStatus), "status:read", "events:read")
// ── Read endpoints ───────────────────────────────────────────────────
- route("GET", "/sinks", handlers.HandleSinks, "events:read")
- route("GET", "/sinks/{name}", handlers.HandleSink, "events:read")
- route("POST", "/sinks/{name}/test", handlers.HandleSinkTest, "events:write")
- route("GET", "/schema", handlers.HandleSchemaList, "schema:read")
- route("POST", "/schema/check", handlers.HandleSchemaCheck, "schema:read")
- route("POST", "/schema/diff", handlers.HandleSchemaDiff, "schema:read")
- route("POST", "/query", handlers.HandleQuery, "events:read")
- route("POST", "/lql/query", handlers.HandleLQLQuery, "events:read")
- route("GET", "/schema/blueprint", handlers.HandleBlueprintList, "schema:read")
+ dataRoute("GET", "/sinks", http.HandlerFunc(handlers.HandleSinks), "events:read", "events:read")
+ dataRoute("GET", "/sinks/{name}", http.HandlerFunc(handlers.HandleSink), "events:read", "events:read")
+ dataRoute("POST", "/sinks/{name}/test", http.HandlerFunc(handlers.HandleSinkTest), "events:write", "events:write")
+ dataRoute("GET", "/schema", http.HandlerFunc(handlers.HandleSchemaList), "schema:read", "events:read")
+ dataRoute("POST", "/schema/check", http.HandlerFunc(handlers.HandleSchemaCheck), "schema:read", "events:read")
+ dataRoute("POST", "/schema/diff", http.HandlerFunc(handlers.HandleSchemaDiff), "schema:read", "events:read")
+ dataRoute("POST", "/query", http.HandlerFunc(handlers.HandleQuery), "events:read", "events:read")
+ registerDataRoute(
+ "POST",
+ "/lql/query",
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": "scoped_raw_sql_unsupported"})
+ }),
+ http.HandlerFunc(handlers.HandleLQLQuery),
+ "events:read",
+ "events:read",
+ )
+ dataRoute("GET", "/schema/blueprint", http.HandlerFunc(handlers.HandleBlueprintList), "schema:read", "events:read")
// ── Tail (events:read) ───────────────────────────────────────────────
- route("GET", "/tail", handlers.HandleTail, "events:read")
+ dataRoute("GET", "/tail", http.HandlerFunc(handlers.HandleTail), "events:read", "events:read")
// ── Write endpoints ──────────────────────────────────────────────────
- route("POST", "/schema/publish", handlers.HandleSchemaPublish, "schema:write")
- route("POST", "/schema/blueprint", handlers.HandleBlueprintPublish, "schema:write")
+ dataRoute("POST", "/schema/publish", http.HandlerFunc(handlers.HandleSchemaPublish), "schema:write", "project:admin")
+ dataRoute("POST", "/schema/blueprint", http.HandlerFunc(handlers.HandleBlueprintPublish), "schema:write", "project:admin")
// ── Replay ───────────────────────────────────────────────────────────
- route("POST", "/replay", handlers.HandleReplay, "events:write")
+ dataRoute("POST", "/replay", http.HandlerFunc(handlers.HandleReplay), "events:write", "events:write")
// ── Admin endpoints ──────────────────────────────────────────────────
- route("POST", "/audit/pii", handlers.HandlePIIAudit, "pii_audit:read")
- route("POST", "/policy/validate", handlers.HandlePolicyValidate, "schema:read")
- route("POST", "/retention/apply", handlers.HandleRetentionApply, "project:admin")
- route("POST", "/keys", handlers.HandleKeyCreate, "project:admin")
- route("POST", "/keys/{id}/revoke", handlers.HandleKeyRevoke, "project:admin")
- route("DELETE", "/keys/{id}", handlers.HandleKeyRevoke, "project:admin")
- route("POST", "/keys/{id}/rotate", handlers.HandleKeyRotate, "project:admin")
- route("DELETE", "/events", handlers.HandleDeleteEvents, "events:delete")
- route("DELETE", "/events/by-tenant/{tenant_id}", handlers.HandleDeleteEvents, "events:delete")
- route("DELETE", "/events/by-user/{user_id}", handlers.HandleDeleteEvents, "events:delete")
- route("DELETE", "/events/{event_id}", handlers.HandleDeleteEvents, "events:delete")
+ registerDataRoute("POST", "/audit/pii", http.HandlerFunc(scopedOperationUnsupported), http.HandlerFunc(handlers.HandlePIIAudit), "pii_audit:read", "project:admin")
+ dataRoute("POST", "/policy/validate", http.HandlerFunc(handlers.HandlePolicyValidate), "schema:read", "project:admin")
+ registerDataRoute("POST", "/retention/apply", http.HandlerFunc(scopedOperationUnsupported), http.HandlerFunc(handlers.HandleRetentionApply), "project:admin", "project:admin")
+ dataRoute("POST", "/keys", http.HandlerFunc(handlers.HandleKeyCreate), "project:admin", "project:admin")
+ dataRoute("POST", "/keys/{id}/revoke", http.HandlerFunc(handlers.HandleKeyRevoke), "project:admin", "project:admin")
+ dataRoute("DELETE", "/keys/{id}", http.HandlerFunc(handlers.HandleKeyRevoke), "project:admin", "project:admin")
+ dataRoute("POST", "/keys/{id}/rotate", http.HandlerFunc(handlers.HandleKeyRotate), "project:admin", "project:admin")
+ dataRoute("DELETE", "/events", http.HandlerFunc(handlers.HandleDeleteEvents), "events:delete", "events:delete")
+ dataRoute("DELETE", "/events/by-tenant/{tenant_id}", http.HandlerFunc(handlers.HandleDeleteEvents), "events:delete", "events:delete")
+ dataRoute("DELETE", "/events/by-user/{user_id}", http.HandlerFunc(handlers.HandleDeleteEvents), "events:delete", "events:delete")
+ dataRoute("DELETE", "/events/{event_id}", http.HandlerFunc(handlers.HandleDeleteEvents), "events:delete", "events:delete")
// ── DLQ ──────────────────────────────────────────────────────────────
- route("GET", "/dlq", handlers.HandleDLQList, "events:read")
- route("POST", "/dlq/replay", handlers.HandleDLQReplayAll, "events:write")
- route("GET", "/dlq/{id}", handlers.HandleDLQShow, "events:read")
- route("POST", "/dlq/{id}/replay", handlers.HandleDLQReplay, "events:write")
- route("DELETE", "/dlq/{id}", handlers.HandleDLQDelete, "events:delete")
+ dataRoute("GET", "/dlq", http.HandlerFunc(handlers.HandleDLQList), "events:read", "events:read")
+ dataRoute("POST", "/dlq/replay", http.HandlerFunc(handlers.HandleDLQReplayAll), "events:write", "events:write")
+ dataRoute("GET", "/dlq/{id}", http.HandlerFunc(handlers.HandleDLQShow), "events:read", "events:read")
+ dataRoute("POST", "/dlq/{id}/replay", http.HandlerFunc(handlers.HandleDLQReplay), "events:write", "events:write")
+ dataRoute("DELETE", "/dlq/{id}", http.HandlerFunc(handlers.HandleDLQDelete), "events:delete", "events:delete")
// ── WebSocket tail ───────────────────────────────────────────────────
if tailWebSocketHandler != nil {
- if protect != nil {
- mux.Handle("GET /ws/tail", protect(tailWebSocketHandler, "events:read"))
- } else {
- mux.Handle("GET /ws/tail", tailWebSocketHandler)
- }
+ registerDataRoute(
+ "GET",
+ "/ws/tail",
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": "scoped_operation_unsupported"})
+ }),
+ tailWebSocketHandler,
+ "events:read",
+ "events:read",
+ )
}
// ── Metrics (protected by default) ───────────────────────────────────
diff --git a/collector/server/http/server_test.go b/collector/server/http/server_test.go
new file mode 100644
index 0000000..d8b413a
--- /dev/null
+++ b/collector/server/http/server_test.go
@@ -0,0 +1,147 @@
+package httpserver
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+type routeTestHandlers struct {
+ scope AuthorizedCollector
+ seen bool
+}
+
+func (h *routeTestHandlers) handle(w http.ResponseWriter, r *http.Request) {
+ h.scope, h.seen = AuthorizedCollectorFromContext(r.Context())
+ w.WriteHeader(http.StatusOK)
+}
+
+func (h *routeTestHandlers) HandleIngest(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleOTLPLogs(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleHealth(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleReady(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleVersion(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleStatus(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleValidate(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleSinks(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleSink(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleSinkTest(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleSchemaList(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleSchemaDiff(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleSchemaCheck(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleSchemaPublish(w http.ResponseWriter, r *http.Request) {
+ h.handle(w, r)
+}
+func (h *routeTestHandlers) HandleBlueprintPublish(w http.ResponseWriter, r *http.Request) {
+ h.handle(w, r)
+}
+func (h *routeTestHandlers) HandleBlueprintList(w http.ResponseWriter, r *http.Request) {
+ h.handle(w, r)
+}
+func (h *routeTestHandlers) HandleQuery(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleLQLQuery(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandlePIIAudit(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandlePolicyValidate(w http.ResponseWriter, r *http.Request) {
+ h.handle(w, r)
+}
+func (h *routeTestHandlers) HandleRetentionApply(w http.ResponseWriter, r *http.Request) {
+ h.handle(w, r)
+}
+func (h *routeTestHandlers) HandleKeyCreate(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleKeyRevoke(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleKeyRotate(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleDeleteEvents(w http.ResponseWriter, r *http.Request) {
+ h.handle(w, r)
+}
+func (h *routeTestHandlers) HandleDLQList(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleDLQReplayAll(w http.ResponseWriter, r *http.Request) {
+ h.handle(w, r)
+}
+func (h *routeTestHandlers) HandleDLQShow(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleDLQReplay(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleDLQDelete(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleTail(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+func (h *routeTestHandlers) HandleReplay(w http.ResponseWriter, r *http.Request) { h.handle(w, r) }
+
+func TestBuildMuxCollectorScopedAuthorization(t *testing.T) {
+ handlers := &routeTestHandlers{}
+ protect := func(next http.Handler, _ string) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer test" {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+ collectorProtect := func(next http.Handler, _ string, resolve CollectorResolver, mode CollectorRouteMode) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if mode == CanonicalCollectorRoute && r.Header.Get("Authorization") != "Bearer test" {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ collector, ok := resolve(r)
+ if mode == CanonicalCollectorRoute && (!ok || collector != "orders" || r.Header.Get("X-Loza-Env") != "dev") {
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r.WithContext(WithAuthorizedCollector(r.Context(), collector, "dev")))
+ })
+ }
+ mux := BuildMux("", "/health", "/ready", "/metrics", false, nil, nil, handlers, protect, collectorProtect, "")
+
+ request := func(path string, headers map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, path, nil)
+ for name, value := range headers {
+ req.Header.Set(name, value)
+ }
+ response := httptest.NewRecorder()
+ mux.ServeHTTP(response, req)
+ return response
+ }
+
+ response := request("/collectors/orders/events", map[string]string{"Authorization": "Bearer test", "X-Loza-Env": "dev"})
+ if response.Code != http.StatusOK {
+ t.Fatalf("authorized scoped write status = %d, want %d", response.Code, http.StatusOK)
+ }
+ if !handlers.seen || handlers.scope.Name != "orders" || handlers.scope.Environment != "dev" {
+ t.Fatalf("handler scope = %#v (seen %t), want authorized orders/dev", handlers.scope, handlers.seen)
+ }
+ if response = request("/collectors/payments/events", map[string]string{"Authorization": "Bearer test", "X-Loza-Env": "dev"}); response.Code != http.StatusForbidden {
+ t.Fatalf("wrong collector status = %d, want %d", response.Code, http.StatusForbidden)
+ }
+ if response = request("/collectors/orders/events", map[string]string{"Authorization": "Bearer test", "X-Loza-Env": "prod"}); response.Code != http.StatusForbidden {
+ t.Fatalf("wrong environment status = %d, want %d", response.Code, http.StatusForbidden)
+ }
+ if response = request("/collectors/orders/events", nil); response.Code != http.StatusUnauthorized {
+ t.Fatalf("missing canonical credentials status = %d, want %d", response.Code, http.StatusUnauthorized)
+ }
+ if response = request("/events", map[string]string{"Authorization": "Bearer test", "X-Loza-Env": "dev"}); response.Code != http.StatusNotFound {
+ t.Fatalf("unscoped write without default status = %d, want %d", response.Code, http.StatusNotFound)
+ }
+
+ health := httptest.NewRecorder()
+ mux.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/health", nil))
+ if health.Code != http.StatusOK {
+ t.Fatalf("public health status = %d, want %d", health.Code, http.StatusOK)
+ }
+}
+
+func TestBuildMuxUsesLogWriteScopeForCanonicalOTLPLogs(t *testing.T) {
+ handlers := &routeTestHandlers{}
+ var requiredPermission string
+ collectorProtect := func(next http.Handler, permission string, _ CollectorResolver, _ CollectorRouteMode) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requiredPermission = permission
+ next.ServeHTTP(w, r)
+ })
+ }
+ mux := BuildMux("", "/health", "/ready", "/metrics", false, nil, nil, handlers, nil, collectorProtect, "")
+
+ response := httptest.NewRecorder()
+ mux.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/collectors/logs/otlp/logs", nil))
+ if requiredPermission != "logs:write" {
+ t.Fatalf("canonical OTLP log permission = %q, want logs:write", requiredPermission)
+ }
+}
diff --git a/conformance/results/collector-conformance-20260527T145952Z.json b/conformance/results/collector-conformance-20260527T145952Z.json
deleted file mode 100644
index 526561d..0000000
--- a/conformance/results/collector-conformance-20260527T145952Z.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "suite": "collector-conformance",
- "component": "collector",
- "timestamp": "20260527T145952Z",
- "results": [],
- "summary": {
- "total": 0,
- "passed": 0,
- "failed": 0
- }
-}
\ No newline at end of file
diff --git a/conformance/run-all.sh b/conformance/run-all.sh
index 340b43f..a18ad60 100644
--- a/conformance/run-all.sh
+++ b/conformance/run-all.sh
@@ -8,6 +8,11 @@ RESULTS_DIR="$SCRIPT_DIR/results"
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$RESULTS_DIR"
+PYTHON_BIN="${PYTHON_BIN:-python3}"
+if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
+ PYTHON_BIN="python"
+fi
+
echo "=== LOZA Conformance Suite ==="
echo "Timestamp: $TIMESTAMP"
echo "Results: $RESULTS_DIR"
@@ -20,18 +25,18 @@ SKIPPED=0
# --- Cross-SDK Conformance via spec runner ---
echo "--- Cross-SDK Conformance ---"
RUNNER="$REPO_ROOT/spec/conformance/runner.py"
-if command -v python3 &>/dev/null && [ -f "$RUNNER" ]; then
+if command -v "$PYTHON_BIN" >/dev/null 2>&1 && [ -f "$RUNNER" ]; then
SDK_CONFORMANCE_OUT="$RESULTS_DIR/sdk-conformance-${TIMESTAMP}.json"
- if python3 "$RUNNER" --sdk all --verbose --output "$SDK_CONFORMANCE_OUT" 2>/dev/null; then
+ if "$PYTHON_BIN" "$RUNNER" --sdk all --verbose --json > "$SDK_CONFORMANCE_OUT" 2>/dev/null; then
echo " PASS: sdk-conformance"
- ((PASSED++))
+ PASSED=$((PASSED + 1))
else
echo " FAIL: sdk-conformance"
- ((FAILED++))
+ FAILED=$((FAILED + 1))
fi
else
echo " SKIP: sdk-conformance (runner not found or python3 missing)"
- ((SKIPPED++))
+ SKIPPED=$((SKIPPED + 1))
fi
# --- Collector Sink Conformance ---
@@ -40,7 +45,7 @@ if command -v go &>/dev/null; then
COLL_CONFORMANCE_OUT="$RESULTS_DIR/collector-conformance-${TIMESTAMP}.json"
cd "$REPO_ROOT/collector"
if go test ./internal/sinks/conformance/... -json -count=1 2>/dev/null | \
- python3 -c "
+ "$PYTHON_BIN" -c "
import sys, json
results = []
passed = 0
@@ -64,34 +69,34 @@ json.dump({
}, sys.stdout, indent=2)
" > "$COLL_CONFORMANCE_OUT" 2>/dev/null; then
echo " PASS: collector-conformance"
- ((PASSED++))
+ PASSED=$((PASSED + 1))
else
echo " FAIL: collector-conformance"
- ((FAILED++))
+ FAILED=$((FAILED + 1))
fi
cd "$REPO_ROOT"
else
echo " SKIP: collector-conformance (go not found)"
- ((SKIPPED++))
+ SKIPPED=$((SKIPPED + 1))
fi
# --- Python SDK Verifier (105 subchecks) ---
echo "--- Python SDK Verifier ---"
VERIFY="$REPO_ROOT/spec/conformance/verify.py"
-if command -v python3 &>/dev/null && [ -f "$VERIFY" ]; then
+if command -v "$PYTHON_BIN" >/dev/null 2>&1 && [ -f "$VERIFY" ]; then
PY_VERIFY_OUT="$RESULTS_DIR/py-verify-${TIMESTAMP}.json"
cd "$REPO_ROOT"
- if python3 "$VERIFY" --json > "$PY_VERIFY_OUT" 2>/dev/null; then
+ if "$PYTHON_BIN" "$VERIFY" --json > "$PY_VERIFY_OUT" 2>/dev/null; then
echo " PASS: py-verify"
- ((PASSED++))
+ PASSED=$((PASSED + 1))
else
echo " FAIL: py-verify"
- ((FAILED++))
+ FAILED=$((FAILED + 1))
fi
cd "$REPO_ROOT"
else
echo " SKIP: py-verify (verify.py not found or python3 missing)"
- ((SKIPPED++))
+ SKIPPED=$((SKIPPED + 1))
fi
# --- Summary ---
diff --git a/cortex/configs/cortex-deployment.yaml b/cortex/configs/cortex-deployment.yaml
index 55cef19..0b3b49e 100644
--- a/cortex/configs/cortex-deployment.yaml
+++ b/cortex/configs/cortex-deployment.yaml
@@ -23,7 +23,7 @@ spec:
fsGroup: 1000
containers:
- name: cortex
- image: ghcr.io/astraive/loza-cortex:0.2.6
+ image: ghcr.io/astraive/loza-cortex:0.3.0
securityContext:
capabilities:
drop:
diff --git a/cortex/configs/docker-compose.yml b/cortex/configs/docker-compose.yml
index 3f4fbf3..1498f95 100644
--- a/cortex/configs/docker-compose.yml
+++ b/cortex/configs/docker-compose.yml
@@ -52,7 +52,7 @@ services:
cortex-cli:
profiles: ["cli"]
- image: astraive/loza-cli:0.2.6
+ image: astraive/loza-cli:0.3.0
environment:
LOZA_CORTEX_URL: http://cortex:9312
depends_on:
diff --git a/cortex/configs/k8s.yaml b/cortex/configs/k8s.yaml
index 655d586..967c675 100644
--- a/cortex/configs/k8s.yaml
+++ b/cortex/configs/k8s.yaml
@@ -16,7 +16,7 @@ spec:
spec:
containers:
- name: cortex
- image: astraive/loza-cortex:0.2.6
+ image: astraive/loza-cortex:0.3.0
ports:
- containerPort: 9312
name: http
diff --git a/cortex/crates/cortex-match/Cargo.lock b/cortex/crates/cortex-match/Cargo.lock
index 86aad6c..45797be 100644
--- a/cortex/crates/cortex-match/Cargo.lock
+++ b/cortex/crates/cortex-match/Cargo.lock
@@ -110,7 +110,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cortex-match"
-version = "0.2.6"
+version = "0.3.0"
dependencies = [
"criterion",
"serde",
diff --git a/cortex/crates/cortex-match/Cargo.toml b/cortex/crates/cortex-match/Cargo.toml
index 370517a..7d56ff5 100644
--- a/cortex/crates/cortex-match/Cargo.toml
+++ b/cortex/crates/cortex-match/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "cortex-match"
-version = "0.2.6"
+version = "0.3.0"
edition = "2021"
description = "High-performance incident shape matching for LOZA Cortex"
authors = ["LOZA Team"]
diff --git a/cortex/deploy/helm/cortex/Chart.yaml b/cortex/deploy/helm/cortex/Chart.yaml
index 64e8d65..cb30e14 100644
--- a/cortex/deploy/helm/cortex/Chart.yaml
+++ b/cortex/deploy/helm/cortex/Chart.yaml
@@ -2,8 +2,8 @@ apiVersion: v2
name: cortex
description: Loza Cortex — incident reconstruction and operational memory engine
type: application
-version: 0.2.6
-appVersion: "0.2.6"
+version: 0.3.0
+appVersion: "0.3.0"
keywords:
- observability
- incident-response
diff --git a/cortex/deploy/helm/cortex/values.yaml b/cortex/deploy/helm/cortex/values.yaml
index b240915..1a854d8 100644
--- a/cortex/deploy/helm/cortex/values.yaml
+++ b/cortex/deploy/helm/cortex/values.yaml
@@ -9,7 +9,7 @@ replicaCount: 2
image:
repository: loza-cortex
- tag: "0.2.6"
+ tag: "0.3.0"
digest: ""
pullPolicy: IfNotPresent
diff --git a/cortex/internal/config/config.go b/cortex/internal/config/config.go
index 2c471d3..3a1afe7 100644
--- a/cortex/internal/config/config.go
+++ b/cortex/internal/config/config.go
@@ -59,17 +59,17 @@ type MatcherWeights struct {
}
type MatcherWeightsTopo struct {
- Symptom float64 `yaml:"symptom"`
- Temporal float64 `yaml:"temporal"`
+ Symptom float64 `yaml:"symptom"`
+ Temporal float64 `yaml:"temporal"`
Resolution float64 `yaml:"resolution"`
}
type TemporalConfig struct {
- DefaultSimilarity float64 `yaml:"default_similarity"`
- SameSpeedScore float64 `yaml:"same_speed_score"`
- ZeroDurationScore float64 `yaml:"zero_duration_score"`
- FastThresholdMs int64 `yaml:"fast_threshold_ms"`
- SlowThresholdMs int64 `yaml:"slow_threshold_ms"`
+ DefaultSimilarity float64 `yaml:"default_similarity"`
+ SameSpeedScore float64 `yaml:"same_speed_score"`
+ ZeroDurationScore float64 `yaml:"zero_duration_score"`
+ FastThresholdMs int64 `yaml:"fast_threshold_ms"`
+ SlowThresholdMs int64 `yaml:"slow_threshold_ms"`
}
// ServerConfig contains HTTP server settings
@@ -93,18 +93,18 @@ type GRPCConfig struct {
// StorageConfig contains database settings
type StorageConfig struct {
- Backend string `yaml:"backend"`
- DuckDB DuckDBConfig `yaml:"duckdb"`
- PostgreSQL PostgresConfig `yaml:"postgresql"`
- CollectorDBPath string `yaml:"collector_db_path"`
- MinSimilarityThreshold float64 `yaml:"min_similarity_threshold"`
- Similarity StorageSimilarityConfig `yaml:"similarity"`
+ Backend string `yaml:"backend"`
+ DuckDB DuckDBConfig `yaml:"duckdb"`
+ PostgreSQL PostgresConfig `yaml:"postgresql"`
+ CollectorDBPath string `yaml:"collector_db_path"`
+ MinSimilarityThreshold float64 `yaml:"min_similarity_threshold"`
+ Similarity StorageSimilarityConfig `yaml:"similarity"`
}
type StorageSimilarityConfig struct {
- ShapePartial float64 `yaml:"shape_partial"`
- ShapeMismatch float64 `yaml:"shape_mismatch"`
- Weights SimilarityWeights `yaml:"weights"`
+ ShapePartial float64 `yaml:"shape_partial"`
+ ShapeMismatch float64 `yaml:"shape_mismatch"`
+ Weights SimilarityWeights `yaml:"weights"`
}
type SimilarityWeights struct {
@@ -171,28 +171,28 @@ type IngestionConfig struct {
// ReconstructorConfig contains reconstruction settings
type ReconstructorConfig struct {
- Fast ReconstructionMode `yaml:"fast"`
- Deep ReconstructionMode `yaml:"deep"`
- Confidence ConfidenceConfig `yaml:"confidence"`
- Graph ReconstructorGraphConfig `yaml:"graph"`
- Explain ExplainConfig `yaml:"explain"`
- SignalScore SignalScoreConfig `yaml:"signal_score"`
+ Fast ReconstructionMode `yaml:"fast"`
+ Deep ReconstructionMode `yaml:"deep"`
+ Confidence ConfidenceConfig `yaml:"confidence"`
+ Graph ReconstructorGraphConfig `yaml:"graph"`
+ Explain ExplainConfig `yaml:"explain"`
+ SignalScore SignalScoreConfig `yaml:"signal_score"`
}
type ReconstructionMode struct {
- MaxDepth int `yaml:"max_depth"`
- MaxEvents int `yaml:"max_events"`
+ MaxDepth int `yaml:"max_depth"`
+ MaxEvents int `yaml:"max_events"`
TimeWindow time.Duration `yaml:"time_window"`
}
type ConfidenceConfig struct {
- Base float64 `yaml:"base"`
- CausalChainBonus float64 `yaml:"causal_chain_bonus"`
- SymptomBonus float64 `yaml:"symptom_bonus"`
- SimilarityWeight float64 `yaml:"similarity_weight"`
- RemediationWeight float64 `yaml:"remediation_weight"`
- MaxConfidence float64 `yaml:"max_confidence"`
- MinConfidence float64 `yaml:"min_confidence"`
+ Base float64 `yaml:"base"`
+ CausalChainBonus float64 `yaml:"causal_chain_bonus"`
+ SymptomBonus float64 `yaml:"symptom_bonus"`
+ SimilarityWeight float64 `yaml:"similarity_weight"`
+ RemediationWeight float64 `yaml:"remediation_weight"`
+ MaxConfidence float64 `yaml:"max_confidence"`
+ MinConfidence float64 `yaml:"min_confidence"`
}
type ReconstructorGraphConfig struct {
@@ -200,8 +200,8 @@ type ReconstructorGraphConfig struct {
}
type ExplainConfig struct {
- MaxKeyFindings int `yaml:"max_key_findings"`
- MaxAlternatives int `yaml:"max_alternatives"`
+ MaxKeyFindings int `yaml:"max_key_findings"`
+ MaxAlternatives int `yaml:"max_alternatives"`
}
type SignalScoreConfig struct {
@@ -229,7 +229,7 @@ type MemoryConfig struct {
// LearnerConfig contains continuous learning settings
type LearnerConfig struct {
- LearningRate float64 `yaml:"learning_rate"`
+ LearningRate float64 `yaml:"learning_rate"`
FeatureWeightMin float64 `yaml:"feature_weight_min"`
FeatureWeightMax float64 `yaml:"feature_weight_max"`
}
diff --git a/cortex/internal/middleware/auth.go b/cortex/internal/middleware/auth.go
index bb50bb7..2b2b83c 100644
--- a/cortex/internal/middleware/auth.go
+++ b/cortex/internal/middleware/auth.go
@@ -15,16 +15,16 @@ import (
)
type Auth struct {
- cfg *config.AuthenticationConfig
- keyCache map[string]*hashedAPIKey
+ cfg *config.AuthenticationConfig
+ keyCache map[string]*hashedAPIKey
hmacSecret []byte
- once sync.Once
+ once sync.Once
}
type hashedAPIKey struct {
- Name string
- KeyHash []byte
- Role string
+ Name string
+ KeyHash []byte
+ Role string
}
// AutoGenerateHMACSecret derives a deterministic HMAC key from the configured API keys.
@@ -80,9 +80,9 @@ func hmacSHA256(data, secret []byte) []byte {
type AuthResult struct {
Authorized bool
- KeyName string
- Role string
- Failure string
+ KeyName string
+ Role string
+ Failure string
FailureCode string
}
@@ -254,11 +254,11 @@ func logAuthJSON(level, event string, fields map[string]interface{}) {
func (a *Auth) logAuthFailure(r *http.Request, keyName, reason, code string) {
logAuthJSON("warn", "auth_failure", map[string]interface{}{
- "key_name": keyName,
+ "key_name": keyName,
"path": r.URL.Path,
"method": r.Method,
"remote_addr": r.RemoteAddr,
"reason": reason,
"failure_code": code,
})
-}
\ No newline at end of file
+}
diff --git a/cortex/loza-cortex.yaml b/cortex/loza-cortex.yaml
index 92f1b79..dab4658 100644
--- a/cortex/loza-cortex.yaml
+++ b/cortex/loza-cortex.yaml
@@ -1,6 +1,6 @@
name: loza-cortex
kind: docker
-version: 0.2.6
+version: 0.3.0
description: Incident intelligence and graph analysis engine for LOZA
license: MIT
repository: https://github.com/Astraive/loza
diff --git a/docs/DSN.md b/docs/DSN.md
index ae0e7c6..20bfc4f 100644
--- a/docs/DSN.md
+++ b/docs/DSN.md
@@ -6,9 +6,14 @@ It resolves to HTTP/HTTPS/OTLP/gRPC/WebSocket endpoints -- it is NOT a new wire
## Format
```
-loza://[host][:port]/[project]?env=&service=&tls=&transport=
+loza://[[:]@][:port]/?env=&service=&tls=&transport=
```
+The required path identifies one named Collector resource. The optional userinfo
+carries either private Basic credentials (`username:password`) or a public
+opaque access ID (`lx_pub_...` with no password). A DSN without userinfo is
+unchanged for API-key-based configurations.
+
## Examples
```bash
@@ -20,17 +25,27 @@ LOZA_API_KEY=lx_sec_dev_k_xxx
LOZA_DSN=loza://collector.example.com/payments?env=prod
LOZA_API_KEY=lx_sec_prod_k_xxx
+# Private credentialed DSN (percent-encode reserved password characters)
+LOZA_DSN=loza://payments-writer:s%40cret%3Avalue@collector.example.com/payments?env=prod
+
+# Public, revocable opaque access ID with no password
+LOZA_DSN=loza://lx_pub_@collector.example.com/payments?env=prod
+
# OTLP transport for staging
LOZA_DSN=loza://loza.internal:4318/backend?env=staging&service=auth&transport=otlp
```
+The private username is the Collector configured grant username and its password is the grant secret. A public username MUST be an opaque high-entropy `lx_pub_...` access ID; it is a bearer capability, not a human name. SDK parsers percent-decode private credentials. Empty private components and username `:`/whitespace are invalid; URL-reserved private password characters must be percent-encoded.
+
## Parameters
| Parameter | Required | Default | Description |
|------------|----------|--------------|--------------------------------------------------|
+| `username` | With userinfo | -- | Private grant username or opaque `lx_pub_...` public access ID |
+| `password` | Private identity | -- | Secret for a private Collector grant; percent-encoded |
| `host` | Yes | -- | Collector hostname |
| `port` | No | See below | Collector port |
-| `project` | Yes | -- | Project name (path segment) |
+| `collector-name` | Yes | -- | Named Collector resource (path segment) |
| `env` | No | `"default"` | Deployment environment |
| `service` | No | `""` | Service name |
| `tls` | No | See below | `true`, `false`, or `auto` |
@@ -57,29 +72,47 @@ Setting `tls=auto` preserves the computed default.
## Resolved URLs
-A parsed `loza://` DSN resolves to these endpoints:
+A parsed `loza://` DSN resolves to credential-free, collector-scoped endpoints:
| Field | Pattern | Example |
|--------------|-----------------------------------|------------------------------------------|
| `BaseURL` | `http(s)://host:port` | `https://collector.example.com:443` |
-| `EventsURL` | `BaseURL + /events` | `https://collector.example.com:443/events` |
-| `BatchURL` | `BaseURL + /events/batch` | `https://collector.example.com:443/events/batch` |
-| `OTLPURL` | `BaseURL + /otlp/logs` | `https://collector.example.com:443/otlp/logs` |
-| `TailWSURL` | `ws(s)://host:port/tail` | `wss://collector.example.com:443/tail` |
+| `EventsURL` | `BaseURL + /collectors/{collector}/events` | `https://collector.example.com:443/collectors/payments/events` |
+| `BatchURL` | `BaseURL + /collectors/{collector}/events/batch` | `https://collector.example.com:443/collectors/payments/events/batch` |
+| `OTLPURL` | `BaseURL + /collectors/{collector}/otlp/logs` | `https://collector.example.com:443/collectors/payments/otlp/logs` |
+| `TailWSURL` | `ws(s)://host:port/collectors/{collector}/tail` | `wss://collector.example.com:443/collectors/payments/tail` |
+
+## Security and Authentication
-## Security
+- A private credentialed DSN sends `Authorization: Basic `; SDKs use TLS by default.
+- A public DSN sends `Authorization: Basic :)>`. Its opaque public access ID is a bearer capability and MUST be redacted and protected like a secret.
+- Plain HTTP with any credential is rejected by SDK configuration validation unless explicitly local (`tls=false`/insecure). The parser may parse the URI, but it must not silently enable production plaintext HTTP.
+- Do not put credentials in query parameters or paths. Resolved `BaseURL`, `EventsURL`, `BatchURL`, `OTLPURL`, and `TailWSURL` never contain userinfo, passwords, or access IDs.
+- Normal DSN string/debug representations must be redacted; never log private passwords or public access IDs. Use a secret manager/environment variable rather than committing a credentialed DSN.
-- **Do NOT put API keys in the DSN URL.** Use `LOZA_API_KEY` environment variable separately.
-- `loza://` resolves to HTTP/HTTPS, not a custom protocol.
-- Userinfo in the URL (`loza://key@host/project`) is explicitly rejected.
+## Credential Precedence
+
+Credential sources are applied with this precedence:
+
+1. Explicit code API key or Basic credentials.
+2. Credentials in an explicitly supplied code DSN.
+3. Environment credential sources (`LOZA_API_KEY` and userinfo in `LOZA_DSN`).
+
+`LOZA_API_KEY` remains the highest-priority token credential. `LOZA_COLLECTOR_URL` overrides only the endpoint; it does not replace DSN-derived environment, service, or credentials. A DSN without userinfo never clears credentials configured separately.
## Environment Variable Usage
```bash
-# Go SDK
+# Go SDK — existing no-userinfo/API-key form
export LOZA_DSN=loza://collector.example.com/my-app?env=prod
export LOZA_API_KEY=lx_sec_prod_k_xxx
+# Private Basic-auth form; keep this value in a secret environment/secret manager
+export LOZA_DSN='loza://payments-writer:s%40cret%3Avalue@collector.example.com/my-app?env=prod'
+
+# Public opaque access ID; it is still sensitive and should be stored as a secret
+export LOZA_DSN='loza://lx_pub_@collector.example.com/my-app?env=prod'
+
# JS/TS SDK
LOZA_DSN=loza://collector.example.com/my-app?env=prod
LOZA_API_KEY=lx_sec_prod_k_xxx
@@ -96,10 +129,13 @@ LOZA_API_KEY=lx_sec_prod_k_xxx
| `http://host/project` | Scheme must be `loza://` |
| `loza://` | Host is required |
| `loza:///project` | Host is required |
-| `loza://host` | Project path is required |
-| `loza://host/` | Project path is required |
-| `loza://key@host/project` | Do not put API keys in the URL |
-| `loza://host/project?tls=maybe` | tls must be true, false, or auto |
+| `loza://host` | Collector name path is required |
+| `loza://host/` | Collector name path is required |
+| `loza://host/collector` | Valid no-userinfo DSN |
+| `loza://writer:@host/collector` | Private username and password are required |
+| `loza://:secret@host/collector` | Private username and password are required |
+| `loza://lx_pub_@host/collector` | Valid public opaque access ID DSN |
+| `loza://user@host/collector` | Public access ID must begin `lx_pub_` |
| `loza://host/project?transport=x` | transport must be http, otlp, or grpc |
| `loza://host:0/project` | Invalid port |
| `loza://host:99999/project` | Invalid port |
diff --git a/docs/authentication.md b/docs/authentication.md
index ac01476..197ea39 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -2,12 +2,24 @@
## Overview
-LOZA uses scoped API keys with the `Authorization: Bearer` header for all ingest and control-plane requests. Keys follow the `lx_` format and carry built-in RBAC roles and ABAC restrictions.
+LOZA supports scoped API keys and opaque bearer tokens with the `Authorization: Bearer` header, plus credentialed DSNs with HTTP Basic authentication. API keys and tokens resolve an RBAC role server-side; credentialed DSNs supply the Collector key ID and its secret.
-```
+Bearer example:
+
+```http
Authorization: Bearer lx_sec_live_k2M9aQp_7QmVxN8pT4zRbK1sYw
```
+Credentialed DSN example:
+
+```text
+loza://kingest:s%40cret%3Avalue@collector.example.com/payments?env=prod
+```
+
+In this PostgreSQL-style form, `username` is the Collector configured `key_id` and `password` is that key's secret. SDKs percent-decode both values; empty values are invalid, username `:`/whitespace is invalid, and URL-reserved password characters must be percent-encoded. SDKs send these credentials as `Authorization: Basic base64(username:password)` over TLS by default. Plain HTTP with credentials is rejected during SDK configuration unless explicitly local (`tls=false`/insecure).
+
+Credentialed DSN passwords are never included in resolved endpoint URLs or normal DSN/debug output, and must never be logged. Use a secret environment variable or secret manager for DSNs and do not commit them.
+
## Key Format
```
@@ -69,9 +81,27 @@ For local development. Full ingest permissions but blocked in production environ
lx_local_dev_mydevtoken
```
+## RBAC roles and credential modes
+
+| Role | Log permissions |
+| --- | --- |
+| `user` | `logs:read` |
+| `client` | `logs:read`, `logs:write` |
+| `admin` | `logs:read`, `logs:write`, `logs:edit`, `logs:delete` |
+| `superadmin` | all `admin` log permissions plus `project:admin` |
+
+Credentials use `mode: private` or `mode: public`. Private credentials can
+use any recognized role. Public credentials require an origin allowlist and
+are limited to the least-privileged `client` role; never assign `admin` or
+`superadmin` to a browser-exposed credential.
+
+An opaque token is configured by environment variable and sent verbatim as a
+Bearer value. LOZA derives its lookup ID and its stored HMAC from the token,
+so the raw value is neither persisted as an identifier nor emitted in logs.
+
## SDK Configuration
-Set the `LOZA_API_KEY` environment variable, then configure the SDK:
+Set `LOZA_API_KEY` for the existing Bearer-token flow, or set `LOZA_DSN` to a credentialed DSN for Basic auth. Explicit SDK credentials can be supplied in code as shown below.
### Go
@@ -163,19 +193,35 @@ const logger = createLoza({
});
```
+## Credential Precedence
+
+Credential sources are applied in this order:
+
+1. Explicit code API key or Basic credentials.
+2. Credentials in an explicitly supplied code DSN.
+3. Environment credentials (`LOZA_API_KEY` and userinfo in `LOZA_DSN`).
+
+`LOZA_API_KEY` remains the highest-priority token credential. `LOZA_COLLECTOR_URL` changes only the endpoint; it does not override DSN-derived environment, service, or credentials. A DSN without userinfo does not clear credentials configured separately.
+
## Headers
-When an API key is configured, the SDK automatically sends these headers:
+When an API key is configured, the SDK sends Bearer authentication. When a credentialed DSN is configured, it sends Basic authentication from the DSN userinfo:
| Header | Value | Description |
|--------|-------|-------------|
-| `Authorization` | `Bearer lx_sec_live_k_xxx_yyyy` | The API key |
+| `Authorization` | `Bearer lx_sec_live_k_xxx_yyyy` | Existing API-key authentication |
+| `Authorization` | `Basic ` | Credentialed DSN authentication |
| `X-Loza-Service` | `checkout-api` | Service name from config |
| `X-Loza-Env` | `prod` | Environment from config |
+Basic credentials are sent over TLS by default. Never put the password in query parameters, paths, resolved endpoint URLs, logs, or unredacted DSN/debug output.
+
## Collector Configuration
-The Collector validates configured key records using RBAC and ABAC. `server_secret` is an independent HMAC key; it is not the at-rest storage key. `secret_env` names hold only the token secret, so a configured key is presented as `lx_{kind}_live_{key_id}_${SECRET_ENV}`.
+The Collector validates configured key records using RBAC and ABAC.
+`server_secret` is an independent HMAC key; it is not the at-rest storage key.
+`secret_env` holds only the token secret, so a configured key is presented as
+`lx_{kind}_live_{key_id}_${SECRET_ENV}`.
```yaml
auth:
@@ -183,28 +229,104 @@ auth:
server_secret: "${COLLECTOR_AUTH_SERVER_SECRET}"
cache_ttl: 5m
negative_cache_ttl: 30s
+ default_collector: payments # legacy root routes only
+ collectors:
+ - slug: payments
keys:
- - name: ingest
- key_id: kingest
- secret_env: COLLECTOR_INGEST_KEY_SECRET
+ - name: payments-operator
+ key_id: kpaymentsoperator
+ secret_env: PAYMENTS_OPERATOR_KEY_SECRET
kind: sec
- roles: [collector_ingest_server]
- - name: administrator
- key_id: kadmin
- secret_env: COLLECTOR_ADMIN_KEY_SECRET
+ collector: payments
+ permissions: [events:read, events:write, events:delete]
+ allowed_envs: [prod, staging]
+ - name: payments-admin
+ key_id: kpaymentsadmin
+ secret_env: PAYMENTS_ADMIN_KEY_SECRET
kind: sec
- roles: [project_admin]
- - name: browser
- key_id: collector_browser
- secret_env: COLLECTOR_BROWSER_KEY_SECRET
+ collector: payments
+ permissions: [project:admin]
+ allowed_envs: [prod]
+ - name: payments-browser
+ key_id: kpaymentsbrowser
+ secret_env: PAYMENTS_BROWSER_KEY_SECRET
kind: pub
- roles: [collector_ingest_public]
+ collector: payments
+ permissions: [events:write]
+ allowed_envs: [prod]
allowed_origins: [https://app.example.com]
storage:
encryption_key_env: LOZA_STORAGE_ENCRYPTION_KEY
```
-All configured key IDs must be unique. `kind` is `sec` or `pub`; public keys require a non-empty origin allowlist. Auth startup requires a non-empty resolved server secret, every configured key secret, and the storage encryption key. An explicit `auth.enabled: false` remains an operator override, not a tracked default.
+All configured key IDs must be unique. `kind` is `sec` or `pub`; public keys
+require a non-empty origin allowlist. A scoped API key MUST supply `collector`,
+`permissions`, and non-empty `allowed_envs`; the collector must be listed in
+`auth.collectors`. Its permissions are evaluated only against the canonical
+`/collectors/{collector}/...` resource and default to deny. Unscoped keys are
+supported solely for legacy root routes during migration. Auth startup requires
+a non-empty resolved server secret, every configured key secret, and the
+storage encryption key. An explicit `auth.enabled: false` remains an operator
+override, not a tracked default.
+
+## Multi-Collector Access
+
+Each DSN path identifies one Collector resource. SDKs send events to the
+canonical scoped resource path:
+
+```text
+loza://kingest:s%40cret@collector.example.com/payments?env=prod
+ └──────── collector name
+```
+
+The resolved ingest URL is
+`https://collector.example.com/collectors/payments/events`; userinfo is never
+included in that URL.
+
+API keys and Collector grants both bind an authenticated credential to a named
+collector, explicit environments, and permissions. The Collector defaults to
+deny when no binding matches the route collector, `X-Loza-Env`, and required
+permission. `project:admin` manages the named collector; `events:read`,
+`events:write`, and `events:delete` remain distinct.
+
+```yaml
+auth:
+ default_collector: payments # temporary legacy /events compatibility only
+ collectors:
+ - slug: payments
+ - slug: analytics
+ grants:
+ - name: payments-ingest
+ collector: payments
+ username: payments-writer
+ password_env: PAYMENTS_WRITER_PASSWORD
+ permissions: [events:write]
+ allowed_envs: [prod, staging]
+ - name: payments-browser
+ collector: payments
+ public_id_env: PAYMENTS_BROWSER_ACCESS_ID
+ permissions: [events:write]
+ allowed_envs: [prod]
+ allowed_origins: [https://app.example.com]
+```
+
+Private grants require `username:password` and a password sourced from a
+secret environment variable. A public DSN uses an opaque, high-entropy
+`lx_pub_...` access ID as its username and no password:
+
+```text
+loza://lx_pub_@collector.example.com/payments?env=prod
+```
+
+Despite appearing in the username position, a public access ID is a revocable
+bearer capability. Store it in a secret manager, redact it in logs and DSN
+representations, constrain it by collector/environment/origin/IP/rate limit,
+and rotate it like any other credential. A human-readable username without a
+password is forgeable and is not a valid authorization identity.
+
+Unscoped legacy routes such as `/events` work only when `default_collector` is
+configured. New clients and integrations MUST use `/collectors/{collector}/...`
+and MUST NOT rely on event payload fields to choose collector or environment.
## Token Storage Security
@@ -221,10 +343,11 @@ hash = hmac_sha256(server_secret, token_secret)
## Key Rotation
-1. Create a new key in the dashboard (or control plane API)
-2. Deploy your application with the new key
-3. The collector accepts both keys during the overlap window
-4. Revoke the old key after all deployments are updated
+1. Create a new key in the dashboard (or control plane API).
+2. Store the new secret in the application secret environment/secret manager; if using a DSN, percent-encode reserved password characters.
+3. Deploy your application with the new key or DSN.
+4. The collector accepts both keys during the overlap window.
+5. Revoke the old key after all deployments are updated.
## Legacy Bootstrap Token
diff --git a/docs/configuration.md b/docs/configuration.md
index f72d9ca..818900c 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -62,7 +62,9 @@ collector:
### `auth`
-API-key authentication uses `Authorization: Bearer` and is enabled in every tracked Collector configuration. `server_secret` is used only for HMAC key-record hashing and must not be reused as the storage encryption key.
+API-key authentication uses `Authorization: Bearer` and is enabled in every
+tracked Collector configuration. `server_secret` is used only for HMAC
+key-record hashing and must not be reused as the storage encryption key.
```yaml
auth:
@@ -70,24 +72,62 @@ auth:
server_secret: "${COLLECTOR_AUTH_SERVER_SECRET}"
cache_ttl: 5m
negative_cache_ttl: 30s
+ collectors:
+ - slug: checkout
keys:
- - name: ingest
- key_id: kingest
- secret_env: COLLECTOR_INGEST_KEY_SECRET
+ - name: checkout-writer
+ key_id: kcheckoutwriter
+ secret_env: CHECKOUT_WRITER_KEY_SECRET
kind: sec
+ mode: private
roles: [collector_ingest_server]
- - name: administrator
- key_id: kadmin
- secret_env: COLLECTOR_ADMIN_KEY_SECRET
+ collector: checkout
+ permissions: [events:write]
+ allowed_envs: [prod]
+ - name: checkout-reader
+ key_id: kcheckoutreader
+ secret_env: CHECKOUT_READER_KEY_SECRET
kind: sec
- roles: [project_admin]
+ mode: private
+ roles: [project_readonly]
+ collector: checkout
+ permissions: [events:read]
+ allowed_envs: [prod]
+```
+
+`secret_env` is the secret segment of
+`lx_{kind}_live_{key_id}_{secret}`, not the full token. Key IDs are unique;
+`kind` must be `sec` or `pub`; `mode` is `private` or `public`; public
+credentials require `allowed_origins` and may use only the `client` role.
+Scoped keys require a configured `collector`, non-empty `permissions`, and
+non-empty `allowed_envs`. Scoped permissions are exact:
+`events:read`, `events:write`, `events:delete`, `logs:read`, `logs:write`,
+`logs:edit`, `logs:delete`, and `project:admin` are authorized independently
+on `/collectors/{collector}/...`. Unscoped keys remain limited to explicitly
+configured legacy root routes.
+
+Opaque Bearer tokens use the same RBAC and scope model:
+
+```yaml
+auth:
+ tokens:
+ - name: browser-log-writer
+ token_env: BROWSER_LOG_TOKEN
+ mode: public
+ roles: [client]
+ collector: checkout
+ permissions: [logs:write]
+ allowed_envs: [prod]
+ allowed_origins: [https://app.example.com]
```
-`secret_env` is the secret segment of `lx_{kind}_live_{key_id}_{secret}`, not the full token. Key IDs are unique; `kind` must be `sec` or `pub`; every role must be a supported Collector role; and public keys require `allowed_origins`.
+`token_env` must contain an opaque value, not an `lx_` API key. The raw token
+is only accepted in `Authorization: Bearer`; it is not used as a stored ID.
```bash
-curl -X POST http://localhost:9308/events \
- -H "Authorization: Bearer lx_sec_live_kingest_${COLLECTOR_INGEST_KEY_SECRET}" \
+curl -X POST http://localhost:9308/collectors/checkout/events \
+ -H "Authorization: Bearer lx_sec_live_kcheckoutwriter_${CHECKOUT_WRITER_KEY_SECRET}" \
+ -H "X-Loza-Env: prod" \
-H 'Content-Type: application/json' -d '[{"event":"example"}]'
curl http://localhost:9308/health
```
diff --git a/docs/ports.md b/docs/ports.md
index b92dcde..64502b1 100644
--- a/docs/ports.md
+++ b/docs/ports.md
@@ -41,7 +41,7 @@
## Configuration
-Loza uses a layered config system. All non-secret values can be set via YAML files, environment variables, or code. Secrets must come from environment variables.
+Loza uses a layered config system. Non-secret values can be set via YAML files, environment variables, or code. Prefer secret environment variables or a secret manager for credentials. A credentialed `LOZA_DSN` may carry percent-encoded Basic-auth userinfo, but it must not be committed or logged.
### 1. Defaults (committed)
@@ -94,7 +94,7 @@ docker run -v /host/config.yaml:/etc/loza/config.yaml \
### 3. Environment variables
-All config values can be overridden via env vars. Secrets must use env vars.
+All config values can be overridden via env vars. Keep API keys and DSN credentials in environment variables or a secret manager.
```bash
# Non-secret overrides
@@ -102,15 +102,19 @@ COLLECTOR_SERVER_PORT=9308
CORTEX_SERVER_PORT=9312
LOZA_ENVIRONMENT=production
-# Secrets (env vars only)
+# Secrets
LOZA_API_KEY=lx_sec_live_xxx
+# Optional Basic-auth form; percent-encode reserved password characters
+LOZA_DSN='loza://kingest:s%40cret%3Avalue@collector.example.com/my-app?env=prod'
LOZA_STORAGE_ENCRYPTION_KEY=xxx
CORTEX_POSTGRES_PASSWORD=xxx
```
### 4. Code (SDKs)
-Highest precedence — overrides everything.
+Credential precedence is: explicit code API key or Basic credentials, then credentials in an explicitly supplied code DSN, then environment credentials (`LOZA_API_KEY` or userinfo in `LOZA_DSN`). `LOZA_API_KEY` remains the highest-priority token credential. `LOZA_COLLECTOR_URL` overrides only the endpoint and does not replace DSN-derived environment, service, or credentials. A DSN without userinfo does not clear separately configured credentials.
+
+Explicit code configuration otherwise remains the highest-precedence source for the fields it sets:
```go
loza.Configure(loza.Production("checkout").
@@ -145,4 +149,6 @@ loza://localhost/my-app → http://localhost:9308
loza://localhost:9999/my-app → http://localhost:9999
```
+Credentialed DSNs use `loza://username:password@host/project`. The username is the Collector `key_id`; the password is its secret. SDKs send Basic auth over TLS by default and reject plaintext HTTP with credentials unless explicitly local (`tls=false`/insecure). Resolved endpoint URLs and logs never contain the password.
+
Set via env var: `LOZA_DSN=loza://localhost/my-app`
diff --git a/examples/quickstart/go/go.mod b/examples/quickstart/go/go.mod
index c794ac1..058fee8 100644
--- a/examples/quickstart/go/go.mod
+++ b/examples/quickstart/go/go.mod
@@ -5,7 +5,7 @@ go 1.25.0
require github.com/astraive/loza/sdks/go v0.2.1
require (
- github.com/astraive/loza/spec v0.0.0 // indirect
+ github.com/astraive/loza/spec v0.2.6 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
diff --git a/examples/quickstart/rs/src/main.rs b/examples/quickstart/rs/src/main.rs
index e26bf8c..86e106f 100644
--- a/examples/quickstart/rs/src/main.rs
+++ b/examples/quickstart/rs/src/main.rs
@@ -5,12 +5,17 @@ fn main() {
loza::info("server started");
- let ctx = loza::start_event(loza::Params::new("user.signup").with_kind("http"));
- loza::enrich(&ctx, "user.email", "demo@example.com");
- loza::enrich(&ctx, "user.plan", "pro");
- loza::finish(&ctx, "success");
- match loza::emit(&ctx) {
- Ok(result) => println!("Event emitted: {result}"),
+ let mut ctx = loza::start_event(None, loza::Params::new("user.signup").with_kind("http"));
+ loza::enrich(
+ &mut ctx,
+ [
+ loza::String("user.email", "demo@example.com"),
+ loza::String("user.plan", "pro"),
+ ],
+ );
+ loza::finish(&mut ctx);
+ match loza::emit(&mut ctx) {
+ Ok(()) => println!("Event emitted"),
Err(e) => eprintln!("emit error: {e}"),
}
diff --git a/go.work b/go.work
new file mode 100644
index 0000000..fb1196d
--- /dev/null
+++ b/go.work
@@ -0,0 +1,18 @@
+go 1.25.13
+
+use (
+ ./cli
+ ./collector
+ ./cortex
+ ./examples/quickstart/go
+ ./gen/go
+ ./sdks/go
+ ./sdks/go/bench
+ ./sdks/go/examples/httpbatch-to-collector
+ ./sdks/go/src/integrations
+ ./sdks/go/src/middleware
+ ./sdks/go/tests/e2e
+ ./sdks/go/tests/integration
+ ./spec
+ ./tools/stress
+)
diff --git a/go.work.sum b/go.work.sum
new file mode 100644
index 0000000..779b86e
--- /dev/null
+++ b/go.work.sum
@@ -0,0 +1,20 @@
+cloud.google.com/go/compute v1.54.0 h1:4CKmnpO+40z44bKG5bdcKxQ7ocNpRtOc9SCLLUzze1w=
+github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
+github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.mongodb.org/mongo-driver v1.11.4 h1:4ayjakA013OdpGyL2K3ZqylTac/rMjrJOMZ1EHizXas=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401001100-f93e5f3e9f0f/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
diff --git a/loza.yaml b/loza.yaml
index 7cb29f2..0b029ba 100644
--- a/loza.yaml
+++ b/loza.yaml
@@ -1,6 +1,6 @@
name: loza
kind: release
-version: 0.2.6
+version: 0.3.0
description: Umbrella LOZA repository release
license: MIT
repository: https://github.com/Astraive/loza
diff --git a/lql/Cargo.lock b/lql/Cargo.lock
index 89f24bf..e8fbaa6 100644
--- a/lql/Cargo.lock
+++ b/lql/Cargo.lock
@@ -398,7 +398,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loza-lql"
-version = "0.2.6"
+version = "0.3.0"
dependencies = [
"criterion",
"insta",
diff --git a/lql/Cargo.toml b/lql/Cargo.toml
index 09b4d8b..782f2e2 100644
--- a/lql/Cargo.toml
+++ b/lql/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "loza-lql"
-version = "0.2.6"
+version = "0.3.0"
edition = "2021"
description = "Loza Query Language — compiles to DuckDB/ClickHouse SQL"
license = "MIT"
diff --git a/lql/lql.yaml b/lql/lql.yaml
index 9a16a50..d74e7b0 100644
--- a/lql/lql.yaml
+++ b/lql/lql.yaml
@@ -1,7 +1,7 @@
name: loza-lql
kind: package
language: rust
-version: 0.2.6
+version: 0.3.0
description: LOZA Query Language compiler and tooling
license: MIT
repository: https://github.com/Astraive/loza
diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh
index 28e1c01..9f46049 100644
--- a/scripts/bump-version.sh
+++ b/scripts/bump-version.sh
@@ -42,6 +42,7 @@ FILES=(
# JS SDK — package.json "version": "X.Y.Z"
"sdks/js/package.json"
+ "sdks/js/package-lock.json"
# LQL — Cargo.toml version = "X.Y.Z"
"lql/Cargo.toml"
@@ -53,6 +54,9 @@ FILES=(
"cortex/deploy/helm/cortex/Chart.yaml"
"collector/deploy/helm/loza/Chart.yaml"
+
+ # Umbrella release metadata
+ "loza.yaml"
# Project metadata YAML
"collector/loza.yaml"
"cortex/loza-cortex.yaml"
@@ -60,6 +64,7 @@ FILES=(
"sdks/go/loza-go.yaml"
"sdks/py/loza-py.yaml"
"sdks/rs/loza-rs.yaml"
+ "spec/docs/sdk-parity-manifest.json"
"sdks/js/loza-js.yaml"
"spec/loza-spec.yaml"
"lql/lql.yaml"
@@ -75,6 +80,10 @@ FILES=(
"cortex/configs/cortex-deployment.yaml"
"cortex/configs/k8s.yaml"
"collector/deploy/k8s/collector-deployment.yaml"
+
+ # Chart default image tags
+ "collector/deploy/helm/loza/values.yaml"
+ "cortex/deploy/helm/cortex/values.yaml"
)
# ---------------------------------------------------------------------------
diff --git a/scripts/release/test_validate_manifests.py b/scripts/release/test_validate_manifests.py
new file mode 100644
index 0000000..8951a1f
--- /dev/null
+++ b/scripts/release/test_validate_manifests.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+import importlib.util
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+
+MODULE_PATH = Path(__file__).with_name("validate-manifests.py")
+SPEC = importlib.util.spec_from_file_location("validate_manifests", MODULE_PATH)
+assert SPEC is not None and SPEC.loader is not None
+validate_manifests = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(validate_manifests)
+
+
+class JavaScriptPackageIdentityTests(unittest.TestCase):
+ def test_scoped_package_metadata_matches_native_manifest(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary_directory:
+ package_root = Path(temporary_directory)
+ (package_root / "package.json").write_text(
+ json.dumps({"name": "@astraive/loza", "version": "0.3.0"}),
+ encoding="utf-8",
+ )
+ manifest = {
+ "kind": "sdk",
+ "language": "javascript",
+ "version": "0.3.0",
+ "paths": {"root": package_root.as_posix()},
+ "publish": {"npm": {"owner": "astraive", "package": "@astraive/loza"}},
+ }
+
+ native_errors = validate_manifests.validate_native_metadata("sdk-js", manifest, package_root / "loza-js.yaml")
+ publish_errors = validate_manifests.validate_publish_metadata("sdk-js", manifest, package_root / "loza-js.yaml")
+
+ self.assertEqual(native_errors, [])
+ self.assertEqual(publish_errors, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/release/validate-manifests.py b/scripts/release/validate-manifests.py
index b23f7a7..a8c31f0 100644
--- a/scripts/release/validate-manifests.py
+++ b/scripts/release/validate-manifests.py
@@ -24,6 +24,7 @@
KNOWN_KINDS = {"spec", "docker", "cli", "sdk", "package", "release"}
KNOWN_LANGUAGES = {"go", "javascript", "python", "rust"}
PUBLISH_OWNER = "astraive"
+NPM_PACKAGE = "@astraive/loza"
def load_yaml(path: Path) -> dict[str, Any]:
@@ -61,8 +62,8 @@ def validate_native_metadata(component: str, manifest: dict[str, Any], manifest_
package_json = root / "package.json"
if package_json.exists():
package_data = json.loads(package_json.read_text(encoding="utf-8"))
- if package_data.get("name") != "loza":
- errors.append(f"{component}: sdks/js/package.json name must be 'loza', not {package_data.get('name')!r}")
+ if package_data.get("name") != NPM_PACKAGE:
+ errors.append(f"{component}: sdks/js/package.json name must be {NPM_PACKAGE!r}, not {package_data.get('name')!r}")
if package_data.get("version") != version:
errors.append(f"{component}: sdks/js/package.json version must match manifest version {version}")
@@ -154,8 +155,8 @@ def validate_publish_metadata(component: str, manifest: dict[str, Any], manifest
npm = publish.get("npm")
if not isinstance(npm, dict):
errors.append(f"{component}: JavaScript SDK requires publish.npm")
- elif npm.get("package") != "loza":
- errors.append(f"{component}: npm package must be 'loza', not {npm.get('package')!r}")
+ elif npm.get("package") != NPM_PACKAGE:
+ errors.append(f"{component}: npm package must be {NPM_PACKAGE!r}, not {npm.get('package')!r}")
elif npm.get("owner") != PUBLISH_OWNER:
errors.append(f"{component}: publish.npm.owner must be '{PUBLISH_OWNER}'")
if language == "python":
diff --git a/sdks/go/.env.example b/sdks/go/.env.example
index e48e5d7..1b9e0de 100644
--- a/sdks/go/.env.example
+++ b/sdks/go/.env.example
@@ -2,11 +2,13 @@
# Copy to .env and fill in values
# Connection
-# LOZA_DSN=loza://localhost:9308 # loza:// connection URI (sets CollectorURL, Environment, Service)
-# LOZA_COLLECTOR_URL=http://localhost:9308 # Collector endpoint URL (overrides DSN)
+# LOZA_DSN=loza://localhost:9308/my-app # No-userinfo DSN; sets endpoint and DSN metadata
+# LOZA_DSN=loza://kingest:s%40cret%3Avalue@collector.example.com/my-app?env=prod
+# # Optional Basic auth: key_id:percent-encoded secret
+# LOZA_COLLECTOR_URL=http://localhost:9308 # Endpoint override; does not clear DSN metadata/credentials
# Authentication
-# LOZA_API_KEY= # Ingest API key (e.g. lx_sec_live_k_xxx_yyyy)
+# LOZA_API_KEY= # Bearer token (e.g. lx_sec_live_k_xxx_yyyy); highest-priority token credential
# Service Identity
# LOZA_SERVICE_NAME=my-service # Service name (required in strict mode)
diff --git a/sdks/go/README.md b/sdks/go/README.md
index de730b2..5b44643 100644
--- a/sdks/go/README.md
+++ b/sdks/go/README.md
@@ -2,7 +2,7 @@
[](https://github.com/astraive/loza/actions/workflows/sdks-go-ci.yml)
-**Status**: 🟢 **STABLE** (v0.2.6) - Production-ready, collector-first stable-v1 SDK
+**Status**: 🟢 **STABLE** (v0.3.0) - Production-ready, collector-first stable-v1 SDK
Full emitter SDK conformance is tracked through `spec/`:
diff --git a/sdks/go/bench/go.mod b/sdks/go/bench/go.mod
index 766af2a..d38c7a8 100644
--- a/sdks/go/bench/go.mod
+++ b/sdks/go/bench/go.mod
@@ -1,6 +1,6 @@
module github.com/astraive/loza/sdks/go/bench
-go 1.25.0
+go 1.25.13
require (
github.com/astraive/loza/sdks/go v0.0.0
@@ -8,7 +8,7 @@ require (
)
require (
- github.com/astraive/loza/spec v0.0.0 // indirect
+ github.com/astraive/loza/spec v0.2.6 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
diff --git a/sdks/go/bench/go.sum b/sdks/go/bench/go.sum
index 297ba2c..916fb14 100644
--- a/sdks/go/bench/go.sum
+++ b/sdks/go/bench/go.sum
@@ -51,7 +51,7 @@ golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/sdks/go/docs/sdk-parity-manifest.json b/sdks/go/docs/sdk-parity-manifest.json
index b0da7b2..3cfee69 100644
--- a/sdks/go/docs/sdk-parity-manifest.json
+++ b/sdks/go/docs/sdk-parity-manifest.json
@@ -1,8 +1,8 @@
{
- "version": "0.2.6",
+ "version": "0.3.0",
"package": "loza",
"scope": "lightweight-sdk",
- "stability": "product-parity-v0.2.6",
+ "stability": "product-parity-v0.3.0",
"policy": "full-product-method-family",
"sdks": ["go", "python", "rust", "javascript"],
"lifecycle": [
diff --git a/sdks/go/examples/httpbatch-to-collector/go.mod b/sdks/go/examples/httpbatch-to-collector/go.mod
index 47b1add..013a17f 100644
--- a/sdks/go/examples/httpbatch-to-collector/go.mod
+++ b/sdks/go/examples/httpbatch-to-collector/go.mod
@@ -1,13 +1,25 @@
module github.com/astraive/loza/sdks/go/examples/httpbatch-to-collector
-go 1.25.0
+go 1.25.13
require github.com/astraive/loza/sdks/go v0.0.0
require (
+ github.com/astraive/loza/spec v0.2.6 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_golang v1.23.2 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
+ github.com/prometheus/procfs v0.16.1 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ golang.org/x/sys v0.42.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
)
replace github.com/astraive/loza/sdks/go => ../..
+
+replace github.com/astraive/loza/spec => ../../../../spec
diff --git a/sdks/go/examples/httpbatch-to-collector/go.sum b/sdks/go/examples/httpbatch-to-collector/go.sum
index 8caad09..1bfddf7 100644
--- a/sdks/go/examples/httpbatch-to-collector/go.sum
+++ b/sdks/go/examples/httpbatch-to-collector/go.sum
@@ -1,3 +1,5 @@
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -8,8 +10,28 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -20,5 +42,16 @@ go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWv
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/sdks/go/examples/httpbatch-to-collector/main.go b/sdks/go/examples/httpbatch-to-collector/main.go
index edd1f6b..4405bdd 100644
--- a/sdks/go/examples/httpbatch-to-collector/main.go
+++ b/sdks/go/examples/httpbatch-to-collector/main.go
@@ -6,7 +6,7 @@ import (
"time"
"github.com/astraive/loza/sdks/go"
- "github.com/astraive/loza/sdks/go/sinks/httpbatch"
+ "github.com/astraive/loza/sdks/go/src/sinks/httpbatch"
)
func main() {
diff --git a/sdks/go/go.mod b/sdks/go/go.mod
index 9ff2181..3409069 100644
--- a/sdks/go/go.mod
+++ b/sdks/go/go.mod
@@ -1,6 +1,6 @@
module github.com/astraive/loza/sdks/go
-go 1.25.0
+go 1.25.13
require (
github.com/astraive/loza/spec v0.2.6
diff --git a/sdks/go/loza-go.yaml b/sdks/go/loza-go.yaml
index 5c26966..78c6036 100644
--- a/sdks/go/loza-go.yaml
+++ b/sdks/go/loza-go.yaml
@@ -1,7 +1,7 @@
name: loza-go
kind: sdk
language: go
-version: 0.2.6
+version: 0.3.0
description: Go SDK for LOZA
license: MIT
repository: https://github.com/Astraive/loza
diff --git a/sdks/go/src/core/collector_client.go b/sdks/go/src/core/collector_client.go
index f32c328..d8bf96f 100644
--- a/sdks/go/src/core/collector_client.go
+++ b/sdks/go/src/core/collector_client.go
@@ -15,38 +15,67 @@ const maxCollectorClientResponseBytes = 10 << 20
// CollectorClient communicates with the LOZA collector REST API.
type CollectorClient struct {
- endpoint string
- apiKey string
- client *http.Client
+ endpoint string
+ collectorName string
+ apiKey string
+ basicUsername string
+ basicPassword string
+ initErr error
+ client *http.Client
}
// CollectorClientConfig configures the collector client.
type CollectorClientConfig struct {
- Endpoint string
- APIKey string
- Client *http.Client
+ Endpoint string
+ CollectorName string
+ APIKey string
+ BasicUsername string
+ BasicPassword string
+ Insecure bool
+ Client *http.Client
}
// NewCollectorClient creates a new collector client.
func NewCollectorClient(cfg CollectorClientConfig) *CollectorClient {
+ endpoint := safeCollectorEndpoint(cfg.Endpoint)
+ initErr := validateCollectorCredentials(
+ cfg.Endpoint,
+ cfg.BasicUsername,
+ cfg.BasicPassword,
+ cfg.Insecure,
+ )
if cfg.Client == nil {
cfg.Client = &http.Client{}
}
return &CollectorClient{
- endpoint: strings.TrimRight(cfg.Endpoint, "/"),
- apiKey: cfg.APIKey,
- client: cfg.Client,
+ endpoint: strings.TrimRight(endpoint, "/"),
+ collectorName: cfg.CollectorName,
+ apiKey: cfg.APIKey,
+ basicUsername: cfg.BasicUsername,
+ basicPassword: cfg.BasicPassword,
+ initErr: initErr,
+ client: cfg.Client,
}
}
+func (c *CollectorClient) collectorPath(route string) string {
+ if c.collectorName == "" {
+ return route
+ }
+ return "/collectors/" + url.PathEscape(c.collectorName) + route
+}
+
func (c *CollectorClient) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
- url := c.endpoint + path
+ if c.initErr != nil {
+ return nil, c.initErr
+ }
+ endpoint := c.endpoint + path
var req *http.Request
var err error
if body != nil {
- req, err = http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
+ req, err = http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
} else {
- req, err = http.NewRequestWithContext(ctx, method, url, nil)
+ req, err = http.NewRequestWithContext(ctx, method, endpoint, nil)
}
if err != nil {
return nil, fmt.Errorf("collector: request: %w", err)
@@ -54,6 +83,8 @@ func (c *CollectorClient) do(ctx context.Context, method, path string, body []by
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ } else if c.basicUsername != "" {
+ req.Header.Set("Authorization", basicAuthorization(c.basicUsername, c.basicPassword))
}
resp, err := c.client.Do(req)
if err != nil {
@@ -81,7 +112,7 @@ func (c *CollectorClient) Ingest(ctx context.Context, events []json.RawMessage)
if err != nil {
return nil, fmt.Errorf("collector: marshal: %w", err)
}
- return c.do(ctx, http.MethodPost, "/ingest", body)
+ return c.do(ctx, http.MethodPost, c.collectorPath("/events"), body)
}
// Query queries events from the collector.
@@ -91,12 +122,12 @@ func (c *CollectorClient) Query(ctx context.Context, query json.RawMessage) ([]b
// Tail tails events from the collector (server-sent events).
func (c *CollectorClient) Tail(ctx context.Context, filter json.RawMessage) ([]byte, error) {
- return c.do(ctx, http.MethodPost, "/tail", filter)
+ return c.do(ctx, http.MethodPost, c.collectorPath("/tail"), filter)
}
// Delete deletes events from the collector.
func (c *CollectorClient) Delete(ctx context.Context, filter json.RawMessage) ([]byte, error) {
- return c.do(ctx, http.MethodDelete, "/events", filter)
+ return c.do(ctx, http.MethodDelete, c.collectorPath("/events"), filter)
}
// Replay replays events through the collector.
diff --git a/sdks/go/src/core/config.go b/sdks/go/src/core/config.go
index 891a26f..a54cf97 100644
--- a/sdks/go/src/core/config.go
+++ b/sdks/go/src/core/config.go
@@ -2,7 +2,9 @@ package core
import (
"context"
+ "encoding/base64"
"fmt"
+ "net/url"
"os"
"strconv"
"strings"
@@ -139,12 +141,14 @@ type Config struct {
TenantID string // Multi-tenant identifier
// ── Authentication ───────────────────────────────────────────────────────
- APIKey string // Ingest API key (e.g., "lx_sec_live_k_xxx_yyyy")
- Insecure bool // Allow plain HTTP (local dev only). Default: false.
+ APIKey string // Ingest API key (e.g., "lx_sec_live_k_xxx_yyyy")
+ DSNUsername string // Private Basic username or public bearer capability from a DSN.
+ DSNPassword string // Basic password; intentionally empty for lx_pub_ DSNs.
+ Insecure bool // Allow plain HTTP (local dev only). Default: false.
// ── Collector configuration ───────────────────────────────────────────────
- CollectorURL string // URL of the LOZA collector (required)
-
+ CollectorURL string // Credential-free base URL of the LOZA collector (required)
+ CollectorName string // Collector slug derived from a loza:// DSN path
// ── Batching configuration ────────────────────────────────────────────────
BatchSize int // Number of events per batch (default: 100)
FlushInterval time.Duration // Time between automatic flushes (default: 5s)
@@ -189,8 +193,8 @@ type Config struct {
PanicRecovery bool
// OTelBridge enables OpenTelemetry trace context extraction from context.
// When false (default), TraceFromOTel is skipped to avoid the ~50-100ns context.Value lookup.
- OTelBridge bool
- Security SecurityConfig
+ OTelBridge bool
+ Security SecurityConfig
// Strict enables stronger runtime validation for event shape and attrs.
Strict bool
// ValidateEncoded controls post-encode spec contract validation in strict mode.
@@ -202,8 +206,7 @@ type Config struct {
IDGen IDGenerator
// ── Clock ─────────────────────────────────────────────────────────────────
- Clock Clock
-
+ Clock Clock
codeSetCompression bool
codeSetAsync bool
codeSetBackpressure bool
@@ -212,6 +215,7 @@ type Config struct {
codeSetRedactByDefault bool
codeSetAllowPII bool
codeSetDropOversized bool
+ codeSetInsecure bool
}
// WithService returns a copy of cfg with Service set.
@@ -364,9 +368,17 @@ func (c Config) WithAPIKey(apiKey string) Config {
return c
}
+// WithBasicAuth sets the Collector Basic-auth credentials.
+func (c Config) WithBasicAuth(username, password string) Config {
+ c.DSNUsername = username
+ c.DSNPassword = password
+ return c
+}
+
// WithInsecure allows plain HTTP connections (for local dev only).
func (c Config) WithInsecure(insecure bool) Config {
c.Insecure = insecure
+ c.codeSetInsecure = true
return c
}
@@ -559,6 +571,42 @@ func (c Config) Validate() error {
Problem: fmt.Sprintf("must be >= 0 (got %d)", c.Security.MaxAttrCount),
}
}
+ if endpointURL, err := url.Parse(strings.TrimSpace(c.CollectorURL)); err == nil &&
+ endpointURL.User != nil {
+ return &ConfigValidationError{
+ Field: "CollectorURL",
+ Problem: "collector credentials must be supplied through DSN or Basic-auth fields, not embedded in the endpoint URL",
+ }
+ }
+ if c.DSNUsername == "" && c.DSNPassword != "" {
+ return &ConfigValidationError{
+ Field: "DSNUsername/DSNPassword",
+ Problem: "basic auth password requires a username",
+ }
+ }
+ if c.DSNUsername != "" && c.DSNPassword == "" && !dsn.IsPublicCredentialUsername(c.DSNUsername) {
+ return &ConfigValidationError{
+ Field: "DSNUsername/DSNPassword",
+ Problem: "basic auth username requires a password unless it is an lx_pub_ capability",
+ }
+ }
+ if c.DSNUsername != "" {
+ if strings.ContainsAny(c.DSNUsername, ":\t\r\n ") {
+ return &ConfigValidationError{
+ Field: "DSNUsername",
+ Problem: "basic auth username must not contain ':' or whitespace",
+ }
+ }
+ if endpointURL, err := url.Parse(strings.TrimSpace(c.CollectorURL)); err == nil &&
+ strings.EqualFold(endpointURL.Scheme, "http") &&
+ (!c.Insecure || !isLocalCollectorHost(endpointURL.Hostname())) {
+ return &ConfigValidationError{
+ Field: "CollectorURL",
+ Problem: "credentialed collector endpoints require TLS (set Insecure only for local development)",
+ }
+ }
+ }
+
if !c.Strict {
return nil
}
@@ -591,6 +639,65 @@ func (c Config) Validate() error {
return nil
}
+// safeCollectorEndpoint strips URL userinfo before an endpoint is retained or
+// passed to net/http. DSN credentials are carried separately in Config.
+func safeCollectorEndpoint(raw string) string {
+ endpoint := strings.TrimSpace(raw)
+ parsed, err := url.Parse(endpoint)
+ if err != nil {
+ return endpoint
+ }
+ parsed.User = nil
+ return parsed.String()
+}
+
+func basicAuthorization(username, password string) string {
+ token := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
+ return "Basic " + token
+}
+
+func collectorRouteURL(baseURL, collectorName, route string) string {
+ baseURL = strings.TrimRight(baseURL, "/")
+ if collectorName == "" {
+ return baseURL + route
+ }
+ return baseURL + "/collectors/" + url.PathEscape(collectorName) + route
+}
+
+func isLocalCollectorHost(host string) bool {
+ switch strings.ToLower(strings.TrimSpace(host)) {
+ case "localhost", "127.0.0.1", "::1":
+ return true
+ default:
+ return false
+ }
+}
+
+func validateCollectorCredentials(endpoint, username, password string, insecure bool) error {
+ if endpointURL, err := url.Parse(strings.TrimSpace(endpoint)); err == nil &&
+ endpointURL.User != nil {
+ return fmt.Errorf("loza: collector credentials must not be embedded in the endpoint URL")
+ }
+ if username == "" && password != "" {
+ return fmt.Errorf("loza: basic auth password requires a username")
+ }
+ if username != "" && password == "" && !dsn.IsPublicCredentialUsername(username) {
+ return fmt.Errorf("loza: basic auth username requires a password unless it is an lx_pub_ capability")
+ }
+ if username == "" {
+ return nil
+ }
+ if strings.ContainsAny(username, ":\t\r\n ") {
+ return fmt.Errorf("loza: basic auth username must not contain ':' or whitespace")
+ }
+ if endpointURL, err := url.Parse(strings.TrimSpace(endpoint)); err == nil &&
+ strings.EqualFold(endpointURL.Scheme, "http") &&
+ (!insecure || !isLocalCollectorHost(endpointURL.Hostname())) {
+ return fmt.Errorf("loza: credentialed collector endpoints require TLS (set Insecure only for local development)")
+ }
+ return nil
+}
+
// ── Preset configs ────────────────────────────────────────────────────────────
// Dev returns a config suitable for local development:
@@ -760,16 +867,22 @@ func envOr(key, def string) string {
func LoadFromEnv(base Config) Config {
cfg := base
- // Load DSN first (sets CollectorURL, Environment, Service, Insecure).
- // Individual env vars below can override DSN-derived values.
+ // Load DSN first (sets CollectorURL, Environment, Service, Insecure, and
+ // credential fields when userinfo is present). Individual env vars below
+ // can override DSN-derived values.
if rawDSN := os.Getenv("LOZA_DSN"); rawDSN != "" {
- if d, err := dsn.Parse(rawDSN); err == nil {
+ if d, username, password, err := parseSDKDSN(rawDSN); err == nil {
cfg.CollectorURL = d.BaseURL
+ cfg.CollectorName = d.CollectorName
cfg.Environment = d.Env
if d.Service != "" {
cfg.Service = d.Service
}
cfg.Insecure = !d.TLS
+ if username != "" {
+ cfg.DSNUsername = username
+ cfg.DSNPassword = password
+ }
}
// If DSN parsing fails, fall through to individual env vars.
}
@@ -845,6 +958,45 @@ func LoadFromEnv(base Config) Config {
return cfg
}
+// parseSDKDSN keeps the SDK compatible with released spec modules that parse
+// only credential-free endpoint strings while preserving the shared parser's
+// endpoint semantics and the credentialed DSN contract.
+func parseSDKDSN(raw string) (*dsn.LozaDSN, string, string, error) {
+ parsed, err := dsn.Parse(raw)
+ if err == nil {
+ username, password := credentialedDSNUserinfo(raw)
+ return parsed, username, password, nil
+ }
+
+ parsedURL, urlErr := url.Parse(raw)
+ if urlErr != nil || parsedURL.User == nil {
+ return nil, "", "", err
+ }
+ username, password := parsedURL.User.Username(), ""
+ var hasPassword bool
+ password, hasPassword = parsedURL.User.Password()
+ if username == "" || !hasPassword || (password == "" && !dsn.IsPublicCredentialUsername(username)) {
+ return nil, "", "", err
+ }
+ parsedURL.User = nil
+ endpoint, endpointErr := dsn.Parse(parsedURL.String())
+ if endpointErr != nil {
+ return nil, "", "", err
+ }
+ return endpoint, username, password, nil
+}
+
+func credentialedDSNUserinfo(raw string) (string, string) {
+ parsed, err := url.Parse(raw)
+ if err != nil || parsed.User == nil {
+ return "", ""
+ }
+ password, ok := parsed.User.Password()
+ if !ok {
+ return "", ""
+ }
+ return parsed.User.Username(), password
+}
// validateSDKConfig validates the SDK configuration per Requirement 32.5 and 32.6.
// Returns an error if required fields are missing or values are invalid.
@@ -928,8 +1080,9 @@ func NewClient(cfg Config) (*Logger, error) {
// Step 3: Apply code-level config (highest precedence)
merged = mergeCodeConfig(merged, cfg)
+ merged.CollectorURL = safeCollectorEndpoint(merged.CollectorURL)
if strings.TrimSpace(merged.CollectorURL) == "" && strings.TrimSpace(merged.CollectorEndpoint) != "" {
- merged.CollectorURL = strings.TrimSpace(merged.CollectorEndpoint)
+ merged.CollectorURL = safeCollectorEndpoint(merged.CollectorEndpoint)
}
// Step 4: Validate the final config
@@ -938,12 +1091,14 @@ func NewClient(cfg Config) (*Logger, error) {
}
// If no explicit sink was configured, route events to the collector endpoint
- // using HTTPBatchSink (NDJSON batching with periodic flush).
if shouldInstallDefaultCollectorSink(merged) {
- // Build headers from config (auth + service identity)
+ // Build headers from config (auth + service identity). Bearer API-key
+ // authentication takes precedence over DSN Basic credentials.
headers := make(map[string]string)
if merged.APIKey != "" {
headers["Authorization"] = "Bearer " + merged.APIKey
+ } else if merged.DSNUsername != "" {
+ headers["Authorization"] = basicAuthorization(merged.DSNUsername, merged.DSNPassword)
}
if merged.Service != "" {
headers["X-Loza-Service"] = merged.Service
@@ -953,11 +1108,12 @@ func NewClient(cfg Config) (*Logger, error) {
}
batchSink, err := HTTPBatchSink(HTTPBatchSinkConfig{
- Endpoint: strings.TrimRight(merged.CollectorURL, "/") + "/events",
+ Endpoint: collectorRouteURL(merged.CollectorURL, merged.CollectorName, "/events"),
Headers: headers,
BatchSize: merged.BatchSize,
FlushInterval: merged.Async.FlushInterval,
Gzip: merged.EnableCompression,
+ Insecure: merged.Insecure,
})
if err != nil {
return nil, fmt.Errorf("loza: initialize httpbatch sink: %w", err)
@@ -992,6 +1148,16 @@ func mergeCodeConfig(base, code Config) Config {
base.CollectorEndpoint = code.CollectorEndpoint
base.CollectorURL = code.CollectorEndpoint
}
+ if code.APIKey != "" {
+ base.APIKey = code.APIKey
+ }
+ if code.DSNUsername != "" {
+ base.DSNUsername = code.DSNUsername
+ base.DSNPassword = code.DSNPassword
+ }
+ if code.codeSetInsecure || code.Insecure {
+ base.Insecure = code.Insecure
+ }
if code.Service != "" {
base.Service = code.Service
}
@@ -1004,6 +1170,9 @@ func mergeCodeConfig(base, code Config) Config {
if code.TenantID != "" {
base.TenantID = code.TenantID
}
+ if code.CollectorName != "" {
+ base.CollectorName = code.CollectorName
+ }
if code.BatchSize != 0 {
base.BatchSize = code.BatchSize
}
diff --git a/sdks/go/src/core/config_dsn_test.go b/sdks/go/src/core/config_dsn_test.go
index 4f63ffb..10d06bd 100644
--- a/sdks/go/src/core/config_dsn_test.go
+++ b/sdks/go/src/core/config_dsn_test.go
@@ -2,6 +2,7 @@ package core
import (
"os"
+ "strings"
"testing"
)
@@ -13,29 +14,33 @@ func TestWithDSN(t *testing.T) {
wantEnv string
wantService string
wantInsecure bool
+ wantCollector string
wantParseFail bool
}{
{
- name: "localhost dev",
- dsn: "loza://localhost:9308/demo?env=dev&tls=false",
- wantURL: "http://localhost:9308",
- wantEnv: "dev",
- wantInsecure: true,
+ name: "localhost dev",
+ dsn: "loza://localhost:9308/demo?env=dev&tls=false",
+ wantURL: "http://localhost:9308",
+ wantEnv: "dev",
+ wantInsecure: true,
+ wantCollector: "demo",
},
{
- name: "prod default tls",
- dsn: "loza://collector.example.com/demo?env=prod",
- wantURL: "https://collector.example.com:443",
- wantEnv: "prod",
- wantInsecure: false,
+ name: "prod default tls",
+ dsn: "loza://collector.example.com/demo?env=prod",
+ wantURL: "https://collector.example.com:443",
+ wantEnv: "prod",
+ wantInsecure: false,
+ wantCollector: "demo",
},
{
- name: "with service",
- dsn: "loza://collector.example.com/demo?env=staging&service=auth",
- wantURL: "https://collector.example.com:443",
- wantEnv: "staging",
- wantService: "auth",
- wantInsecure: false,
+ name: "with service",
+ dsn: "loza://collector.example.com/demo?env=staging&service=auth",
+ wantURL: "https://collector.example.com:443",
+ wantEnv: "staging",
+ wantService: "auth",
+ wantInsecure: false,
+ wantCollector: "demo",
},
{
name: "invalid DSN",
@@ -72,6 +77,9 @@ func TestWithDSN(t *testing.T) {
if cfg.Insecure != tt.wantInsecure {
t.Errorf("Insecure = %v, want %v", cfg.Insecure, tt.wantInsecure)
}
+ if cfg.CollectorName != tt.wantCollector {
+ t.Errorf("CollectorName = %q, want %q", cfg.CollectorName, tt.wantCollector)
+ }
})
}
}
@@ -189,3 +197,56 @@ func TestLoadFromEnv_DSN(t *testing.T) {
}
})
}
+
+func TestCredentialedDSNMappingAndPrecedence(t *testing.T) {
+ t.Setenv("LOZA_DSN", "loza://dsn-user:dsn%2Fsecret@collector.example.com/project?env=prod")
+ t.Setenv("LOZA_API_KEY", "")
+
+ cfg := LoadFromEnv(Config{})
+ if cfg.DSNUsername != "dsn-user" || cfg.DSNPassword != "dsn/secret" {
+ t.Fatalf("DSN credentials = %q/%q, want decoded userinfo", cfg.DSNUsername, cfg.DSNPassword)
+ }
+ if strings.Contains(cfg.CollectorURL, "dsn-user") || strings.Contains(cfg.CollectorURL, "secret") {
+ t.Fatalf("CollectorURL contains DSN credentials: %q", cfg.CollectorURL)
+ }
+
+ code := ApplyConfig(cfg, WithDSN("loza://code-user:code-secret@collector.example.com/project?env=prod"), WithAPIKey("api-key"))
+ if code.DSNUsername != "code-user" || code.DSNPassword != "code-secret" {
+ t.Fatalf("code DSN credentials = %q/%q", code.DSNUsername, code.DSNPassword)
+ }
+ if code.APIKey != "api-key" {
+ t.Fatalf("APIKey = %q, want explicit code API key", code.APIKey)
+ }
+}
+
+func TestPublicDSNUsesScopedEndpointAndEmptyBasicPassword(t *testing.T) {
+ const capability = "lx_pub_6DJvd3D0izOaQx3n5BhKqN"
+ cfg := ApplyConfig(
+ Config{},
+ WithDSN("loza://"+capability+":@collector.example.com/public-collector?env=prod"),
+ WithAPIKey("api-key"),
+ )
+
+ if cfg.CollectorName != "public-collector" {
+ t.Fatalf("CollectorName = %q, want public-collector", cfg.CollectorName)
+ }
+ if cfg.DSNUsername != capability || cfg.DSNPassword != "" {
+ t.Fatalf("DSN credentials = %q/%q, want public capability and empty password", cfg.DSNUsername, cfg.DSNPassword)
+ }
+ if strings.Contains(cfg.CollectorURL, capability) {
+ t.Fatalf("CollectorURL leaked public capability: %q", cfg.CollectorURL)
+ }
+ if cfg.APIKey != "api-key" {
+ t.Fatalf("API key precedence was not retained")
+ }
+}
+
+func TestCredentialedDSNRejectsRemotePlaintext(t *testing.T) {
+ cfg := ApplyConfig(
+ Config{},
+ WithDSN("loza://dsn-user:dsn-secret@collector.example.com/project?tls=false"),
+ )
+ if err := cfg.Validate(); err == nil {
+ t.Fatal("expected remote plaintext Basic-auth DSN to be rejected")
+ }
+}
diff --git a/sdks/go/src/core/config_options.go b/sdks/go/src/core/config_options.go
index 6fc253a..73d0be1 100644
--- a/sdks/go/src/core/config_options.go
+++ b/sdks/go/src/core/config_options.go
@@ -2,8 +2,6 @@ package core
import (
"time"
-
- "github.com/astraive/loza/spec/dsn"
)
// ConfigOption mutates and returns a Config.
@@ -158,6 +156,14 @@ func WithCollectorURL(url string) ConfigOption {
}
}
+// WithCollectorName applies the canonical collector slug used for scoped routes.
+func WithCollectorName(name string) ConfigOption {
+ return func(cfg Config) Config {
+ cfg.CollectorName = name
+ return cfg
+ }
+}
+
// WithTenantID applies the tenant ID.
func WithTenantID(tenantID string) ConfigOption {
return func(cfg Config) Config {
@@ -262,6 +268,25 @@ func WithAPIKey(apiKey string) ConfigOption {
}
}
+// WithBasicAuth sets Collector Basic-auth credentials. API-key authentication
+// still takes precedence when both are configured.
+func WithBasicAuth(username, password string) ConfigOption {
+ return func(cfg Config) Config {
+ cfg.DSNUsername = username
+ cfg.DSNPassword = password
+ return cfg
+ }
+}
+
+// WithInsecure allows plain HTTP connections for explicitly local development.
+func WithInsecure(insecure bool) ConfigOption {
+ return func(cfg Config) Config {
+ cfg.Insecure = insecure
+ cfg.codeSetInsecure = true
+ return cfg
+ }
+}
+
// WithOtelBridge enables or disables OpenTelemetry bridge integration.
func WithOtelBridge(enabled bool) ConfigOption {
return func(cfg Config) Config {
@@ -291,8 +316,9 @@ func WithLogger(l *Logger) ConfigOption {
}
// WithDSN parses a loza:// connection URI and applies the resolved values
-// to the config. It sets CollectorURL, Environment, Service (if present in
-// the DSN), and Insecure (derived from TLS setting).
+// to the config. It retains the credential-free collector base URL and records
+// the required collector slug so the default transport targets canonical
+// /collectors/{collector}/events routes.
//
// Individual config options or env vars applied after WithDSN will override
// the DSN-derived values.
@@ -300,22 +326,28 @@ func WithLogger(l *Logger) ConfigOption {
// Example:
//
// config.NewClient(config.Production(),
-// config.WithDSN("loza://localhost:9308/demo?env=dev&tls=false"),
+// config.WithDSN("loza://key-id:key-secret@collector.example/demo?env=prod"),
// )
func WithDSN(raw string) ConfigOption {
return func(cfg Config) Config {
- d, err := dsn.Parse(raw)
+ d, username, password, err := parseSDKDSN(raw)
if err != nil {
// Store the parse error; it will surface during NewClient validation.
cfg.CollectorURL = "" // signal invalid state
return cfg
}
cfg.CollectorURL = d.BaseURL
+ cfg.CollectorName = d.CollectorName
cfg.Environment = d.Env
if d.Service != "" {
cfg.Service = d.Service
}
+ if username != "" {
+ cfg.DSNUsername = username
+ cfg.DSNPassword = password
+ }
cfg.Insecure = !d.TLS
+ cfg.codeSetInsecure = true
return cfg
}
}
diff --git a/sdks/go/src/core/standard_sinks.go b/sdks/go/src/core/standard_sinks.go
index cc2dba4..20c7fc5 100644
--- a/sdks/go/src/core/standard_sinks.go
+++ b/sdks/go/src/core/standard_sinks.go
@@ -18,6 +18,23 @@ import (
speccontract "github.com/astraive/loza/spec/generated/go/contract"
)
+func cloneHeaders(headers map[string]string) map[string]string {
+ cloned := make(map[string]string, len(headers))
+ for key, value := range headers {
+ cloned[key] = value
+ }
+ return cloned
+}
+
+func hasNonEmptyHeader(headers map[string]string, name string) bool {
+ for key, value := range headers {
+ if strings.EqualFold(key, name) && value != "" {
+ return true
+ }
+ }
+ return false
+}
+
// ── stdout / stderr sinks ─────────────────────────────────────────────────────
type writerSink struct {
@@ -261,6 +278,9 @@ func NoopSink() Sink { return noopSink{} }
type CollectorSinkConfig struct {
Endpoint string
Headers map[string]string
+ BasicUsername string
+ BasicPassword string
+ Insecure bool
Client *http.Client
Transport *HTTPTransport
Metrics *MetricsCollector
@@ -282,6 +302,25 @@ func CollectorSink(cfg CollectorSinkConfig) (Sink, error) {
if strings.TrimSpace(cfg.Endpoint) == "" {
return nil, fmt.Errorf("loza: collector endpoint is required")
}
+ if err := validateCollectorCredentials(
+ cfg.Endpoint,
+ cfg.BasicUsername,
+ cfg.BasicPassword,
+ cfg.Insecure,
+ ); err != nil {
+ return nil, err
+ }
+ cfg.Endpoint = safeCollectorEndpoint(cfg.Endpoint)
+ if cfg.BasicUsername != "" {
+ if cfg.Headers == nil {
+ cfg.Headers = make(map[string]string)
+ } else {
+ cfg.Headers = cloneHeaders(cfg.Headers)
+ }
+ if !hasNonEmptyHeader(cfg.Headers, "Authorization") {
+ cfg.Headers["Authorization"] = basicAuthorization(cfg.BasicUsername, cfg.BasicPassword)
+ }
+ }
if cfg.Transport == nil {
cfg.Transport = NewHTTPTransport(HTTPTransportConfig{
MaxRetries: cfg.MaxRetries,
@@ -488,6 +527,9 @@ func gzipBody(body []byte) ([]byte, error) {
type HTTPBatchSinkConfig struct {
Endpoint string
Headers map[string]string
+ BasicUsername string
+ BasicPassword string
+ Insecure bool
BatchSize int
FlushInterval time.Duration
Gzip bool
@@ -503,12 +545,29 @@ type httpBatchSink struct {
closeOnce sync.Once
}
-// HTTPBatchSink creates a sink that batches events as NDJSON and flushes
-// to the endpoint when BatchSize is reached or FlushInterval elapses.
func HTTPBatchSink(cfg HTTPBatchSinkConfig) (Sink, error) {
if strings.TrimSpace(cfg.Endpoint) == "" {
return nil, fmt.Errorf("loza: httpbatch endpoint is required")
}
+ if err := validateCollectorCredentials(
+ cfg.Endpoint,
+ cfg.BasicUsername,
+ cfg.BasicPassword,
+ cfg.Insecure,
+ ); err != nil {
+ return nil, err
+ }
+ cfg.Endpoint = safeCollectorEndpoint(cfg.Endpoint)
+ if cfg.BasicUsername != "" {
+ if cfg.Headers == nil {
+ cfg.Headers = make(map[string]string)
+ } else {
+ cfg.Headers = cloneHeaders(cfg.Headers)
+ }
+ if !hasNonEmptyHeader(cfg.Headers, "Authorization") {
+ cfg.Headers["Authorization"] = basicAuthorization(cfg.BasicUsername, cfg.BasicPassword)
+ }
+ }
if cfg.BatchSize <= 0 {
cfg.BatchSize = 100
}
diff --git a/sdks/go/src/core/standard_sinks_test.go b/sdks/go/src/core/standard_sinks_test.go
index 16a2aef..c9a490b 100644
--- a/sdks/go/src/core/standard_sinks_test.go
+++ b/sdks/go/src/core/standard_sinks_test.go
@@ -204,3 +204,106 @@ func TestCollectorSink_CompressesRequestBodyWhenEnabled(t *testing.T) {
t.Fatalf("expected compressed collector envelope, got %s", string(decompressed))
}
}
+
+func TestHTTPBatchSink_BasicAuthAndSafeEndpoint(t *testing.T) {
+ var gotUser, gotPassword string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotUser, gotPassword, _ = r.BasicAuth()
+ if strings.Contains(r.URL.String(), "dsn-secret") {
+ t.Errorf("request URL contains credential material: %q", r.URL.String())
+ }
+ w.WriteHeader(http.StatusAccepted)
+ }))
+ defer srv.Close()
+
+ endpoint := srv.URL + "/events"
+ sink, err := HTTPBatchSink(HTTPBatchSinkConfig{
+ Endpoint: endpoint,
+ BasicUsername: "dsn-user",
+ BasicPassword: "dsn-secret",
+ Insecure: true,
+ BatchSize: 1,
+ })
+ if err != nil {
+ t.Fatalf("HTTPBatchSink() error = %v", err)
+ }
+ defer sink.Close(context.Background())
+ if err := sink.WriteEvent(context.Background(), []byte(`{"event":"test"}`), nil); err != nil {
+ t.Fatalf("WriteEvent() error = %v", err)
+ }
+ if gotUser != "dsn-user" || gotPassword != "dsn-secret" {
+ t.Fatalf("BasicAuth = %q/%q, want DSN credentials", gotUser, gotPassword)
+ }
+}
+
+func TestHTTPBatchSink_PublicBasicAuthUsesEmptyPassword(t *testing.T) {
+ const capability = "lx_pub_6DJvd3D0izOaQx3n5BhKqN"
+ var gotUser, gotPassword, gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotUser, gotPassword, _ = r.BasicAuth()
+ gotPath = r.URL.Path
+ w.WriteHeader(http.StatusAccepted)
+ }))
+ defer srv.Close()
+
+ sink, err := HTTPBatchSink(HTTPBatchSinkConfig{
+ Endpoint: srv.URL + "/collectors/public-collector/events",
+ BasicUsername: capability,
+ BasicPassword: "",
+ Insecure: true,
+ BatchSize: 1,
+ })
+ if err != nil {
+ t.Fatalf("HTTPBatchSink() error = %v", err)
+ }
+ defer sink.Close(context.Background())
+ if err := sink.WriteEvent(context.Background(), []byte(`{"event":"test"}`), nil); err != nil {
+ t.Fatalf("WriteEvent() error = %v", err)
+ }
+ if gotUser != capability || gotPassword != "" {
+ t.Fatalf("BasicAuth = %q/%q, want public capability with empty password", gotUser, gotPassword)
+ }
+ if gotPath != "/collectors/public-collector/events" {
+ t.Fatalf("request path = %q, want collector-scoped events route", gotPath)
+ }
+}
+
+func TestHTTPBatchSink_PreservesCaseInsensitiveAuthorizationHeader(t *testing.T) {
+ var gotAuthorization string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuthorization = r.Header.Get("Authorization")
+ w.WriteHeader(http.StatusAccepted)
+ }))
+ defer srv.Close()
+
+ sink, err := HTTPBatchSink(HTTPBatchSinkConfig{
+ Endpoint: srv.URL + "/events",
+ Headers: map[string]string{"authorization": "Bearer existing"},
+ BasicUsername: "dsn-user",
+ BasicPassword: "dsn-secret",
+ Insecure: true,
+ BatchSize: 1,
+ })
+ if err != nil {
+ t.Fatalf("HTTPBatchSink() error = %v", err)
+ }
+ defer sink.Close(context.Background())
+ if err := sink.WriteEvent(context.Background(), []byte(`{"event":"test"}`), nil); err != nil {
+ t.Fatalf("WriteEvent() error = %v", err)
+ }
+ if gotAuthorization != "Bearer existing" {
+ t.Fatalf("Authorization = %q, want existing Bearer header", gotAuthorization)
+ }
+}
+
+func TestCollectorClient_RejectsRemotePlaintextBasicAuth(t *testing.T) {
+ client := NewCollectorClient(CollectorClientConfig{
+ Endpoint: "http://collector.example.com",
+ BasicUsername: "dsn-user",
+ BasicPassword: "dsn-secret",
+ })
+ _, err := client.Ingest(context.Background(), nil)
+ if err == nil || !strings.Contains(err.Error(), "require TLS") {
+ t.Fatalf("Ingest() error = %v, want plaintext Basic-auth rejection", err)
+ }
+}
diff --git a/sdks/go/src/core/version.go b/sdks/go/src/core/version.go
index b637c35..bfa00c5 100644
--- a/sdks/go/src/core/version.go
+++ b/sdks/go/src/core/version.go
@@ -6,7 +6,7 @@ import (
"sync"
)
-const fallbackVersion = "0.2.6"
+const fallbackVersion = "0.3.0"
var (
sdkVersionOnce sync.Once
diff --git a/sdks/go/src/integrations/go.mod b/sdks/go/src/integrations/go.mod
index cb86001..ac7c3c0 100644
--- a/sdks/go/src/integrations/go.mod
+++ b/sdks/go/src/integrations/go.mod
@@ -1,6 +1,6 @@
module github.com/astraive/loza/sdks/go/src/integrations
-go 1.25.0
+go 1.25.13
require (
github.com/astraive/loza/sdks/go v0.0.0
@@ -11,7 +11,7 @@ require (
)
require (
- github.com/astraive/loza/spec v0.0.0 // indirect
+ github.com/astraive/loza/spec v0.2.6 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
diff --git a/sdks/go/src/integrations/go.sum b/sdks/go/src/integrations/go.sum
index b3f164f..5681471 100644
--- a/sdks/go/src/integrations/go.sum
+++ b/sdks/go/src/integrations/go.sum
@@ -64,7 +64,7 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/sdks/go/src/middleware/go.mod b/sdks/go/src/middleware/go.mod
index e501af0..bd267d1 100644
--- a/sdks/go/src/middleware/go.mod
+++ b/sdks/go/src/middleware/go.mod
@@ -1,6 +1,6 @@
module github.com/astraive/loza/sdks/go/src/middleware
-go 1.25.0
+go 1.25.13
require (
github.com/astraive/loza/sdks/go v0.0.0
@@ -13,7 +13,7 @@ require (
require (
github.com/andybalholm/brotli v1.1.0 // indirect
- github.com/astraive/loza/spec v0.0.0 // indirect
+ github.com/astraive/loza/spec v0.2.6 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
diff --git a/sdks/go/tests/conformance/manifest_sync_test.go b/sdks/go/tests/conformance/manifest_sync_test.go
index c7d0a25..4571efa 100644
--- a/sdks/go/tests/conformance/manifest_sync_test.go
+++ b/sdks/go/tests/conformance/manifest_sync_test.go
@@ -25,11 +25,11 @@ func TestParityManifestMatchesStableLifecycleDoc(t *testing.T) {
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatalf("unmarshal parity manifest: %v", err)
}
- if manifest.Version != "0.2.6" {
- t.Fatalf("expected product parity manifest version 0.2.6, got %q", manifest.Version)
+ if manifest.Version != "0.3.0" {
+ t.Fatalf("expected product parity manifest version 0.3.0, got %q", manifest.Version)
}
- if manifest.Stability != "product-parity-v0.2.6" {
- t.Fatalf("expected stability product-parity-v0.2.6, got %q", manifest.Stability)
+ if manifest.Stability != "product-parity-v0.3.0" {
+ t.Fatalf("expected stability product-parity-v0.3.0, got %q", manifest.Stability)
}
docPath := filepath.Join("..", "..", "docs", "public-api.md")
diff --git a/sdks/go/tests/e2e/go.mod b/sdks/go/tests/e2e/go.mod
index 8db0f63..7c11b9b 100644
--- a/sdks/go/tests/e2e/go.mod
+++ b/sdks/go/tests/e2e/go.mod
@@ -1,11 +1,11 @@
module github.com/astraive/loza/sdks/go/tests/e2e
-go 1.25.0
+go 1.25.13
require github.com/astraive/loza/sdks/go v0.0.0
require (
- github.com/astraive/loza/spec v0.0.0 // indirect
+ github.com/astraive/loza/spec v0.2.6 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
diff --git a/sdks/go/tests/e2e/go.sum b/sdks/go/tests/e2e/go.sum
index d5132c5..1bfddf7 100644
--- a/sdks/go/tests/e2e/go.sum
+++ b/sdks/go/tests/e2e/go.sum
@@ -51,7 +51,7 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/sdks/go/tests/integration/go.mod b/sdks/go/tests/integration/go.mod
index df6d933..b75b9d4 100644
--- a/sdks/go/tests/integration/go.mod
+++ b/sdks/go/tests/integration/go.mod
@@ -1,6 +1,6 @@
module github.com/astraive/loza/sdks/go/tests/integration
-go 1.25.0
+go 1.25.13
require (
github.com/astraive/loza/sdks/go v0.0.0
@@ -8,7 +8,7 @@ require (
)
require (
- github.com/astraive/loza/spec v0.0.0 // indirect
+ github.com/astraive/loza/spec v0.2.6 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
diff --git a/sdks/go/tests/integration/go.sum b/sdks/go/tests/integration/go.sum
index 297ba2c..916fb14 100644
--- a/sdks/go/tests/integration/go.sum
+++ b/sdks/go/tests/integration/go.sum
@@ -51,7 +51,7 @@ golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v0.0.1-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/sdks/js/README.md b/sdks/js/README.md
index 8d650a4..a7a74f9 100644
--- a/sdks/js/README.md
+++ b/sdks/js/README.md
@@ -2,7 +2,7 @@
LOZA wide-event SDK for JavaScript/TypeScript — lightweight bridge connector to loza-collector.
-**Status**: STABLE (v0.2.6) - Production-ready, full feature conformance
+**Status**: STABLE (v0.3.0) - Production-ready, full feature conformance
## Installation
diff --git a/sdks/js/docs/sdk-parity-manifest.json b/sdks/js/docs/sdk-parity-manifest.json
index b0da7b2..3cfee69 100644
--- a/sdks/js/docs/sdk-parity-manifest.json
+++ b/sdks/js/docs/sdk-parity-manifest.json
@@ -1,8 +1,8 @@
{
- "version": "0.2.6",
+ "version": "0.3.0",
"package": "loza",
"scope": "lightweight-sdk",
- "stability": "product-parity-v0.2.6",
+ "stability": "product-parity-v0.3.0",
"policy": "full-product-method-family",
"sdks": ["go", "python", "rust", "javascript"],
"lifecycle": [
diff --git a/sdks/js/loza-js.yaml b/sdks/js/loza-js.yaml
index 79c5655..4b4e2ea 100644
--- a/sdks/js/loza-js.yaml
+++ b/sdks/js/loza-js.yaml
@@ -1,11 +1,11 @@
name: loza-js
kind: sdk
language: javascript
-version: 0.2.6
+version: 0.3.0
description: JavaScript and TypeScript SDK for LOZA
license: MIT
repository: https://github.com/Astraive/loza
-module: npm:loza
+module: npm:@astraive/loza
paths:
root: sdks/js
@@ -13,5 +13,5 @@ paths:
publish:
npm:
owner: astraive
- package: loza
+ package: "@astraive/loza"
directory: sdks/js
diff --git a/sdks/js/package-lock.json b/sdks/js/package-lock.json
index 79417f1..44e5cc3 100644
--- a/sdks/js/package-lock.json
+++ b/sdks/js/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@astraive/loza",
- "version": "0.2.6",
+ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@astraive/loza",
- "version": "0.2.6",
+ "version": "0.3.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^25.9.1",
diff --git a/sdks/js/package.json b/sdks/js/package.json
index f9fd5b2..2a7408b 100644
--- a/sdks/js/package.json
+++ b/sdks/js/package.json
@@ -1,7 +1,7 @@
{
"name": "@astraive/loza",
"type": "module",
- "version": "0.2.6",
+ "version": "0.3.0",
"description": "LOZA wide-event SDK for JavaScript/TypeScript — lightweight bridge connector to loza-collector",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/sdks/js/src/collector/client.ts b/sdks/js/src/collector/client.ts
index 9c1a56d..641de8b 100644
--- a/sdks/js/src/collector/client.ts
+++ b/sdks/js/src/collector/client.ts
@@ -4,13 +4,16 @@ import { gzip as zlibGzip } from 'node:zlib';
import { promisify } from 'node:util';
import { buildIngestEnvelope, parseCollectorResponse } from '../generated/spec-contract.ts';
import type { CollectorResponse } from '../generated/spec-contract.ts';
+import { isPublicDSNUsername } from '../config/dsn.ts';
import { SDK_VERSION } from '../config/version.ts';
-
const gzipAsync = promisify(zlibGzip);
export interface CollectorClientOptions {
url: string;
+ collectorName?: string;
apiKey?: string;
+ username?: string;
+ password?: string;
authHeader?: string;
timeout?: number;
enableCompression?: boolean;
@@ -26,14 +29,35 @@ export interface VersionInfo {
/** Standalone client for loza-collector HTTP API. */
export class CollectorClient {
private url: string;
+ private collectorName: string;
private apiKey: string;
+ private username: string;
+ private password: string;
private authHeader: string;
private timeout: number;
private enableCompression: boolean;
constructor(opts: CollectorClientOptions) {
this.url = opts.url.replace(/\/$/, '');
+ const endpoint = new URL(this.url);
+ if (endpoint.username || endpoint.password) {
+ throw new Error('invalid CollectorClient URL: credentials must not be embedded in the URL');
+ }
this.apiKey = opts.apiKey || '';
+ this.username = opts.username || '';
+ this.password = opts.password || '';
+ this.collectorName = opts.collectorName || '';
+ if (!this.username && this.password) {
+ throw new Error('invalid CollectorClient options: Basic password requires a username');
+ }
+ if (this.username && !this.password && !isPublicDSNUsername(this.username)) {
+ throw new Error('invalid CollectorClient options: Basic credentials require a password unless username is an lx_pub_ capability');
+ }
+ if (!this.apiKey && this.username &&
+ endpoint.protocol === 'http:' &&
+ !['localhost', '127.0.0.1', '::1'].includes(endpoint.hostname)) {
+ throw new Error('invalid CollectorClient options: Basic credentials require HTTPS (HTTP is allowed only for localhost)');
+ }
this.authHeader = opts.authHeader || 'x-loza-api-key';
this.timeout = opts.timeout || 5000;
this.enableCompression = opts.enableCompression ?? true;
@@ -85,7 +109,7 @@ export class CollectorClient {
: this.apiKey;
}
- const res = await this.request('POST', '/events', body, headers);
+ const res = await this.request('POST', this.collectorPath('/events'), body, headers);
return parseCollectorResponse(res.body);
}
@@ -93,7 +117,7 @@ export class CollectorClient {
/** Validate an event against the collector schema. */
async validate(event: Record): Promise {
- const res = await this.request('POST', '/validate', Buffer.from(JSON.stringify(event), 'utf-8'));
+ const res = await this.request('POST', this.collectorPath('/validate'), Buffer.from(JSON.stringify(event), 'utf-8'));
return JSON.parse(res.body);
}
@@ -104,20 +128,22 @@ export class CollectorClient {
/** Query events from the collector. */
async query(query: Record): Promise {
- const res = await this.request('POST', '/query', Buffer.from(JSON.stringify(query), 'utf-8'));
+ const res = await this.request('POST', this.collectorPath('/query'), Buffer.from(JSON.stringify(query), 'utf-8'));
return JSON.parse(res.body);
}
/** Tail events from the collector (streaming stub). */
- async tail(query?: Record): Promise {
- const params = query ? '?' + new URLSearchParams(query as any).toString() : '';
- const res = await this.request('GET', `/tail${params}`);
+ async tail(query?: Record): Promise {
+ const params = query
+ ? `?${new URLSearchParams(Object.entries(query).map(([key, value]) => [key, String(value)])).toString()}`
+ : '';
+ const res = await this.request('GET', `${this.collectorPath('/tail')}${params}`);
return JSON.parse(res.body);
}
/** Delete events from the collector. */
async delete(query: Record): Promise {
- const res = await this.request('DELETE', '/events', Buffer.from(JSON.stringify(query), 'utf-8'));
+ const res = await this.request('DELETE', this.collectorPath('/events'), Buffer.from(JSON.stringify(query), 'utf-8'));
return JSON.parse(res.body);
}
@@ -200,6 +226,12 @@ export class CollectorClient {
return JSON.parse(res.body);
}
+ private collectorPath(path: string): string {
+ return this.collectorName
+ ? `/collectors/${encodeURIComponent(this.collectorName)}${path}`
+ : path;
+ }
+
private get(path: string): Promise<{ statusCode: number; body: string }> {
return this.request('GET', path);
}
@@ -211,7 +243,11 @@ export class CollectorClient {
const mod = isHttps ? https : http;
const headers: Record = { ...extraHeaders };
- if (this.apiKey && !headers[this.authHeader]) headers[this.authHeader] = this.apiKey;
+ if (this.apiKey && !headers[this.authHeader]) headers[this.authHeader] =
+ this.authHeader.toLowerCase() === 'authorization' ? `Bearer ${this.apiKey}` : this.apiKey;
+ if (!this.apiKey && this.username && (this.password || isPublicDSNUsername(this.username)) && !headers.Authorization) {
+ headers.Authorization = `Basic ${Buffer.from(`${this.username}:${this.password}`, 'utf8').toString('base64')}`;
+ }
if (body) headers['Content-Length'] = body.length.toString();
const req = mod.request({
diff --git a/sdks/js/src/config/config-file.ts b/sdks/js/src/config/config-file.ts
index 611102a..546d89c 100644
--- a/sdks/js/src/config/config-file.ts
+++ b/sdks/js/src/config/config-file.ts
@@ -244,6 +244,11 @@ export function mergeFileConfig(base: Config, raw: YamlRecord): Config {
try {
const dsn = parseDSN(cfg.collectorUrl);
cfg.collectorUrl = dsn.baseURL;
+ cfg.collectorName = dsn.collectorName;
+ if (dsn.username !== undefined) {
+ cfg.username = dsn.username;
+ cfg.password = dsn.password ?? '';
+ }
// Extract DSN-derived values (only override if not already set from YAML)
if (dsn.env && dsn.env !== 'default' && cfg.environment === 'development') {
cfg.environment = dsn.env;
diff --git a/sdks/js/src/config/config.ts b/sdks/js/src/config/config.ts
index aa22ae8..79f2ada 100644
--- a/sdks/js/src/config/config.ts
+++ b/sdks/js/src/config/config.ts
@@ -8,7 +8,56 @@ import type { Schema } from '../core/schema.ts';
import { DefaultSchema } from '../core/schema.ts';
import type { Level } from '../core/level.ts';
import { loadFileConfig, mergeFileConfig } from './config-file.ts';
-import { parse as parseDSN } from './dsn.ts';
+import { isPublicDSNUsername, parse as parseDSN } from './dsn.ts';
+
+function isLocalhost(host: string): boolean {
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
+}
+
+function hasBasicCredentials(cfg: Pick): boolean {
+ return !cfg.apiKey && !!cfg.username && (!!cfg.password || isPublicDSNUsername(cfg.username));
+}
+
+export function collectorRouteURL(baseURL: string, collectorName: string, route: string): string {
+ const base = baseURL.replace(/\/+$/, '');
+ return collectorName ? `${base}/collectors/${encodeURIComponent(collectorName)}${route}` : `${base}${route}`;
+}
+
+/** Apply a collector endpoint, resolving loza:// DSNs without retaining userinfo. */
+function applyCollectorURL(cfg: Config, raw: string): void {
+ if (!raw.startsWith('loza://')) {
+ cfg.collectorUrl = raw;
+ return;
+ }
+ const dsn = parseDSN(raw);
+ cfg.collectorUrl = dsn.baseURL;
+ cfg.collectorName = dsn.collectorName;
+ if (dsn.username !== undefined) {
+ cfg.username = dsn.username;
+ cfg.password = dsn.password ?? '';
+ }
+ if (dsn.env && dsn.env !== 'default') cfg.environment = dsn.env;
+ if (dsn.service) cfg.service = dsn.service;
+}
+
+/** Reject credentialed non-local plaintext HTTP before any request is made. */
+export function validateConfig(cfg: Config): Config {
+ if (!cfg.collectorUrl) return cfg;
+ const endpoint = new URL(cfg.collectorUrl);
+ if (endpoint.username || endpoint.password) {
+ throw new Error('invalid Loza config: credentials must not be embedded in the collector URL');
+ }
+ if (!cfg.username && cfg.password) {
+ throw new Error('invalid Loza config: Basic password requires a username');
+ }
+ if (cfg.username && !cfg.password && !isPublicDSNUsername(cfg.username)) {
+ throw new Error('invalid Loza config: Basic credentials require a password unless username is an lx_pub_ capability');
+ }
+ if (hasBasicCredentials(cfg) && endpoint.protocol === 'http:' && !isLocalhost(endpoint.hostname)) {
+ throw new Error('invalid Loza config: Basic credentials require HTTPS (HTTP is allowed only for localhost)');
+ }
+ return cfg;
+}
/** Async delivery configuration. */
export interface AsyncConfig {
@@ -34,11 +83,14 @@ export interface Config {
service: string;
alias: string;
version: string;
- environment: string;
release: string;
+ environment: string;
namespace: string;
collectorUrl: string;
+ collectorName: string;
apiKey: string;
+ username: string;
+ password: string;
sink: Sink | null;
sinks: Sink[];
sampler: Sampler;
@@ -91,24 +143,19 @@ export function fromEnv(): Config {
// Layer 3: Environment variables
if (typeof process !== 'undefined') {
- // Parse LOZA_DSN first (sets collectorUrl, environment, service)
+ // Parse LOZA_DSN first. Resolved URLs never retain userinfo.
const dsnRaw = process.env.LOZA_DSN;
if (dsnRaw) {
try {
- const dsn = parseDSN(dsnRaw);
- cfg.collectorUrl = dsn.baseURL;
- if (dsn.env && dsn.env !== 'default') {
- cfg.environment = dsn.env;
- }
- if (dsn.service) {
- cfg.service = dsn.service;
- }
+ applyCollectorURL(cfg, dsnRaw);
} catch {
// Invalid DSN — fall through to individual env vars
}
}
- // Individual env vars override DSN-derived and file-derived values
+ // Individual env vars override DSN-derived and file-derived values.
+ // LOZA_COLLECTOR_URL intentionally overrides only the endpoint; DSN
+ // environment/service/credentials remain in effect.
cfg.service = process.env.LOZA_SERVICE || process.env.SERVICE || cfg.service;
cfg.version = process.env.LOZA_VERSION || process.env.VERSION || cfg.version;
cfg.environment = process.env.LOZA_ENVIRONMENT || process.env.ENVIRONMENT || cfg.environment;
@@ -118,7 +165,7 @@ export function fromEnv(): Config {
cfg.apiKey = process.env.LOZA_API_KEY || process.env.API_KEY || cfg.apiKey;
cfg.level = process.env.LOZA_LEVEL || process.env.LOG_LEVEL || cfg.level;
}
- return cfg;
+ return validateConfig(cfg);
}
export function defaultConfig(): Config {
@@ -130,7 +177,10 @@ export function defaultConfig(): Config {
release: '',
namespace: '',
collectorUrl: '',
+ collectorName: '',
apiKey: (typeof process !== 'undefined' && process.env?.LOZA_API_KEY) || '',
+ username: '',
+ password: '',
sink: null,
sinks: [],
sampler: sampleAll(),
@@ -201,7 +251,10 @@ export interface ConfigOptions {
release?: string;
namespace?: string;
collectorUrl?: string;
+ collectorName?: string;
apiKey?: string;
+ username?: string;
+ password?: string;
sink?: Sink;
sampler?: Sampler;
redactor?: Redactor;
@@ -226,11 +279,14 @@ export class ConfigBuilder implements Config {
service: string;
alias: string;
version: string;
- environment: string;
release: string;
+ environment: string;
namespace: string;
collectorUrl: string;
+ collectorName: string;
apiKey: string;
+ username: string;
+ password: string;
sink: Sink | null;
sinks: Sink[];
sampler: Sampler;
@@ -255,11 +311,14 @@ export class ConfigBuilder implements Config {
this.service = base.service;
this.alias = base.alias;
this.version = base.version;
- this.environment = base.environment;
this.release = base.release;
+ this.environment = base.environment;
this.namespace = base.namespace;
this.collectorUrl = base.collectorUrl;
+ this.collectorName = base.collectorName;
this.apiKey = base.apiKey;
+ this.username = base.username;
+ this.password = base.password;
this.sink = base.sink;
this.sinks = base.sinks;
this.sampler = base.sampler;
@@ -285,8 +344,17 @@ export class ConfigBuilder implements Config {
withAlias(alias: string): this { this.alias = alias; return this; }
withVersion(version: string): this { this.version = version; return this; }
withEnvironment(environment: string): this { this.environment = environment; return this; }
- withCollectorUrl(url: string): this { this.collectorUrl = url; return this; }
+ withCollectorUrl(url: string): this {
+ Object.assign(this, withOptions(this, { collectorUrl: url }));
+ return this;
+ }
+ withCollectorName(collectorName: string): this { this.collectorName = collectorName; return this; }
withApiKey(apiKey: string): this { this.apiKey = apiKey.trim(); return this; }
+ withBasicAuth(username: string, password: string): this {
+ this.username = username;
+ this.password = password;
+ return this;
+ }
withSink(sink: Sink): this { this.sink = sink; return this; }
withSinks(...sinks: Sink[]): this { this.sinks = sinks; return this; }
withSampler(sampler: Sampler): this { this.sampler = sampler; return this; }
@@ -294,9 +362,9 @@ export class ConfigBuilder implements Config {
withSchema(schema: Schema): this { this.schema = schema; return this; }
withLevel(level: string): this { this.level = level; return this; }
withStrict(strict: boolean): this { this.strict = strict; return this; }
- withAsync(enabled: boolean): this { this.async.enabled = enabled; return this; }
- withCollectorEndpoint(url: string): this { this.collectorUrl = url; return this; }
withDuplicatePolicy(policy: string): this { this.duplicatePolicy = policy; return this; }
+ withAsync(enabled: boolean): this { this.async.enabled = enabled; return this; }
+ withCollectorEndpoint(url: string): this { return this.withCollectorUrl(url); }
withStatsHandler(_handler: unknown): this { return this; }
withDeploymentID(_deploymentId: string): this { return this; }
withIncludeHost(includeHost: boolean): this { this.includeHost = includeHost; return this; }
@@ -315,7 +383,7 @@ export class ConfigBuilder implements Config {
withQueueSize(size: number): this { this.async.queueSize = size; return this; }
withLogger(logger: Logger): this { this.logger = logger; return this; }
disabled(): Config { return { ...this, sink: null, sampler: sampleNone() }; }
- build(): Config { return { ...this }; }
+ build(): Config { return validateConfig({ ...this }); }
}
/** Create a config from a base config and options. */
@@ -325,8 +393,13 @@ export function withOptions(base: Config, opts: ConfigOptions): Config {
if (opts.alias !== undefined) cfg.alias = opts.alias;
if (opts.version !== undefined) cfg.version = opts.version;
if (opts.environment !== undefined) cfg.environment = opts.environment;
- if (opts.collectorUrl !== undefined) cfg.collectorUrl = opts.collectorUrl;
- if (opts.sink !== undefined) cfg.sink = opts.sink;
+ if (opts.release !== undefined) cfg.release = opts.release;
+ if (opts.namespace !== undefined) cfg.namespace = opts.namespace;
+ if (opts.collectorUrl !== undefined) applyCollectorURL(cfg, opts.collectorUrl);
+ if (opts.collectorName !== undefined) cfg.collectorName = opts.collectorName;
+ if (opts.apiKey !== undefined) cfg.apiKey = opts.apiKey.trim();
+ if (opts.username !== undefined) cfg.username = opts.username;
+ if (opts.password !== undefined) cfg.password = opts.password;
if (opts.sampler !== undefined) cfg.sampler = opts.sampler;
if (opts.redactor !== undefined) cfg.redactor = opts.redactor;
if (opts.schema !== undefined) cfg.schema = opts.schema;
@@ -340,7 +413,7 @@ export function withOptions(base: Config, opts: ConfigOptions): Config {
if (opts.enableCompression !== undefined) cfg.enableCompression = opts.enableCompression;
if (opts.duplicatePolicy !== undefined) cfg.duplicatePolicy = opts.duplicatePolicy;
if (opts.includeHost !== undefined) cfg.includeHost = opts.includeHost;
- return cfg;
+ return validateConfig(cfg);
}
export type ConfigOption = (cfg: Config) => Config;
@@ -357,6 +430,10 @@ export function WithEventSchema(schema: Schema): ConfigOption { return WithSchem
export function WithAsync(enabled: boolean): ConfigOption { return cfg => withOptions(cfg, { async: enabled }); }
export function WithCollectorEndpoint(collectorUrl: string): ConfigOption { return cfg => withOptions(cfg, { collectorUrl }); }
export function WithDuplicatePolicy(duplicatePolicy: string): ConfigOption { return cfg => withOptions(cfg, { duplicatePolicy }); }
+
+export function WithBasicAuth(username: string, password: string): ConfigOption {
+ return cfg => withOptions(cfg, { username, password });
+}
export function WithStatsHandler(statsHandler: unknown): ConfigOption { return cfg => withOptions(cfg, { statsHandler }); }
export function WithDeploymentID(deploymentId: string): ConfigOption { return cfg => withOptions(cfg, { deploymentId }); }
export function WithIncludeHost(includeHost: boolean): ConfigOption { return cfg => withOptions(cfg, { includeHost }); }
diff --git a/sdks/js/src/config/dsn.ts b/sdks/js/src/config/dsn.ts
index d7e74a6..a8df801 100644
--- a/sdks/js/src/config/dsn.ts
+++ b/sdks/js/src/config/dsn.ts
@@ -5,38 +5,55 @@
* It resolves to HTTP/HTTPS/OTLP/gRPC/WebSocket endpoints — it is NOT a wire protocol.
*
* Format:
- * loza://[host][:port]/[project]?env=&service=&tls=&transport=
- */
+ * loza://[username:password@][host][:port]/[collector]?env=&service=&tls=&transport=
+ *
+ * Private credentials use username:password. Public lx_pub_... credentials use
+ * an explicitly empty password (lx_pub_...:) as a bearer capability.
/** Parsed and resolved values from a loza:// connection URI. */
export interface LozaDSN {
- scheme: string; // always "loza"
- host: string; // hostname (no port)
- port: number; // resolved port number
- project: string; // path segment (project name)
- env: string; // environment name (default: "default")
- service: string; // optional service name
- tls: boolean; // whether to use HTTPS
- transport: string; // "http", "otlp", or "grpc" (default: "http")
- baseURL: string; // resolved http(s)://host:port
- eventsURL: string; // base + /events
- batchURL: string; // base + /events/batch
- otlpURL: string; // base + /otlp/logs
- tailWSURL: string; // ws(s)://host:port/tail
+ scheme: string; // always "loza"
+ username?: string; // decoded private key ID or public bearer capability
+ password?: string; // decoded private key secret; empty for public capabilities
+ host: string; // hostname (no port)
+ port: number; // resolved port number
+ collectorName: string; // canonical collector slug from the required path
+ project: string; // compatibility alias for collectorName
+ env: string; // environment name (default: "default")
+ service: string; // optional service name
+ tls: boolean; // whether to use HTTPS
+ transport: string; // "http", "otlp", or "grpc" (default: "http")
+ baseURL: string; // resolved http(s)://host:port (never includes userinfo)
+ eventsURL: string; // base + /collectors/{collector}/events
+ batchURL: string; // base + /collectors/{collector}/events/batch
+ otlpURL: string; // base + /collectors/{collector}/otlp/logs
+ tailWSURL: string; // ws(s)://host:port/collectors/{collector}/tail
+ toString(): string;
+ toJSON(): Omit;
}
+const USERINFO_RESERVED = /[:\s]/;
+const PASSWORD_RESERVED = /[/:?#\[\]@!$&'()*+,;=]/;
+
function isLocalhost(host: string): boolean {
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
}
+export function isPublicDSNUsername(username: string): boolean {
+ const prefix = 'lx_pub_';
+ return username.startsWith(prefix) && username.length > prefix.length;
+}
+
/**
* Parse a raw loza:// connection URI into a LozaDSN.
*
* Validation rules:
- * - Scheme must be loza://
- * - Host is required (loza:// or loza:///project are rejected)
- * - Project path is required (loza://host is rejected)
- * - No userinfo allowed (loza://user:pass@host/project is rejected)
+ * - Collector path is required (loza://host is rejected)
+ * - Private userinfo must contain non-empty username/password.
+ * - Public userinfo is lx_pub_...: with an explicitly empty password.
+ * - Userinfo is percent-decoded and malformed escapes are rejected.
+ * - Basic usernames cannot contain ':' or whitespace.
+ * - Password reserved characters must be percent-encoded.
* - tls must be "true", "false", or "auto"
* - transport must be "http", "otlp", or "grpc"
* - Port must be 1-65535 if specified
@@ -66,9 +83,45 @@ export function parse(raw: string): LozaDSN {
throw new Error(`invalid Loza DSN: ${e instanceof Error ? e.message : String(e)}`);
}
- // Reject userinfo (API keys must not be in the URL).
- if (url.username || url.password) {
- throw new Error('invalid Loza DSN: do not put API keys in the URL, use LOZA_API_KEY instead');
+ // Parse userinfo from the raw authority before URL normalisation. URL.username
+ // remains percent-encoded in Node, and inspecting the raw value lets us reject
+ // unescaped password delimiters without ever retaining credentials in output.
+ const authority = raw.slice('loza://'.length).split(/[/?#]/, 1)[0] || '';
+ const at = authority.lastIndexOf('@');
+ let username: string | undefined;
+ let password: string | undefined;
+ if (at >= 0) {
+ const userinfo = authority.slice(0, at);
+ const separator = userinfo.indexOf(':');
+ if (separator < 0 || separator !== userinfo.lastIndexOf(':')) {
+ throw new Error('invalid Loza DSN: userinfo must be username:password');
+ }
+ const rawUsername = userinfo.slice(0, separator);
+ const rawPassword = userinfo.slice(separator + 1);
+ if (!rawUsername) {
+ throw new Error('invalid Loza DSN: credentials require username:password or lx_pub_...:');
+ }
+ if (PASSWORD_RESERVED.test(rawPassword)) {
+ throw new Error('invalid Loza DSN: reserved password characters must be percent-encoded');
+ }
+ try {
+ username = decodeURIComponent(rawUsername);
+ password = decodeURIComponent(rawPassword);
+ } catch {
+ throw new Error('invalid Loza DSN: malformed percent-encoded userinfo');
+ }
+ if (!username || (password === '' && !isPublicDSNUsername(username))) {
+ throw new Error('invalid Loza DSN: credentials require username:password or lx_pub_...:');
+ }
+ if (USERINFO_RESERVED.test(username)) {
+ throw new Error('invalid Loza DSN: username must not contain ":" or whitespace');
+ }
+ }
+
+ // Reject userinfo if URL parsing found an authority shape we did not parse.
+ // (For example, URL may accept an empty userinfo as `url.username/password`.)
+ if (at < 0 && (url.username || url.password)) {
+ throw new Error('invalid Loza DSN: malformed userinfo');
}
// Strip brackets from IPv6 hostname (URL API may include them for non-standard schemes).
@@ -77,10 +130,11 @@ export function parse(raw: string): LozaDSN {
throw new Error('invalid Loza DSN: host is required');
}
- // Project is the path segment without leading slash.
- const project = url.pathname.replace(/^\//, '');
- if (!project) {
- throw new Error('invalid Loza DSN: project path is required, e.g. loza://host/my-project');
+ // The required path is the canonical collector identity. project remains
+ // available for compatibility with existing SDK consumers.
+ const collectorName = url.pathname.replace(/^\//, '');
+ if (!collectorName) {
+ throw new Error('invalid Loza DSN: collector path is required, e.g. loza://host/my-collector');
}
// ── TLS default ──────────────────────────────────────────────────────────
@@ -142,19 +196,30 @@ export function parse(raw: string): LozaDSN {
const hostPart = host.includes(':') ? `[${host}]` : host;
const baseURL = `${scheme}://${hostPart}:${port}`;
- return {
+ const collectorPath = encodeURIComponent(collectorName);
+ const collectorBaseURL = `${baseURL}/collectors/${collectorPath}`;
+ const collectorTailBaseURL = `${wsScheme}://${hostPart}:${port}/collectors/${collectorPath}`;
+ const redacted = {
scheme: 'loza',
host,
port,
- project,
+ collectorName,
+ project: collectorName,
env,
service,
tls,
transport,
baseURL,
- eventsURL: `${baseURL}/events`,
- batchURL: `${baseURL}/events/batch`,
- otlpURL: `${baseURL}/otlp/logs`,
- tailWSURL: `${wsScheme}://${hostPart}:${port}/tail`,
+ eventsURL: `${collectorBaseURL}/events`,
+ batchURL: `${collectorBaseURL}/events/batch`,
+ otlpURL: `${collectorBaseURL}/otlp/logs`,
+ tailWSURL: `${collectorTailBaseURL}/tail`,
+ };
+
+ return {
+ ...redacted,
+ ...(username !== undefined ? { username, password } : {}),
+ toString: () => baseURL,
+ toJSON: () => redacted,
};
}
diff --git a/sdks/js/src/config/version.ts b/sdks/js/src/config/version.ts
index 9321178..a7ea049 100644
--- a/sdks/js/src/config/version.ts
+++ b/sdks/js/src/config/version.ts
@@ -8,7 +8,7 @@ import { existsSync, readFileSync } from 'node:fs';
import { resolve, join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
-const FALLBACK_VERSION = '0.2.6';
+const FALLBACK_VERSION = '0.3.0';
/** Resolve the package root directory (two levels up from this file). */
function getPackageRoot(): string {
diff --git a/sdks/js/src/core/logger.ts b/sdks/js/src/core/logger.ts
index f755d35..73e6fdf 100644
--- a/sdks/js/src/core/logger.ts
+++ b/sdks/js/src/core/logger.ts
@@ -4,7 +4,7 @@ import {
String as StringAttr,
} from './event.ts';
import type { Attr, Params } from './event.ts';
-import { defaultConfig, withOptions, dev, production, test } from '../config/config.ts';
+import { collectorRouteURL, defaultConfig, withOptions, dev, production, test, validateConfig } from '../config/config.ts';
import type { Config, ConfigOptions } from '../config/config.ts';
import type { Sink } from '../sinks/sink.ts';
import { HTTPBatchSink } from '../sinks/standard-sinks.ts';
@@ -21,8 +21,10 @@ function resolveSink(cfg: Config): Sink | null {
if (cfg.sink) return cfg.sink;
if (cfg.collectorUrl) {
return new HTTPBatchSink({
- endpoint: cfg.collectorUrl,
+ endpoint: collectorRouteURL(cfg.collectorUrl, cfg.collectorName, '/events'),
apiKey: cfg.apiKey,
+ username: cfg.username,
+ password: cfg.password,
service: cfg.service,
batchSize: cfg.batchSize,
flushIntervalMs: cfg.flushIntervalMs,
@@ -42,7 +44,7 @@ export class Logger {
private _resolvedSink: Sink | null;
constructor(cfg?: Partial) {
- this.cfg = { ...defaultConfig(), ...cfg };
+ this.cfg = validateConfig({ ...defaultConfig(), ...cfg });
this._resolvedSink = resolveSink(this.cfg);
}
diff --git a/sdks/js/src/index.ts b/sdks/js/src/index.ts
index 65cb084..930f3cf 100644
--- a/sdks/js/src/index.ts
+++ b/sdks/js/src/index.ts
@@ -118,13 +118,13 @@ export type { Level } from './core/level.ts';
// --- Config + Builder ---
export {
defaultConfig, dev, development, production, test, withOptions, ConfigBuilder,
- fromEnv, disabled,
+ fromEnv, disabled, validateConfig,
dev as Dev, development as Development, production as Production, test as Test,
WithService, WithAlias, WithVersion, WithEnvironment, WithSink, WithSampler,
WithRedactor, WithSchema, WithEventSchema, WithAsync,
WithCollectorEndpoint, WithDuplicatePolicy, WithStatsHandler,
WithDeploymentID, WithIncludeHost, WithPanicRecovery,
- WithApiKey,
+ WithApiKey, WithBasicAuth,
WithRelease, WithNamespace, WithOtelBridge, WithRetry, WithTimeout, WithQueueSize, WithLogger,
} from './config/config.ts';
export type { Config, AsyncConfig, SecurityConfig, ConfigOptions } from './config/config.ts';
diff --git a/sdks/js/src/sinks/standard-sinks.ts b/sdks/js/src/sinks/standard-sinks.ts
index fc1f8fa..b1ee0ec 100644
--- a/sdks/js/src/sinks/standard-sinks.ts
+++ b/sdks/js/src/sinks/standard-sinks.ts
@@ -6,8 +6,8 @@ import { gzip as zlibGzip } from 'node:zlib';
import { promisify } from 'node:util';
import { parseCollectorResponse } from '../generated/spec-contract.ts';
import type { CollectorResponse } from '../generated/spec-contract.ts';
+import { isPublicDSNUsername } from '../config/dsn.ts';
import { SDK_VERSION } from '../config/version.ts';
-
const gzipAsync = promisify(zlibGzip);
/** Stats handler for collector acknowledgements. */
@@ -157,6 +157,8 @@ export class MemorySink implements Sink {
export class HTTPBatchSink implements Sink {
private endpoint: string;
private apiKey: string;
+ private username: string;
+ private password: string;
private authHeader: string;
private sdkName: string;
private sdkVersion: string;
@@ -177,6 +179,8 @@ export class HTTPBatchSink implements Sink {
constructor(opts: {
endpoint: string;
apiKey?: string;
+ username?: string;
+ password?: string;
authHeader?: string;
sdkName?: string;
sdkVersion?: string;
@@ -192,7 +196,24 @@ export class HTTPBatchSink implements Sink {
statsHandler?: StatsHandler;
}) {
this.endpoint = opts.endpoint;
+ const endpoint = new URL(this.endpoint);
+ if (endpoint.username || endpoint.password) {
+ throw new Error('invalid HTTP sink endpoint: credentials must not be embedded in the URL');
+ }
this.apiKey = opts.apiKey || '';
+ this.username = opts.username || '';
+ this.password = opts.password || '';
+ if (!this.username && this.password) {
+ throw new Error('invalid HTTP sink options: Basic password requires a username');
+ }
+ if (this.username && !this.password && !isPublicDSNUsername(this.username)) {
+ throw new Error('invalid HTTP sink options: Basic credentials require a password unless username is an lx_pub_ capability');
+ }
+ if (!this.apiKey && this.username &&
+ endpoint.protocol === 'http:' &&
+ !['localhost', '127.0.0.1', '::1'].includes(endpoint.hostname)) {
+ throw new Error('invalid HTTP sink options: Basic credentials require HTTPS (HTTP is allowed only for localhost)');
+ }
this.authHeader = opts.authHeader || 'Authorization';
this.sdkName = opts.sdkName || 'loza-js';
this.sdkVersion = opts.sdkVersion || SDK_VERSION;
@@ -369,13 +390,15 @@ export class HTTPBatchSink implements Sink {
const headers: Record = {
'Content-Type': this.ndjson ? 'application/x-ndjson' : 'application/json',
'Content-Length': body.length.toString(),
+ ...(this.enableCompression ? { 'Content-Encoding': 'gzip' } : {}),
};
if (this.apiKey) {
headers[this.authHeader] = this.authHeader.toLowerCase() === 'authorization'
? `Bearer ${this.apiKey}`
: this.apiKey;
+ } else if (this.username && (this.password || isPublicDSNUsername(this.username))) {
+ headers.Authorization = `Basic ${Buffer.from(`${this.username}:${this.password}`, 'utf8').toString('base64')}`;
}
- if (this.enableCompression) headers['Content-Encoding'] = 'gzip';
const req = mod.request({
hostname: url.hostname,
@@ -428,8 +451,9 @@ function sleep(ms: number): Promise {
export interface HTTPBatchSinkOptions {
endpoint: string;
apiKey?: string;
+ username?: string;
+ password?: string;
authHeader?: string;
- sdkName?: string;
sdkVersion?: string;
service?: string;
timeout?: number;
diff --git a/sdks/js/tests/collector-routing.test.ts b/sdks/js/tests/collector-routing.test.ts
new file mode 100644
index 0000000..3d6d428
--- /dev/null
+++ b/sdks/js/tests/collector-routing.test.ts
@@ -0,0 +1,43 @@
+import assert from 'node:assert/strict';
+import { createServer } from 'node:http';
+import { once } from 'node:events';
+import { test } from 'node:test';
+import { CollectorClient } from '../src/collector/client.ts';
+
+test('public Basic credentials reach a collector-scoped endpoint without URL userinfo', async () => {
+ const capability = 'lx_pub_6DJvd3D0izOaQx3n5BhKqN';
+ let requestPath = '';
+ let authorization = '';
+ const server = createServer((request, response) => {
+ requestPath = request.url ?? '';
+ authorization = request.headers.authorization ?? '';
+ response.writeHead(202, { 'content-type': 'application/json' });
+ response.end('{"request_id":"req_1","status":"accepted","accepted":1,"rejected":0,"invalid":0,"acks":[]}');
+ });
+
+ server.listen(0, '127.0.0.1');
+ await once(server, 'listening');
+ const address = server.address();
+ if (address === null || typeof address === 'string') {
+ throw new Error('test server did not expose a TCP address');
+ }
+ const { port } = address;
+
+ try {
+ const client = new CollectorClient({
+ url: `http://127.0.0.1:${port}`,
+ collectorName: 'public-collector',
+ username: capability,
+ password: '',
+ enableCompression: false,
+ });
+ await client.sendBatch([{ event: 'checkout.completed' }]);
+ } finally {
+ server.close();
+ await once(server, 'close');
+ }
+
+ assert.equal(requestPath, '/collectors/public-collector/events');
+ assert.equal(authorization, `Basic ${Buffer.from(`${capability}:`, 'utf8').toString('base64')}`);
+ assert.equal(requestPath.includes(capability), false);
+});
diff --git a/sdks/js/tests/config-file.test.ts b/sdks/js/tests/config-file.test.ts
index 2714dbc..d581fae 100644
--- a/sdks/js/tests/config-file.test.ts
+++ b/sdks/js/tests/config-file.test.ts
@@ -91,11 +91,14 @@ describe('mergeFileConfig', () => {
assert.equal(result.collectorUrl, 'http://collector:9308');
});
- it('parses loza:// DSN in collector_url', () => {
+ it('parses a credentialed DSN as a scoped, credential-free config', () => {
const base = defaultConfig();
- const raw = { collector_url: 'loza://localhost:9308/my-project' };
+ const raw = { collector_url: 'loza://private-user:private-secret@localhost:9308/my-project' };
const result = mergeFileConfig(base, raw);
assert.equal(result.collectorUrl, 'http://localhost:9308');
+ assert.equal(result.collectorName, 'my-project');
+ assert.equal(result.username, 'private-user');
+ assert.equal(result.password, 'private-secret');
});
it('extracts env from DSN when environment is default', () => {
@@ -105,6 +108,18 @@ describe('mergeFileConfig', () => {
assert.equal(result.environment, 'staging');
});
+ it('accepts a public DSN capability with an intentionally empty password', () => {
+ const base = defaultConfig();
+ const capability = 'lx_pub_6DJvd3D0izOaQx3n5BhKqN';
+ const result = mergeFileConfig(base, {
+ collector_url: `loza://${capability}:@localhost:9308/public-collector`,
+ });
+ assert.equal(result.collectorName, 'public-collector');
+ assert.equal(result.username, capability);
+ assert.equal(result.password, '');
+ assert.equal(result.collectorUrl.includes(capability), false);
+ });
+
it('applies batch_size to batchSize', () => {
const base = defaultConfig();
const raw = { batch_size: 200 };
diff --git a/sdks/js/tests/dsn.test.ts b/sdks/js/tests/dsn.test.ts
index b30c2e9..79d71d7 100644
--- a/sdks/js/tests/dsn.test.ts
+++ b/sdks/js/tests/dsn.test.ts
@@ -31,3 +31,45 @@ describe('loza:// DSN parser', () => {
}
}
});
+
+it('routes private credentials to a collector-scoped endpoint without retaining userinfo', () => {
+ const dsn = parse('loza://key%40id:secret%3Avalue@example.com/project');
+ assert.equal(dsn.collectorName, 'project');
+ assert.equal(dsn.project, 'project');
+ assert.equal(dsn.username, 'key@id');
+ assert.equal(dsn.password, 'secret:value');
+ assert.equal(dsn.baseURL, 'https://example.com:443');
+ assert.equal(dsn.eventsURL, 'https://example.com:443/collectors/project/events');
+ assert.equal(dsn.baseURL.includes('secret'), false);
+});
+
+it('rejects empty, malformed, and unescaped credential components', () => {
+ for (const raw of [
+ 'loza://:secret@example.com/project',
+ 'loza://key:@example.com/project',
+ 'loza://key:secret%2@example.com/project',
+ 'loza://key:secret/part@example.com/project',
+ ]) {
+ assert.throws(() => parse(raw), /invalid Loza DSN/);
+ }
+});
+
+it('redacts credentials from string and JSON representations', () => {
+ const dsn = parse('loza://key-id:s%40cret%3Avalue@example.com/project');
+ assert.equal(dsn.toString().includes('s@cret:value'), false);
+ assert.equal(JSON.stringify(dsn).includes('s@cret:value'), false);
+ assert.equal(JSON.stringify(dsn).includes('key-id'), false);
+});
+
+it('routes and redacts public bearer credentials', () => {
+ const capability = 'lx_pub_6DJvd3D0izOaQx3n5BhKqN';
+ const dsn = parse(`loza://${capability}:@example.com/public-collector`);
+ assert.equal(dsn.username, capability);
+ assert.equal(dsn.password, '');
+ assert.equal(dsn.eventsURL, 'https://example.com:443/collectors/public-collector/events');
+ assert.equal(dsn.batchURL, 'https://example.com:443/collectors/public-collector/events/batch');
+ assert.equal(dsn.otlpURL, 'https://example.com:443/collectors/public-collector/otlp/logs');
+ assert.equal(dsn.tailWSURL, 'wss://example.com:443/collectors/public-collector/tail');
+ assert.equal(dsn.toString().includes(capability), false);
+ assert.equal(JSON.stringify(dsn).includes(capability), false);
+});
diff --git a/sdks/py/README.md b/sdks/py/README.md
index f6d231d..8886e0c 100644
--- a/sdks/py/README.md
+++ b/sdks/py/README.md
@@ -1,6 +1,6 @@
# LOZA Python SDK
-**Status**: STABLE (v0.2.6) - Production-ready, full feature conformance
+**Status**: STABLE (v0.3.0) - Production-ready, full feature conformance
Full API conformance with specification is complete. See [SDK_CONFORMANCE_CONTRACT.md](../../spec/docs/SDK_CONFORMANCE_CONTRACT.md) for detailed guarantees.
diff --git a/sdks/py/docs/sdk-parity-manifest.json b/sdks/py/docs/sdk-parity-manifest.json
index b0da7b2..3cfee69 100644
--- a/sdks/py/docs/sdk-parity-manifest.json
+++ b/sdks/py/docs/sdk-parity-manifest.json
@@ -1,8 +1,8 @@
{
- "version": "0.2.6",
+ "version": "0.3.0",
"package": "loza",
"scope": "lightweight-sdk",
- "stability": "product-parity-v0.2.6",
+ "stability": "product-parity-v0.3.0",
"policy": "full-product-method-family",
"sdks": ["go", "python", "rust", "javascript"],
"lifecycle": [
diff --git a/sdks/py/loza-py.yaml b/sdks/py/loza-py.yaml
index 2b1ed9b..44f3211 100644
--- a/sdks/py/loza-py.yaml
+++ b/sdks/py/loza-py.yaml
@@ -1,7 +1,7 @@
name: loza-py
kind: sdk
language: python
-version: 0.2.6
+version: 0.3.0
description: Python SDK for LOZA
license: MIT
repository: https://github.com/Astraive/loza
diff --git a/sdks/py/pyproject.toml b/sdks/py/pyproject.toml
index f5afd48..f0a1294 100644
--- a/sdks/py/pyproject.toml
+++ b/sdks/py/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "loza"
-version = "0.2.6"
+version = "0.3.0"
description = "Python LOZA wide-event SDK"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/sdks/py/src/loza/core/client.py b/sdks/py/src/loza/core/client.py
index cca531a..0cc2538 100644
--- a/sdks/py/src/loza/core/client.py
+++ b/sdks/py/src/loza/core/client.py
@@ -70,7 +70,15 @@ def _install_default_collector_sink(self) -> None:
if not self._config.sinks or all(self._is_default_terminal_sink(sink) for sink in self._config.sinks):
self._config = replace(
self._config,
- sinks=[HTTPBatchSink(_collector_ingest_endpoint(endpoint), api_key=self._config.api_key, service=self._config.service)],
+ sinks=[
+ HTTPBatchSink(
+ _collector_ingest_endpoint(endpoint, self._config.collector_name),
+ api_key=self._config.api_key,
+ username=self._config.username,
+ password=self._config.password,
+ service=self._config.service,
+ )
+ ],
)
@staticmethod
diff --git a/sdks/py/src/loza/core/config.py b/sdks/py/src/loza/core/config.py
index 67124ec..7ce9cf3 100644
--- a/sdks/py/src/loza/core/config.py
+++ b/sdks/py/src/loza/core/config.py
@@ -4,6 +4,7 @@
from pathlib import Path
from dataclasses import dataclass, field, replace
from typing import Any, Callable, Protocol, runtime_checkable
+from urllib.parse import quote
from ..sinks.stdout import StdoutSink
from ..generated.spec_contract import LOZA_EVENT_VERSION, LOZA_INGEST_API_VERSION, LOZA_SPEC_VERSION
@@ -99,7 +100,10 @@ class Config:
strict: bool = False
sinks: list[Any] = field(default_factory=list)
collector_endpoint: str = ""
+ collector_name: str = ""
api_key: str = ""
+ username: str = field(default="", repr=False)
+ password: str = field(default="", repr=False)
duplicate_policy: str = "canonical_wins"
schema: Any = None
sampler: Any = None
@@ -173,6 +177,12 @@ def with_async(self, enabled: bool) -> "Config":
def with_collector_endpoint(self, endpoint: str) -> "Config":
return replace(self, collector_endpoint=endpoint)
+ def with_collector_name(self, collector_name: str) -> "Config":
+ return replace(self, collector_name=collector_name)
+
+ def with_basic_auth(self, username: str, password: str) -> "Config":
+ return replace(self, username=username, password=password)
+
def with_api_key(self, api_key: str) -> "Config":
return replace(self, api_key=api_key.strip())
@@ -215,6 +225,8 @@ def from_env(cls) -> "Config":
return _apply_env_vars(cls())
def validate(self) -> None:
+ if self.collector_endpoint.strip().startswith("loza://"):
+ _apply_dsn(self, self.collector_endpoint.strip(), include_credentials=not bool(self.api_key))
if self.level not in {"debug", "info", "notice", "warn", "error", "fatal"}:
raise ValueError(f"unsupported level: {self.level}")
if self.async_config.queue_size <= 0:
@@ -225,6 +237,18 @@ def validate(self) -> None:
raise ValueError("max_event_bytes must be positive")
if self.strict and not self.service:
raise ValueError("strict mode requires service")
+ from .dsn import is_public_dsn_username
+
+ if not self.username and self.password:
+ raise ValueError("collector basic auth password requires a username")
+ if self.username and not self.password and not is_public_dsn_username(self.username):
+ raise ValueError("collector basic auth requires a password unless username is an lx_pub_ capability")
+ if self.username and self.collector_endpoint.lower().startswith("http://"):
+ from urllib.parse import urlparse
+
+ host = (urlparse(self.collector_endpoint).hostname or "").lower()
+ if host not in {"localhost", "127.0.0.1", "::1"}:
+ raise ValueError("collector basic auth requires HTTPS except for local endpoints")
def load_layered_config() -> Config:
@@ -269,15 +293,51 @@ def new_client(code_config: Config): # -> Logger
return Logger(merged)
-def _collector_ingest_endpoint(endpoint: str) -> str:
+def _collector_ingest_endpoint(endpoint: str, collector_name: str = "") -> str:
endpoint = endpoint.strip().rstrip("/")
+ if collector_name:
+ return f"{endpoint}/collectors/{quote(collector_name, safe='')}/events"
if endpoint.endswith("/events"):
return endpoint
return f"{endpoint}/events"
+def _apply_dsn(cfg: Config, raw: str, *, include_credentials: bool = True) -> None:
+ from .dsn import parse
+
+ dsn = parse(raw)
+ cfg.collector_endpoint = dsn.base_url
+ cfg.collector_name = dsn.collector_name
+ if dsn.env != "default":
+ cfg.environment = dsn.env
+ if dsn.service:
+ cfg.service = dsn.service
+ if include_credentials and dsn.username and not cfg.api_key and not cfg.username:
+ cfg.username = dsn.username
+ cfg.password = dsn.password
+
+
+def _resolve_endpoint_source(cfg: Config, raw: str, *, include_credentials: bool) -> None:
+ raw = raw.strip()
+ if raw.startswith("loza://"):
+ _apply_dsn(cfg, raw, include_credentials=include_credentials)
+ else:
+ cfg.collector_endpoint = raw
+
+
def _apply_env_vars(cfg: Config) -> Config:
"""Apply environment variables to config, overriding file values."""
+ dsn_raw = os.getenv("LOZA_DSN", "").strip()
+ if dsn_raw:
+ _resolve_endpoint_source(cfg, dsn_raw, include_credentials=True)
+
+ collector_url = os.getenv("LOZA_COLLECTOR_URL", "").strip()
+ collector_endpoint = os.getenv("LOZA_COLLECTOR_ENDPOINT", "").strip()
+ if collector_url:
+ _resolve_endpoint_source(cfg, collector_url, include_credentials=False)
+ elif collector_endpoint:
+ _resolve_endpoint_source(cfg, collector_endpoint, include_credentials=False)
+
env_map = {
"LOZA_SERVICE": "service",
"LOZA_SERVICE_NAME": "service",
@@ -285,8 +345,6 @@ def _apply_env_vars(cfg: Config) -> Config:
"LOZA_ENVIRONMENT": "environment",
"LOZA_REGION": "region",
"LOZA_LOG_LEVEL": "level",
- "LOZA_COLLECTOR_URL": "collector_endpoint",
- "LOZA_COLLECTOR_ENDPOINT": "collector_endpoint",
"LOZA_API_KEY": "api_key",
"LOZA_DUPLICATE_POLICY": "duplicate_policy",
}
@@ -342,7 +400,14 @@ def _merge_file_config(base: Config, file_cfg: Config) -> Config:
if file_cfg.strict:
base.strict = file_cfg.strict
if file_cfg.collector_endpoint and not base.collector_endpoint:
- base.collector_endpoint = file_cfg.collector_endpoint
+ _resolve_endpoint_source(base, file_cfg.collector_endpoint, include_credentials=True)
+ if file_cfg.collector_name and not base.collector_name:
+ base.collector_name = file_cfg.collector_name
+ if file_cfg.api_key and not base.api_key:
+ base.api_key = file_cfg.api_key
+ if file_cfg.username and not base.username and not base.api_key:
+ base.username = file_cfg.username
+ base.password = file_cfg.password
if file_cfg.duplicate_policy and file_cfg.duplicate_policy != CanonicalWins:
base.duplicate_policy = file_cfg.duplicate_policy
if file_cfg.checkpoint_emit_immediately:
@@ -365,7 +430,14 @@ def _merge_code_config(base: Config, code: Config) -> Config:
if code.strict:
base.strict = code.strict
if code.collector_endpoint:
- base.collector_endpoint = code.collector_endpoint
+ _resolve_endpoint_source(base, code.collector_endpoint, include_credentials=not bool(code.api_key))
+ if code.api_key:
+ base.api_key = code.api_key.strip()
+ if code.collector_name:
+ base.collector_name = code.collector_name
+ if code.username and not code.api_key:
+ base.username = code.username
+ base.password = code.password
if code.duplicate_policy and code.duplicate_policy != CanonicalWins:
base.duplicate_policy = code.duplicate_policy
if code.sinks:
@@ -430,6 +502,10 @@ def _config_from_mapping(data: dict[str, Any]) -> Config:
level=str(data.get("level", "info")),
strict=bool(data.get("strict", False)),
collector_endpoint=str(data.get("collector_endpoint", "")),
+ collector_name=str(data.get("collector_name", "")),
+ api_key=str(data.get("api_key", "")),
+ username=str(data.get("username", "")),
+ password=str(data.get("password", "")),
duplicate_policy=str(data.get("duplicate_policy", CanonicalWins)),
checkpoint_emit_immediately=bool(data.get("checkpoint_emit_immediately", False)),
async_config=AsyncConfig(
diff --git a/sdks/py/src/loza/core/dsn.py b/sdks/py/src/loza/core/dsn.py
index ce9cd17..0731801 100644
--- a/sdks/py/src/loza/core/dsn.py
+++ b/sdks/py/src/loza/core/dsn.py
@@ -5,14 +5,18 @@
Format::
- loza://[host][:port]/[project]?env=&service=&tls=&transport=
+ loza://[username:password@][host][:port]/[collector]?env=&service=&tls=&transport=
+
+Private credentials use username:password. Public `lx_pub_...` bearer
+capabilities use an explicitly empty password (`lx_pub_...:`).
+
"""
from __future__ import annotations
import os
-from dataclasses import dataclass
-from urllib.parse import urlparse, parse_qs
+from dataclasses import dataclass, field
+from urllib.parse import parse_qs, quote, unquote_to_bytes, urlparse
@dataclass(frozen=True, slots=True)
@@ -22,6 +26,7 @@ class LozaDSN:
scheme: str
host: str
port: int
+ collector_name: str
project: str
env: str
service: str
@@ -32,6 +37,37 @@ class LozaDSN:
batch_url: str
otlp_url: str
tail_ws_url: str
+ username: str = field(default="", repr=False)
+ password: str = field(default="", repr=False)
+
+ def __repr__(self) -> str:
+ credentials = "" if self.username else ""
+ return (
+ "LozaDSN("
+ f"scheme={self.scheme!r}, host={self.host!r}, port={self.port!r}, "
+ f"collector_name={self.collector_name!r}, project={self.project!r}, "
+ f"env={self.env!r}, service={self.service!r}, tls={self.tls!r}, "
+ f"transport={self.transport!r}, base_url={self.base_url!r}, "
+ f"events_url={self.events_url!r}, batch_url={self.batch_url!r}, "
+ f"otlp_url={self.otlp_url!r}, tail_ws_url={self.tail_ws_url!r}, "
+ f"credentials={credentials!r})"
+ )
+
+ __str__ = __repr__
+
+
+_HEX = frozenset("0123456789abcdefABCDEF")
+_PASSWORD_RESERVED = frozenset(":/?#[]@!$&'()*+,;=")
+
+
+def _decode_userinfo(value: str, label: str) -> str:
+ for index, char in enumerate(value):
+ if char == "%" and (index + 2 >= len(value) or value[index + 1] not in _HEX or value[index + 2] not in _HEX):
+ raise ValueError(f"invalid Loza DSN: malformed percent-encoding in {label}")
+ try:
+ return unquote_to_bytes(value).decode("utf-8")
+ except UnicodeDecodeError as exc:
+ raise ValueError(f"invalid Loza DSN: {label} must be valid UTF-8") from exc
_LOCALHOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
@@ -41,6 +77,11 @@ def _is_localhost(host: str) -> bool:
return host in _LOCALHOSTS
+def is_public_dsn_username(username: str) -> bool:
+ prefix = "lx_pub_"
+ return username.startswith(prefix) and len(username) > len(prefix)
+
+
def parse(raw: str) -> LozaDSN:
"""Parse a raw loza:// connection URI into a LozaDSN.
@@ -53,13 +94,30 @@ def parse(raw: str) -> LozaDSN:
if not raw.startswith("loza://"):
raise ValueError("invalid Loza DSN: scheme must be loza://")
- u = urlparse(raw)
-
- # Reject userinfo (API keys must not be in the URL).
- if u.username:
- raise ValueError(
- "invalid Loza DSN: do not put API keys in the URL, use LOZA_API_KEY instead"
- )
+ try:
+ u = urlparse(raw)
+ except ValueError as exc:
+ raise ValueError("invalid Loza DSN: malformed URL") from exc
+
+ username = ""
+ password = ""
+ if "@" in u.netloc:
+ raw_userinfo = u.netloc.rsplit("@", 1)[0]
+ if ":" not in raw_userinfo:
+ raise ValueError("invalid Loza DSN: userinfo must include username and password")
+ raw_username, raw_password = raw_userinfo.split(":", 1)
+ if not raw_username:
+ raise ValueError("invalid Loza DSN: credentials require username:password or lx_pub_...:")
+ if any(char in _PASSWORD_RESERVED for char in raw_password):
+ raise ValueError("invalid Loza DSN: reserved password characters must be percent-encoded")
+ username = _decode_userinfo(raw_username, "username")
+ password = _decode_userinfo(raw_password, "password")
+ if not username or (not password and not is_public_dsn_username(username)):
+ raise ValueError("invalid Loza DSN: credentials require username:password or lx_pub_...:")
+ if ":" in username or any(char.isspace() for char in username):
+ raise ValueError("invalid Loza DSN: username must not contain ':' or whitespace")
+ elif u.username is not None or u.password is not None:
+ raise ValueError("invalid Loza DSN: malformed userinfo")
host = u.hostname or ""
if not host:
@@ -71,12 +129,11 @@ def parse(raw: str) -> LozaDSN:
except ValueError:
raise ValueError("invalid Loza DSN: invalid port")
- # Project is the path segment without leading slash.
- project = u.path.lstrip("/")
- if not project:
- raise ValueError(
- "invalid Loza DSN: project path is required, e.g. loza://host/my-project"
- )
+ # The required path is the canonical collector identity. project remains
+ # a compatibility alias for existing SDK consumers.
+ collector_name = u.path.lstrip("/")
+ if not collector_name:
+ raise ValueError("invalid Loza DSN: collector path is required, e.g. loza://host/my-collector")
q = parse_qs(u.query)
@@ -91,9 +148,7 @@ def parse(raw: str) -> LozaDSN:
elif tls_val == "auto":
pass # keep computed default
else:
- raise ValueError(
- f"invalid Loza DSN: tls must be true, false, or auto, got {tls_val!r}"
- )
+ raise ValueError(f"invalid Loza DSN: tls must be true, false, or auto, got {tls_val!r}")
# --- Port default -------------------------------------------------------
if tls:
@@ -116,9 +171,7 @@ def parse(raw: str) -> LozaDSN:
if transport_val in ("http", "otlp", "grpc"):
transport = transport_val
else:
- raise ValueError(
- f"invalid Loza DSN: transport must be http, otlp, or grpc, got {transport_val!r}"
- )
+ raise ValueError(f"invalid Loza DSN: transport must be http, otlp, or grpc, got {transport_val!r}")
# --- Env ----------------------------------------------------------------
env = q.get("env", ["default"])[0]
@@ -135,21 +188,27 @@ def parse(raw: str) -> LozaDSN:
host_part = f"[{host}]" if ":" in host else host
base_url = f"{scheme}://{host_part}:{port}"
+ collector_path = quote(collector_name, safe="")
+ collector_base_url = f"{base_url}/collectors/{collector_path}"
+ collector_tail_base_url = f"{ws_scheme}://{host_part}:{port}/collectors/{collector_path}"
return LozaDSN(
scheme="loza",
host=host,
port=port,
- project=project,
+ collector_name=collector_name,
+ project=collector_name,
env=env,
service=service,
tls=tls,
transport=transport,
base_url=base_url,
- events_url=base_url + "/events",
- batch_url=base_url + "/events/batch",
- otlp_url=base_url + "/otlp/logs",
- tail_ws_url=f"{ws_scheme}://{host_part}:{port}/tail",
+ events_url=collector_base_url + "/events",
+ batch_url=collector_base_url + "/events/batch",
+ otlp_url=collector_base_url + "/otlp/logs",
+ tail_ws_url=collector_tail_base_url + "/tail",
+ username=username,
+ password=password,
)
diff --git a/sdks/py/src/loza/core/http_client.py b/sdks/py/src/loza/core/http_client.py
index c4bbbec..ad0bc45 100644
--- a/sdks/py/src/loza/core/http_client.py
+++ b/sdks/py/src/loza/core/http_client.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import base64
import json
import socket
import time
@@ -19,6 +20,7 @@
validate_event_payload,
)
from ..version import SDK_VERSION
+from .dsn import is_public_dsn_username
class InstrumentedHTTPClient:
@@ -131,8 +133,11 @@ def __init__(
sdk_name: str = "loza-py",
sdk_version: str = SDK_VERSION,
service: str = "",
+ username: str = "",
+ password: str = "",
) -> None:
_validate_collector_endpoint(endpoint)
+ _validate_basic_auth_endpoint(endpoint, username, password)
self.endpoint = endpoint
self.api_key = api_key
self.auth_header = auth_header
@@ -141,6 +146,18 @@ def __init__(
self.sdk_name = sdk_name
self.sdk_version = sdk_version
self.service = service
+ self.username = username
+ self.password = password
+
+ def _auth_headers(self) -> dict[str, str]:
+ if self.api_key:
+ if self.auth_header.lower() == "authorization":
+ return {self.auth_header: f"Bearer {self.api_key}"}
+ return {self.auth_header: self.api_key}
+ if self.username or self.password:
+ token = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii")
+ return {"Authorization": f"Basic {token}"}
+ return {}
def envelope(self, encoded_events: Iterable[str]) -> bytes:
return _build_ingest_body(encoded_events, self.sdk_name, self.sdk_version, self.service)
@@ -148,11 +165,7 @@ def envelope(self, encoded_events: Iterable[str]) -> bytes:
def send_batch(self, encoded_events: Iterable[str]) -> CollectorResponse:
body = self.envelope(encoded_events)
headers = {"content-type": "application/json"}
- if self.api_key:
- if self.auth_header.lower() == "authorization":
- headers[self.auth_header] = f"Bearer {self.api_key}"
- else:
- headers[self.auth_header] = self.api_key
+ headers.update(self._auth_headers())
request = Request(self.endpoint, data=body, headers=headers, method="POST")
last_error: Exception | None = None
for attempt in range(self.retries + 1):
@@ -193,7 +206,7 @@ def _base_url(self) -> str:
def health(self) -> bool:
"""Check if the collector is healthy."""
for path in ("/health", "/healthz"):
- req = Request(f"{self._base_url()}{path}", method="GET")
+ req = Request(f"{self._base_url()}{path}", headers=self._auth_headers(), method="GET")
try:
with urlopen(req, timeout=self.timeout) as resp:
data = json.loads(resp.read())
@@ -206,7 +219,7 @@ def health(self) -> bool:
def ready(self) -> bool:
"""Check if the collector is ready to accept requests."""
for path in ("/ready", "/readyz"):
- req = Request(f"{self._base_url()}{path}", method="GET")
+ req = Request(f"{self._base_url()}{path}", headers=self._auth_headers(), method="GET")
try:
with urlopen(req, timeout=self.timeout) as resp:
data = json.loads(resp.read())
@@ -218,18 +231,13 @@ def ready(self) -> bool:
def version(self) -> dict:
"""Fetch version info from the collector."""
- req = Request(f"{self._base_url()}/version", method="GET")
+ req = Request(f"{self._base_url()}/version", headers=self._auth_headers(), method="GET")
with urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read())
def status(self) -> dict:
"""Fetch operational status from the collector."""
- req = Request(f"{self._base_url()}/status", method="GET")
- if self.api_key:
- if self.auth_header.lower() == "authorization":
- req.add_header(self.auth_header, f"Bearer {self.api_key}")
- else:
- req.add_header(self.auth_header, self.api_key)
+ req = Request(f"{self._base_url()}/status", headers=self._auth_headers(), method="GET")
with urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read())
@@ -258,7 +266,7 @@ def tail_lines(
if params:
url += "?" + urlencode(params)
- request = Request(url, headers={"accept": "application/x-ndjson"})
+ request = Request(url, headers={"accept": "application/x-ndjson", **self._auth_headers()})
with urlopen(request, timeout=timeout) as response:
for raw in response:
line = raw.decode("utf-8").strip()
@@ -270,9 +278,15 @@ def tail_lines(
def _validate_collector_endpoint(endpoint: str) -> None:
- parsed = urlparse(endpoint)
+ try:
+ parsed = urlparse(endpoint)
+ parsed.port
+ except ValueError as exc:
+ raise ValueError("collector endpoint is malformed") from exc
if parsed.scheme not in {"http", "https"}:
raise ValueError("collector endpoint must use http or https")
+ if parsed.username is not None or parsed.password is not None:
+ raise ValueError("collector endpoint must not contain credentials")
if not parsed.hostname:
raise ValueError("collector endpoint must include a host")
if _private_endpoint_allowed():
@@ -291,6 +305,18 @@ def _validate_collector_endpoint(endpoint: str) -> None:
raise ValueError("collector endpoint resolves to a non-public address")
+def _validate_basic_auth_endpoint(endpoint: str, username: str, password: str) -> None:
+ if not username and password:
+ raise ValueError("collector basic auth password requires a username")
+ if username and not password and not is_public_dsn_username(username):
+ raise ValueError("collector basic auth requires a password unless username is an lx_pub_ capability")
+ if not username:
+ return
+ parsed = urlparse(endpoint)
+ if parsed.scheme == "http" and (parsed.hostname or "").lower() not in {"localhost", "127.0.0.1", "::1"}:
+ raise ValueError("collector basic auth requires HTTPS except for local endpoints")
+
+
def _private_endpoint_allowed() -> bool:
return getenv("LOZA_ALLOW_PRIVATE_COLLECTOR_ENDPOINTS", "").lower() in {"1", "true", "yes"}
@@ -324,11 +350,7 @@ def _collector_request(
url += "?" + urlencode(params)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"content-type": "application/json"} if body else {}
- if client.api_key:
- if client.auth_header.lower() == "authorization":
- headers[client.auth_header] = f"Bearer {client.api_key}"
- else:
- headers[client.auth_header] = client.api_key
+ headers.update(client._auth_headers())
req = Request(url, data=data, headers=headers, method=method)
with urlopen(req, timeout=client.timeout) as resp:
raw = resp.read()
diff --git a/sdks/py/src/loza/core/logger.py b/sdks/py/src/loza/core/logger.py
index df1177c..3de1c74 100644
--- a/sdks/py/src/loza/core/logger.py
+++ b/sdks/py/src/loza/core/logger.py
@@ -72,7 +72,15 @@ def _install_default_collector_sink(self) -> None:
if not self._config.sinks or all(self._is_default_terminal_sink(sink) for sink in self._config.sinks):
self._config = replace(
self._config,
- sinks=[HTTPBatchSink(_collector_ingest_endpoint(endpoint), api_key=self._config.api_key, service=self._config.service)],
+ sinks=[
+ HTTPBatchSink(
+ _collector_ingest_endpoint(endpoint, self._config.collector_name),
+ api_key=self._config.api_key,
+ username=self._config.username,
+ password=self._config.password,
+ service=self._config.service,
+ )
+ ],
)
@staticmethod
diff --git a/sdks/py/src/loza/core/lql.py b/sdks/py/src/loza/core/lql.py
index 39e886d..267016f 100644
--- a/sdks/py/src/loza/core/lql.py
+++ b/sdks/py/src/loza/core/lql.py
@@ -61,7 +61,9 @@ def query_lql(endpoint: str, lql: str, *, api_key: str = "", timeout: float = 30
)
-def query_sql(endpoint: str, sql: str, *, engine: str = "duckdb", api_key: str = "", timeout: float = 30.0) -> QueryResult:
+def query_sql(
+ endpoint: str, sql: str, *, engine: str = "duckdb", api_key: str = "", timeout: float = 30.0
+) -> QueryResult:
"""Execute a raw SQL query against the collector.
Args:
diff --git a/sdks/py/src/loza/sinks/httpbatch/httpbatch.py b/sdks/py/src/loza/sinks/httpbatch/httpbatch.py
index a146aad..bceef40 100644
--- a/sdks/py/src/loza/sinks/httpbatch/httpbatch.py
+++ b/sdks/py/src/loza/sinks/httpbatch/httpbatch.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import base64
import gzip as gzip_mod
import time
import urllib.request
@@ -19,6 +20,7 @@
_parse_collector_response_body,
_parse_retry_after,
_retry_delay,
+ _validate_basic_auth_endpoint,
_validate_collector_endpoint,
)
from ...version import SDK_VERSION
@@ -35,6 +37,8 @@ class HTTPBatchSink:
endpoint: str
api_key: str = ""
+ username: str = ""
+ password: str = ""
auth_header: str = "Authorization"
sdk_name: str = "loza-py"
sdk_version: str = SDK_VERSION
@@ -50,6 +54,17 @@ class HTTPBatchSink:
def __post_init__(self) -> None:
_validate_collector_endpoint(self.endpoint)
+ _validate_basic_auth_endpoint(self.endpoint, self.username, self.password)
+
+ def _auth_headers(self) -> dict[str, str]:
+ if self.api_key:
+ if self.auth_header.lower() == "authorization":
+ return {self.auth_header: f"Bearer {self.api_key}"}
+ return {self.auth_header: self.api_key}
+ if self.username or self.password:
+ token = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii")
+ return {"Authorization": f"Basic {token}"}
+ return {}
def write(self, encoded: str) -> None:
self.write_batch([encoded])
@@ -69,11 +84,7 @@ def write_batch(self, encoded_events: Iterable[str]) -> None:
payload = gzip_mod.compress(payload)
headers["content-encoding"] = "gzip"
- if self.api_key:
- if self.auth_header.lower() == "authorization":
- headers[self.auth_header] = f"Bearer {self.api_key}"
- else:
- headers[self.auth_header] = self.api_key
+ headers.update(self._auth_headers())
req = urllib.request.Request(self.endpoint, data=payload, headers=headers, method="POST")
last_error: Exception | None = None
diff --git a/sdks/py/src/loza/version.py b/sdks/py/src/loza/version.py
index 0de0dd7..2c51232 100644
--- a/sdks/py/src/loza/version.py
+++ b/sdks/py/src/loza/version.py
@@ -5,7 +5,7 @@
from __future__ import annotations
-_FALLBACK_VERSION = "0.2.6"
+_FALLBACK_VERSION = "0.3.0"
def _load_version() -> str:
diff --git a/sdks/py/src/loza_contract.py b/sdks/py/src/loza_contract.py
index 7aa0b55..98bc2a4 100644
--- a/sdks/py/src/loza_contract.py
+++ b/sdks/py/src/loza_contract.py
@@ -6,7 +6,7 @@
from typing import Any, Iterable
CONTRACT = {
- "product_version": "0.2.6",
+ "product_version": "0.3.0",
"spec_version": "v1",
"api_version": "v1",
"event_version": "v1",
@@ -298,7 +298,7 @@
},
"fixtures": {
"version": "v1",
- "product_version": "0.2.6",
+ "product_version": "0.3.0",
"strict_schema": "../schema/event.strict.schema.json",
"loose_schema": "../schema/event.loose.schema.json",
"valid": [
diff --git a/sdks/py/tests/integration/test_e2e.py b/sdks/py/tests/integration/test_e2e.py
index 75e50de..e8a7c3d 100644
--- a/sdks/py/tests/integration/test_e2e.py
+++ b/sdks/py/tests/integration/test_e2e.py
@@ -1,4 +1,5 @@
"""Integration tests for Python SDK with collector."""
+
import json
import time
import loza
@@ -23,11 +24,7 @@ def test_event_integrity_through_pipeline() -> None:
logger = loza.New(loza.Test("test_service"))
# Create event with specific values
- ctx = logger.start_event(loza.Params(
- event="integrity_test",
- message="Test message",
- level="info"
- ))
+ ctx = logger.start_event(loza.Params(event="integrity_test", message="Test message", level="info"))
logger.enrich(ctx, loza.String("user_id", "user123"))
logger.finish(ctx, "success")
payload = logger.emit(ctx)
@@ -64,10 +61,7 @@ def test_error_event_collection() -> None:
"""Verify error events are properly collected."""
logger = loza.New(loza.Test("error_test"))
- ctx = logger.start_event(loza.Params(
- event="error_event",
- message="Something went wrong"
- ))
+ ctx = logger.start_event(loza.Params(event="error_event", message="Something went wrong"))
logger.enrich(ctx, loza.String("error_code", "E001"))
logger.finish(ctx, "error")
payload = logger.emit(ctx)
@@ -127,9 +121,7 @@ def test_canonical_fields_immutable() -> None:
def test_sampling_in_pipeline() -> None:
"""Verify sampling decision is consistent through pipeline."""
# Create logger with deterministic sampling
- logger = loza.New(
- loza.Test("sample_test").with_sampler(loza.SampleAll())
- )
+ logger = loza.New(loza.Test("sample_test").with_sampler(loza.SampleAll()))
for i in range(3):
ctx = logger.start_event(loza.Params(event=f"sampled_{i}"))
diff --git a/sdks/py/tests/test_alias.py b/sdks/py/tests/test_alias.py
index 3ffa69f..085664e 100644
--- a/sdks/py/tests/test_alias.py
+++ b/sdks/py/tests/test_alias.py
@@ -1,4 +1,5 @@
"""Tests for create_loza and alias functions."""
+
import json
import loza
diff --git a/sdks/py/tests/test_behavior.py b/sdks/py/tests/test_behavior.py
index 27c0095..d652586 100644
--- a/sdks/py/tests/test_behavior.py
+++ b/sdks/py/tests/test_behavior.py
@@ -11,10 +11,7 @@
def test_schema_redaction_and_flattening() -> None:
sink = loza.MemorySink()
cfg = (
- loza.Test("checkout")
- .with_sink(sink)
- .with_schema(loza.FlatSchema())
- .with_redactor(loza.RedactKeys("password"))
+ loza.Test("checkout").with_sink(sink).with_schema(loza.FlatSchema()).with_redactor(loza.RedactKeys("password"))
)
logger = loza.New(cfg)
ctx = logger.start_event(loza.Params(event="checkout.run"))
diff --git a/sdks/py/tests/test_canonical_fields.py b/sdks/py/tests/test_canonical_fields.py
index 8685dbd..907bff3 100644
--- a/sdks/py/tests/test_canonical_fields.py
+++ b/sdks/py/tests/test_canonical_fields.py
@@ -31,8 +31,7 @@ def test_schema_version_is_protected() -> None:
payload2 = json.loads(logger2.emit(ctx2))
# Canonical should remain v1, not override to v999
- assert payload2["schema_version"] == "v1", \
- "schema_version should be protected, not overridable by user"
+ assert payload2["schema_version"] == "v1", "schema_version should be protected, not overridable by user"
def test_event_version_is_protected() -> None:
@@ -51,8 +50,7 @@ def test_event_version_is_protected() -> None:
logger2.finish(ctx2, "success")
payload2 = json.loads(logger2.emit(ctx2))
- assert payload2["event_version"] == "v1", \
- "event_version should be protected, not overridable by user"
+ assert payload2["event_version"] == "v1", "event_version should be protected, not overridable by user"
def test_event_id_is_protected() -> None:
@@ -66,8 +64,7 @@ def test_event_id_is_protected() -> None:
logger.finish(ctx, "success")
payload = json.loads(logger.emit(ctx))
- assert payload["event_id"] == original_id, \
- "event_id should be protected, not overridable by user"
+ assert payload["event_id"] == original_id, "event_id should be protected, not overridable by user"
assert payload["event_id"] != "user_attempted_override"
@@ -87,8 +84,7 @@ def test_timestamp_is_protected() -> None:
logger2.finish(ctx2, "success")
payload2 = json.loads(logger2.emit(ctx2))
- assert payload2["timestamp"] != "1970-01-01T00:00:00Z", \
- "timestamp should be protected, not overridable by user"
+ assert payload2["timestamp"] != "1970-01-01T00:00:00Z", "timestamp should be protected, not overridable by user"
def test_service_is_protected() -> None:
@@ -101,8 +97,7 @@ def test_service_is_protected() -> None:
logger.finish(ctx, "success")
payload = json.loads(logger.emit(ctx))
- assert payload["service"] == "my-service", \
- "service should be protected, not overridable by user"
+ assert payload["service"] == "my-service", "service should be protected, not overridable by user"
assert payload["service"] != "attacker-service"
@@ -116,8 +111,7 @@ def test_event_name_is_protected() -> None:
logger.finish(ctx, "success")
payload = json.loads(logger.emit(ctx))
- assert payload["event"] == "checkout.completed", \
- "event name should be protected, not overridable by user"
+ assert payload["event"] == "checkout.completed", "event name should be protected, not overridable by user"
assert payload["event"] != "fake.event"
@@ -140,8 +134,7 @@ def test_duration_ms_is_protected() -> None:
payload2 = json.loads(logger2.emit(ctx2))
# Should use actual measured duration, not user-provided value
- assert payload2["duration_ms"] != 9999999, \
- "duration_ms should be calculated, not overridable by user"
+ assert payload2["duration_ms"] != 9999999, "duration_ms should be calculated, not overridable by user"
def test_outcome_is_protected() -> None:
@@ -161,19 +154,15 @@ def test_outcome_is_protected() -> None:
payload2 = json.loads(logger2.emit(ctx2))
# Outcome should be "error" (set by finish), not "fake_outcome"
- assert payload2["outcome"] == "error", \
- "outcome should be determined by finish() call, not user enrichment"
+ assert payload2["outcome"] == "error", "outcome should be determined by finish() call, not user enrichment"
def test_trace_context_is_protected() -> None:
"""Verify trace context fields are protected if set."""
logger = loza.New(loza.Test("test"))
- ctx = logger.start_event(loza.Params(
- event="test.event",
- trace_id="trace_123",
- span_id="span_456",
- request_id="req_789"
- ))
+ ctx = logger.start_event(
+ loza.Params(event="test.event", trace_id="trace_123", span_id="span_456", request_id="req_789")
+ )
logger.finish(ctx, "success")
payload = json.loads(logger.emit(ctx))
@@ -185,37 +174,29 @@ def test_trace_context_is_protected() -> None:
# Attempt to override trace context - enriched values should not override
logger2 = loza.New(loza.Test("test"))
- ctx2 = logger2.start_event(loza.Params(
- event="test.event",
- trace_id="trace_123",
- span_id="span_456",
- request_id="req_789"
- ))
+ ctx2 = logger2.start_event(
+ loza.Params(event="test.event", trace_id="trace_123", span_id="span_456", request_id="req_789")
+ )
logger2.enrich(
ctx2,
loza.String("trace_id", "fake_trace"),
loza.String("span_id", "fake_span"),
- loza.String("request_id", "fake_request")
+ loza.String("request_id", "fake_request"),
)
logger2.finish(ctx2, "success")
payload2 = json.loads(logger2.emit(ctx2))
# Original values should be preserved
- assert payload2.get("trace_id") == "trace_123", \
- "trace_id should be protected from user override"
- assert payload2.get("span_id") == "span_456", \
- "span_id should be protected from user override"
- assert payload2.get("request_id") == "req_789", \
- "request_id should be protected from user override"
+ assert payload2.get("trace_id") == "trace_123", "trace_id should be protected from user override"
+ assert payload2.get("span_id") == "span_456", "span_id should be protected from user override"
+ assert payload2.get("request_id") == "req_789", "request_id should be protected from user override"
def test_canonical_fields_with_all_policies() -> None:
"""Verify canonical protection works with all duplicate policies."""
for policy in [loza.CanonicalWins, loza.UserWins, loza.KeepBoth]:
- logger = loza.New(
- loza.Test("test").with_duplicate_policy(policy)
- )
+ logger = loza.New(loza.Test("test").with_duplicate_policy(policy))
ctx = logger.start_event(loza.Params(event="policy_test"))
original_id = ctx.event_id
@@ -225,5 +206,4 @@ def test_canonical_fields_with_all_policies() -> None:
logger.finish(ctx, "success")
payload = json.loads(logger.emit(ctx))
- assert payload["event_id"] == original_id, \
- f"event_id should be protected even with {policy.__name__} policy"
+ assert payload["event_id"] == original_id, f"event_id should be protected even with {policy.__name__} policy"
diff --git a/sdks/py/tests/test_collector_ack_fixture.py b/sdks/py/tests/test_collector_ack_fixture.py
index 97a848e..f1b424a 100644
--- a/sdks/py/tests/test_collector_ack_fixture.py
+++ b/sdks/py/tests/test_collector_ack_fixture.py
@@ -45,10 +45,14 @@ def test_collector_ack_behavior_fixtures() -> None:
try:
sink = HTTPBatchSink(url, service="checkout")
if fixture["expected"]["outcome"] == "success":
- sink.write('{"event_id":"evt_1","service":"checkout","event":"payment.completed","schema_version":"v1","event_version":"v1","timestamp":"2026-01-01T00:00:00Z"}')
+ sink.write(
+ '{"event_id":"evt_1","service":"checkout","event":"payment.completed","schema_version":"v1","event_version":"v1","timestamp":"2026-01-01T00:00:00Z"}'
+ )
else:
with pytest.raises(RuntimeError, match=fixture["expected"].get("message_contains", "")):
- sink.write('{"event_id":"evt_1","service":"checkout","event":"payment.completed","schema_version":"v1","event_version":"v1","timestamp":"2026-01-01T00:00:00Z"}')
+ sink.write(
+ '{"event_id":"evt_1","service":"checkout","event":"payment.completed","schema_version":"v1","event_version":"v1","timestamp":"2026-01-01T00:00:00Z"}'
+ )
finally:
server.shutdown()
server.server_close()
diff --git a/sdks/py/tests/test_collector_endpoint_config.py b/sdks/py/tests/test_collector_endpoint_config.py
index acec3cf..6f4ba94 100644
--- a/sdks/py/tests/test_collector_endpoint_config.py
+++ b/sdks/py/tests/test_collector_endpoint_config.py
@@ -13,3 +13,43 @@ def test_collector_base_url_targets_ingest_route(logger_type):
sink = next(sink for sink in logger._config.sinks if isinstance(sink, HTTPBatchSink))
assert sink.endpoint == "https://collector.example/events"
+
+
+@pytest.mark.parametrize(
+ ("dsn", "username", "password", "collector"),
+ [
+ (
+ "loza://private-user:private-secret@collector.example/private-collector",
+ "private-user",
+ "private-secret",
+ "private-collector",
+ ),
+ (
+ "loza://lx_pub_6DJvd3D0izOaQx3n5BhKqN:@collector.example/public-collector",
+ "lx_pub_6DJvd3D0izOaQx3n5BhKqN",
+ "",
+ "public-collector",
+ ),
+ ],
+)
+def test_credentialed_dsn_installs_scoped_credential_free_endpoint(dsn, username, password, collector):
+ logger = ClientLogger(Config.production("checkout").with_collector_endpoint(dsn))
+
+ sink = next(sink for sink in logger._config.sinks if isinstance(sink, HTTPBatchSink))
+
+ assert logger._config.collector_name == collector
+ assert sink.endpoint == f"https://collector.example:443/collectors/{collector}/events"
+ assert sink.username == username
+ assert sink.password == password
+ assert username not in sink.endpoint
+
+
+def test_api_key_precedes_public_basic_auth() -> None:
+ sink = HTTPBatchSink(
+ "https://collector.example/collectors/public-collector/events",
+ api_key="api-key",
+ username="lx_pub_6DJvd3D0izOaQx3n5BhKqN",
+ password="",
+ )
+
+ assert sink._auth_headers() == {"Authorization": "Bearer api-key"}
diff --git a/sdks/py/tests/test_config_loader.py b/sdks/py/tests/test_config_loader.py
index 1a25578..76d1a65 100644
--- a/sdks/py/tests/test_config_loader.py
+++ b/sdks/py/tests/test_config_loader.py
@@ -1,4 +1,3 @@
-
from loza.core.config import load_layered_config
diff --git a/sdks/py/tests/test_delivery_semantics.py b/sdks/py/tests/test_delivery_semantics.py
index 17b9840..9e51287 100644
--- a/sdks/py/tests/test_delivery_semantics.py
+++ b/sdks/py/tests/test_delivery_semantics.py
@@ -46,21 +46,17 @@ def test_multiple_events_have_unique_ids() -> None:
event_ids.append(event["event_id"])
# All event_ids should be unique
- assert len(event_ids) == len(set(event_ids)), \
- "Each event must have unique ID"
+ assert len(event_ids) == len(set(event_ids)), "Each event must have unique ID"
# Sink should have all events
- assert len(sink.events) == 5, \
- f"Expected 5 events in sink, got {len(sink.events)}"
+ assert len(sink.events) == 5, f"Expected 5 events in sink, got {len(sink.events)}"
def test_sampling_respected_in_sink() -> None:
"""Verify sampler filters events from sink."""
sink = loza.MemorySink()
logger = loza.New(
- loza.Test("test")
- .with_sink(sink)
- .with_sampler(loza.SampleNone()) # 0% sample rate
+ loza.Test("test").with_sink(sink).with_sampler(loza.SampleNone()) # 0% sample rate
)
ctx = logger.start_event(loza.Params(event="sampled_out"))
@@ -70,8 +66,7 @@ def test_sampling_respected_in_sink() -> None:
# Sampled-out event should return empty string
assert payload == "", "Sampled-out events should not be emitted"
# Sink should not receive sampled-out events
- assert len(sink.events) == 0, \
- "Sampled-out events should not reach sink"
+ assert len(sink.events) == 0, "Sampled-out events should not reach sink"
def test_error_events_recorded_in_sink() -> None:
@@ -87,8 +82,7 @@ def test_error_events_recorded_in_sink() -> None:
# Parse emitted payload
emitted = json.loads(payload)
- assert emitted.get("outcome") == "error", \
- "Error event should have outcome=error"
+ assert emitted.get("outcome") == "error", "Error event should have outcome=error"
# Verify sink has the error event
assert len(sink.events) == 1, "Error event should be in sink"
@@ -138,8 +132,7 @@ def test_events_ordered_in_sink() -> None:
# Sink events are stored as JSON strings, parse them
sink_event_ids = [json.loads(e)["event_id"] for e in sink.events]
- assert sink_event_ids == event_ids, \
- "Sink events should match emission order"
+ assert sink_event_ids == event_ids, "Sink events should match emission order"
def test_idempotent_emit_returns_same_payload() -> None:
@@ -155,8 +148,7 @@ def test_idempotent_emit_returns_same_payload() -> None:
payload3 = logger.emit(ctx)
# All should return the same payload
- assert payload1 == payload2 == payload3, \
- "Repeated emit() calls should return same payload"
+ assert payload1 == payload2 == payload3, "Repeated emit() calls should return same payload"
# Parse and verify
event1 = json.loads(payload1)
@@ -169,12 +161,9 @@ def test_delivery_preserves_event_integrity() -> None:
sink = loza.MemorySink()
logger = loza.New(loza.Test("checkout").with_sink(sink))
- ctx = logger.start_event(loza.Params(
- event="purchase.completed",
- request_id="req_123",
- trace_id="trace_456",
- user_id="user_789"
- ))
+ ctx = logger.start_event(
+ loza.Params(event="purchase.completed", request_id="req_123", trace_id="trace_456", user_id="user_789")
+ )
logger.enrich(ctx, loza.String("order_id", "ord_001"))
logger.enrich(ctx, loza.Int("amount_cents", 9999))
logger.finish(ctx, "success")
@@ -207,8 +196,7 @@ def test_large_event_still_delivered() -> None:
assert payload, "Large event should be emitted"
event = json.loads(payload)
- assert len(event.get("attrs", {})) >= 100, \
- "Large event should preserve all attributes"
+ assert len(event.get("attrs", {})) >= 100, "Large event should preserve all attributes"
def test_concurrent_loggers_dont_interfere() -> None:
diff --git a/sdks/py/tests/test_dsn.py b/sdks/py/tests/test_dsn.py
index f9edd59..b5d1a24 100644
--- a/sdks/py/tests/test_dsn.py
+++ b/sdks/py/tests/test_dsn.py
@@ -10,9 +10,7 @@
from loza.core.dsn import LozaDSN, parse
-_TEST_CASES_PATH = (
- Path(__file__).resolve().parents[3] / "spec" / "dsn" / "test-cases.json"
-)
+_TEST_CASES_PATH = Path(__file__).resolve().parents[3] / "spec" / "dsn" / "test-cases.json"
def _load_cases():
@@ -38,6 +36,9 @@ def test_valid_dsn(case):
assert dsn.host == expected["host"]
assert dsn.port == expected["port"]
assert dsn.project == expected["project"]
+ if "collectorName" in expected:
+ assert dsn.collector_name == expected["collectorName"]
+ assert dsn.project == dsn.collector_name
assert dsn.env == expected.get("env", "default")
assert dsn.service == expected.get("service", "")
assert dsn.tls == expected["tls"]
@@ -63,3 +64,24 @@ def test_valid_dsn(case):
def test_invalid_dsn(case):
with pytest.raises(ValueError):
parse(case["input"])
+
+
+def test_dsn_repr_redacts_credentials() -> None:
+ dsn = parse("loza://key-id:s%40cret%3Avalue@example.com/project")
+ rendered = repr(dsn)
+ assert "s@cret:value" not in rendered
+ assert "credentials=''" in rendered
+
+
+def test_public_dsn_routes_and_redacts_bearer_capability() -> None:
+ capability = "lx_pub_6DJvd3D0izOaQx3n5BhKqN"
+ dsn = parse(f"loza://{capability}:@example.com/public-collector")
+
+ assert dsn.username == capability
+ assert dsn.password == ""
+ assert dsn.collector_name == "public-collector"
+ assert dsn.events_url == "https://example.com:443/collectors/public-collector/events"
+ assert dsn.batch_url == "https://example.com:443/collectors/public-collector/events/batch"
+ assert dsn.otlp_url == "https://example.com:443/collectors/public-collector/otlp/logs"
+ assert dsn.tail_ws_url == "wss://example.com:443/collectors/public-collector/tail"
+ assert capability not in repr(dsn)
diff --git a/sdks/py/tests/test_e2e_live.py b/sdks/py/tests/test_e2e_live.py
index dcbe22d..72c4b63 100644
--- a/sdks/py/tests/test_e2e_live.py
+++ b/sdks/py/tests/test_e2e_live.py
@@ -4,6 +4,7 @@
``http://127.0.0.1:9308``), its ingest token in ``LOZA_API_KEY``, and an
admin token in ``LOZA_TEST_COLLECTOR_ADMIN_KEY``.
"""
+
import json
import os
import socket
@@ -49,20 +50,18 @@ def test_e2e_collector_pipeline():
print("\n=== E2E Test: HTTPBatchSink -> loza-collector ===\n")
# 1. Create logger with collector sink
- config = (
- loza.Production("e2e-test-service")
- .with_collector_endpoint(COLLECTOR_URL)
- .with_api_key(INGEST_API_KEY)
- )
+ config = loza.Production("e2e-test-service").with_collector_endpoint(COLLECTOR_URL).with_api_key(INGEST_API_KEY)
logger = loza.New(config)
# 2. Emit 3 events
for i in range(3):
- ctx = logger.start_event(loza.Params(
- event=f"e2e.test.event_{i}",
- message=f"E2E test event {i}",
- level="info",
- ))
+ ctx = logger.start_event(
+ loza.Params(
+ event=f"e2e.test.event_{i}",
+ message=f"E2E test event {i}",
+ level="info",
+ )
+ )
logger.enrich(ctx, loza.String("test_run", "e2e"))
logger.enrich(ctx, loza.Int("sequence", i))
logger.finish(ctx, "success")
@@ -99,7 +98,9 @@ def test_e2e_collector_pipeline():
try:
query_req = urllib.request.Request(
f"{COLLECTOR_URL}/query",
- data=json.dumps({"sql": "SELECT event, service, outcome FROM events WHERE service = 'e2e-test-service' LIMIT 10"}).encode(),
+ data=json.dumps(
+ {"sql": "SELECT event, service, outcome FROM events WHERE service = 'e2e-test-service' LIMIT 10"}
+ ).encode(),
headers={"Authorization": f"Bearer {ADMIN_API_KEY}", "content-type": "application/json"},
method="POST",
)
@@ -114,6 +115,7 @@ def test_e2e_collector_pipeline():
# 5. CollectorClient health/ready/version/status
from loza.core.http_client import CollectorClient
+
cc = CollectorClient(f"{COLLECTOR_URL}/events", api_key=ADMIN_API_KEY)
assert cc.health() is True, "collector health check failed"
diff --git a/sdks/py/tests/test_incident_id.py b/sdks/py/tests/test_incident_id.py
index 66c0de7..3086825 100644
--- a/sdks/py/tests/test_incident_id.py
+++ b/sdks/py/tests/test_incident_id.py
@@ -10,17 +10,20 @@
def test_incident_id_in_params() -> None:
"""Verify incident_id set via Params appears in emitted output."""
logger = loza.New(loza.Test("test-svc"))
- ctx = logger.start_event(loza.Params(
- event="test.incident",
- trace_id="trace-123",
- span_id="span-456",
- request_id="req-789",
- incident_id="incident-test-001",
- ))
+ ctx = logger.start_event(
+ loza.Params(
+ event="test.incident",
+ trace_id="trace-123",
+ span_id="span-456",
+ request_id="req-789",
+ incident_id="incident-test-001",
+ )
+ )
logger.finish(ctx, "success")
payload = json.loads(logger.emit(ctx))
- assert payload.get("incident_id") == "incident-test-001", \
+ assert payload.get("incident_id") == "incident-test-001", (
f"expected incident_id=incident-test-001, got {payload.get('incident_id')}"
+ )
def test_incident_id_in_params_enrich() -> None:
@@ -33,8 +36,9 @@ def test_incident_id_in_params_enrich() -> None:
ctx = logger.start_event(params)
logger.finish(ctx, "success")
payload = json.loads(logger.emit(ctx))
- assert payload.get("incident_id") == "inc-enrich-test", \
+ assert payload.get("incident_id") == "inc-enrich-test", (
f"expected incident_id=inc-enrich-test, got {payload.get('incident_id')}"
+ )
def test_incident_id_omitted_when_empty() -> None:
@@ -43,21 +47,21 @@ def test_incident_id_omitted_when_empty() -> None:
ctx = logger.start_event(loza.Params(event="test.no-incident"))
logger.finish(ctx, "success")
payload = json.loads(logger.emit(ctx))
- assert "incident_id" not in payload, \
- "incident_id should not be present in output when not set"
+ assert "incident_id" not in payload, "incident_id should not be present in output when not set"
def test_incident_id_preserved_through_all_policies() -> None:
"""Verify incident_id survives all duplicate field policies."""
for policy in [loza.CanonicalWins, loza.UserWins, loza.KeepBoth]:
- logger = loza.New(
- loza.Test("test-svc").with_duplicate_policy(policy)
+ logger = loza.New(loza.Test("test-svc").with_duplicate_policy(policy))
+ ctx = logger.start_event(
+ loza.Params(
+ event="test.incident-policy",
+ incident_id="inc-policy-test",
+ )
)
- ctx = logger.start_event(loza.Params(
- event="test.incident-policy",
- incident_id="inc-policy-test",
- ))
logger.finish(ctx, "success")
payload = json.loads(logger.emit(ctx))
- assert payload.get("incident_id") == "inc-policy-test", \
+ assert payload.get("incident_id") == "inc-policy-test", (
f"expected incident_id=inc-policy-test with {policy.__name__}, got {payload.get('incident_id')}"
+ )
diff --git a/sdks/py/tests/test_integration.py b/sdks/py/tests/test_integration.py
index 75e50de..e8a7c3d 100644
--- a/sdks/py/tests/test_integration.py
+++ b/sdks/py/tests/test_integration.py
@@ -1,4 +1,5 @@
"""Integration tests for Python SDK with collector."""
+
import json
import time
import loza
@@ -23,11 +24,7 @@ def test_event_integrity_through_pipeline() -> None:
logger = loza.New(loza.Test("test_service"))
# Create event with specific values
- ctx = logger.start_event(loza.Params(
- event="integrity_test",
- message="Test message",
- level="info"
- ))
+ ctx = logger.start_event(loza.Params(event="integrity_test", message="Test message", level="info"))
logger.enrich(ctx, loza.String("user_id", "user123"))
logger.finish(ctx, "success")
payload = logger.emit(ctx)
@@ -64,10 +61,7 @@ def test_error_event_collection() -> None:
"""Verify error events are properly collected."""
logger = loza.New(loza.Test("error_test"))
- ctx = logger.start_event(loza.Params(
- event="error_event",
- message="Something went wrong"
- ))
+ ctx = logger.start_event(loza.Params(event="error_event", message="Something went wrong"))
logger.enrich(ctx, loza.String("error_code", "E001"))
logger.finish(ctx, "error")
payload = logger.emit(ctx)
@@ -127,9 +121,7 @@ def test_canonical_fields_immutable() -> None:
def test_sampling_in_pipeline() -> None:
"""Verify sampling decision is consistent through pipeline."""
# Create logger with deterministic sampling
- logger = loza.New(
- loza.Test("sample_test").with_sampler(loza.SampleAll())
- )
+ logger = loza.New(loza.Test("sample_test").with_sampler(loza.SampleAll()))
for i in range(3):
ctx = logger.start_event(loza.Params(event=f"sampled_{i}"))
diff --git a/sdks/py/tests/test_metrics_export.py b/sdks/py/tests/test_metrics_export.py
index d0555b6..7629cf0 100644
--- a/sdks/py/tests/test_metrics_export.py
+++ b/sdks/py/tests/test_metrics_export.py
@@ -1,4 +1,5 @@
"""Test metrics export from Python SDK (Prometheus format)."""
+
import json
import loza
@@ -29,13 +30,17 @@ def test_events_emitted_total() -> None:
def test_delivery_attempts_recorded() -> None:
"""Verify delivery attempts are recorded."""
+
class CountingSink:
def __init__(self):
self.write_count = 0
+
def write(self, payload: str) -> None:
self.write_count += 1
+
def flush(self) -> None:
pass
+
def close(self) -> None:
pass
@@ -53,9 +58,7 @@ def close(self) -> None:
def test_sampling_rate_gauge() -> None:
"""Verify sampling rate is tracked."""
# Create logger with 50% sampling
- logger = loza.New(
- loza.Test("test").with_sampler(loza.SampleRandom(0.5))
- )
+ logger = loza.New(loza.Test("test").with_sampler(loza.SampleRandom(0.5)))
# Emit multiple events and count how many are sampled
sampled_count = 0
@@ -78,12 +81,15 @@ def test_sink_latency_recorded() -> None:
class SlowSink:
def __init__(self):
self.latencies = []
+
def write(self, payload: str) -> None:
start = time.time()
time.sleep(0.001) # 1ms simulated latency
self.latencies.append(time.time() - start)
+
def flush(self) -> None:
pass
+
def close(self) -> None:
pass
@@ -194,9 +200,7 @@ def emit_event(i):
def test_dropped_event_count() -> None:
"""Verify dropped events are counted."""
# Create logger with none sampler (0% sampling)
- logger = loza.New(
- loza.Test("test").with_sampler(loza.SampleNone())
- )
+ logger = loza.New(loza.Test("test").with_sampler(loza.SampleNone()))
dropped_count = 0
for i in range(10):
diff --git a/sdks/py/tests/test_panic_safety.py b/sdks/py/tests/test_panic_safety.py
index 694a573..9ef81b7 100644
--- a/sdks/py/tests/test_panic_safety.py
+++ b/sdks/py/tests/test_panic_safety.py
@@ -54,11 +54,14 @@ def test_invalid_event_name_handled() -> None:
def test_sink_write_failure_doesnt_crash() -> None:
"""Verify sink failures don't crash the SDK."""
+
class FailingSink:
def write(self, payload: str) -> None:
raise IOError("Sink is broken")
+
def flush(self) -> None:
pass
+
def close(self) -> None:
pass
@@ -72,17 +75,14 @@ def close(self) -> None:
# Event may still emit to caller even if sink fails
except Exception as e:
# Should be a known error, not a panic
- assert isinstance(e, (IOError, OSError, RuntimeError)), \
- f"Unexpected error type: {type(e).__name__}"
+ assert isinstance(e, (IOError, OSError, RuntimeError)), f"Unexpected error type: {type(e).__name__}"
def test_invalid_duplicate_policy_rejected() -> None:
"""Verify invalid duplicate policies are rejected."""
try:
# Try to create logger with None policy
- logger = loza.New(
- loza.Test("test").with_duplicate_policy(None)
- )
+ logger = loza.New(loza.Test("test").with_duplicate_policy(None))
# If it doesn't raise, continue
ctx = logger.start_event(loza.Params(event="test"))
logger.finish(ctx, "success")
@@ -95,9 +95,7 @@ def test_invalid_sampler_rejected() -> None:
"""Verify invalid samplers are rejected."""
try:
# Try to create logger with None sampler
- logger = loza.New(
- loza.Test("test").with_sampler(None)
- )
+ logger = loza.New(loza.Test("test").with_sampler(None))
ctx = logger.start_event(loza.Params(event="test"))
logger.finish(ctx, "success")
except (TypeError, ValueError, AttributeError):
@@ -108,9 +106,7 @@ def test_invalid_sampler_rejected() -> None:
def test_invalid_schema_rejected() -> None:
"""Verify invalid schemas are rejected."""
try:
- logger = loza.New(
- loza.Test("test").with_schema(None)
- )
+ logger = loza.New(loza.Test("test").with_schema(None))
ctx = logger.start_event(loza.Params(event="test"))
logger.finish(ctx, "success")
except (TypeError, ValueError, AttributeError):
@@ -121,9 +117,7 @@ def test_invalid_schema_rejected() -> None:
def test_invalid_redactor_rejected() -> None:
"""Verify invalid redactors are rejected."""
try:
- logger = loza.New(
- loza.Test("test").with_redactor(None)
- )
+ logger = loza.New(loza.Test("test").with_redactor(None))
ctx = logger.start_event(loza.Params(event="test"))
logger.finish(ctx, "success")
except (TypeError, ValueError, AttributeError):
@@ -143,8 +137,9 @@ def test_finish_twice_handled() -> None:
logger.finish(ctx, "error")
except Exception as e:
# Should be a known error type (EventAlreadyFinishedError is OK)
- assert "already finished" in str(e) or isinstance(e, (ValueError, RuntimeError)), \
+ assert "already finished" in str(e) or isinstance(e, (ValueError, RuntimeError)), (
f"Unexpected error: {type(e).__name__}"
+ )
def test_emit_before_finish_handled() -> None:
@@ -188,7 +183,7 @@ def test_special_characters_in_attributes() -> None:
special_values = [
"\x00null byte",
"\uffff unicode",
- "\\\"quotes\\\"",
+ '\\"quotes\\"',
"\\nnewlines\\n",
"{}[]()\"quotes'",
]
diff --git a/sdks/py/tests/test_spec_conformance.py b/sdks/py/tests/test_spec_conformance.py
index 6d2123a..4573f1e 100644
--- a/sdks/py/tests/test_spec_conformance.py
+++ b/sdks/py/tests/test_spec_conformance.py
@@ -60,7 +60,18 @@ def test_invalid_golden_fixture_examples_stay_invalid() -> None:
assert "event_id" not in missing_event_id
invalid_enums = _load_payload(invalid_files["invalid_enum_values.json"])
- assert invalid_enums["kind"] not in {"event", "http", "job", "queue", "cli", "cron", "log", "checkpoint", "agent", "ai"}
+ assert invalid_enums["kind"] not in {
+ "event",
+ "http",
+ "job",
+ "queue",
+ "cli",
+ "cron",
+ "log",
+ "checkpoint",
+ "agent",
+ "ai",
+ }
missing_versions = _load_payload(invalid_files["missing_versions.json"])
assert "schema_version" not in missing_versions or "event_version" not in missing_versions
diff --git a/sdks/py/tests/test_timing.py b/sdks/py/tests/test_timing.py
index efb5c5c..3d8f4fc 100644
--- a/sdks/py/tests/test_timing.py
+++ b/sdks/py/tests/test_timing.py
@@ -1,4 +1,5 @@
"""Tests for timing primitives: Process, Timer, Group, Stopwatch."""
+
import time
import loza
diff --git a/sdks/rs/Cargo.lock b/sdks/rs/Cargo.lock
index 0161963..783cc22 100644
--- a/sdks/rs/Cargo.lock
+++ b/sdks/rs/Cargo.lock
@@ -1042,10 +1042,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loza"
-version = "0.2.6"
+version = "0.3.0"
dependencies = [
"actix-web",
"axum",
+ "base64",
"fastrand",
"flate2",
"futures-util",
diff --git a/sdks/rs/Cargo.toml b/sdks/rs/Cargo.toml
index c6e5ede..3dc6f07 100644
--- a/sdks/rs/Cargo.toml
+++ b/sdks/rs/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "loza"
-version = "0.2.6"
+version = "0.3.0"
edition = "2021"
description = "Rust LOZA wide-event SDK"
license = "MIT"
@@ -17,6 +17,7 @@ flate2 = "1"
ureq = { version = "2", features = ["gzip", "json"] }
regex = "1"
sha2 = "0.10"
+base64 = "0.22"
hostname = "0.4"
futures-util = { version = "0.3", optional = true }
actix-web = { version = "4", optional = true }
diff --git a/sdks/rs/README.md b/sdks/rs/README.md
index a4db58f..9eebbaa 100644
--- a/sdks/rs/README.md
+++ b/sdks/rs/README.md
@@ -1,6 +1,6 @@
# LOZA-RS
-**Status**: STABLE (v0.2.6) - Production-ready, full feature conformance
+**Status**: STABLE (v0.3.0) - Production-ready, full feature conformance
`loza-rs` is a collector-first Rust SDK for wide events. It builds one structured event per operation (request, job, queue message, CLI run, cron run), then emits to your log/analytics backend.
diff --git a/sdks/rs/docs/sdk-parity-manifest.json b/sdks/rs/docs/sdk-parity-manifest.json
index b0da7b2..3cfee69 100644
--- a/sdks/rs/docs/sdk-parity-manifest.json
+++ b/sdks/rs/docs/sdk-parity-manifest.json
@@ -1,8 +1,8 @@
{
- "version": "0.2.6",
+ "version": "0.3.0",
"package": "loza",
"scope": "lightweight-sdk",
- "stability": "product-parity-v0.2.6",
+ "stability": "product-parity-v0.3.0",
"policy": "full-product-method-family",
"sdks": ["go", "python", "rust", "javascript"],
"lifecycle": [
diff --git a/sdks/rs/loza-rs.yaml b/sdks/rs/loza-rs.yaml
index 3774ea8..20476e5 100644
--- a/sdks/rs/loza-rs.yaml
+++ b/sdks/rs/loza-rs.yaml
@@ -1,7 +1,7 @@
name: loza-rs
kind: sdk
language: rust
-version: 0.2.6
+version: 0.3.0
description: Rust SDK for LOZA
license: MIT
repository: https://github.com/Astraive/loza
diff --git a/sdks/rs/src/config/config.rs b/sdks/rs/src/config/config.rs
index 400b5ed..c9c6afb 100644
--- a/sdks/rs/src/config/config.rs
+++ b/sdks/rs/src/config/config.rs
@@ -40,7 +40,11 @@ pub struct Config {
pub strict: bool,
pub async_enabled: bool,
pub collector_endpoint: String,
+ pub collector_name: String,
pub api_key: String,
+ pub basic_username: Option,
+ pub basic_password: Option,
+ pub insecure: bool,
pub duplicate_policy: String,
pub max_event_bytes: usize,
pub sampler: SamplerConfig,
@@ -63,11 +67,19 @@ impl std::fmt::Debug for Config {
.field("alias", &self.alias)
.field("version", &self.version)
.field("environment", &self.environment)
- .field("region", &self.region)
.field("level", &self.level)
.field("strict", &self.strict)
- .field("async_enabled", &self.async_enabled)
.field("collector_endpoint", &self.collector_endpoint)
+ .field("collector_name", &self.collector_name)
+ .field(
+ "api_key",
+ &(!self.api_key.is_empty()).then_some(""),
+ )
+ .field(
+ "basic_credentials",
+ &self.basic_username.as_ref().map(|_| ""),
+ )
+ .field("insecure", &self.insecure)
.field("duplicate_policy", &self.duplicate_policy)
.field("max_event_bytes", &self.max_event_bytes)
.field("sinks", &self.sinks)
@@ -83,7 +95,6 @@ impl std::fmt::Debug for Config {
.finish()
}
}
-
#[derive(Clone, Debug, Default, Deserialize)]
pub(crate) struct FileConfig {
pub(crate) service: Option,
@@ -94,7 +105,11 @@ pub(crate) struct FileConfig {
pub(crate) strict: Option,
pub(crate) async_enabled: Option,
pub(crate) collector_endpoint: Option,
+ pub(crate) collector_name: Option,
pub(crate) api_key: Option,
+ pub(crate) basic_username: Option,
+ pub(crate) basic_password: Option,
+ pub(crate) insecure: Option,
pub(crate) duplicate_policy: Option,
pub(crate) max_event_bytes: Option,
}
@@ -122,7 +137,7 @@ impl MemorySinkStore {
}
}
-#[derive(Clone, Debug)]
+#[derive(Clone)]
pub enum SinkConfig {
Stdout,
Stderr,
@@ -132,6 +147,9 @@ pub enum SinkConfig {
HttpBatch {
endpoint: String,
api_key: Option,
+ basic_username: Option,
+ basic_password: Option,
+ insecure: bool,
timeout_ms: u64,
max_batch_bytes: usize,
max_retries: u32,
@@ -140,6 +158,44 @@ pub enum SinkConfig {
},
}
+impl std::fmt::Debug for SinkConfig {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Stdout => f.write_str("Stdout"),
+ Self::Stderr => f.write_str("Stderr"),
+ Self::File(path) => f.debug_tuple("File").field(path).finish(),
+ Self::Memory(store) => f.debug_tuple("Memory").field(store).finish(),
+ Self::Noop => f.write_str("Noop"),
+ Self::HttpBatch {
+ endpoint,
+ api_key,
+ basic_username,
+ basic_password: _basic_password,
+ insecure,
+ timeout_ms,
+ max_batch_bytes,
+ max_retries,
+ enable_compression,
+ ndjson,
+ } => f
+ .debug_struct("HttpBatch")
+ .field("endpoint", endpoint)
+ .field("api_key", &api_key.as_ref().map(|_| ""))
+ .field(
+ "basic_credentials",
+ &basic_username.as_ref().map(|_| ""),
+ )
+ .field("insecure", insecure)
+ .field("timeout_ms", timeout_ms)
+ .field("max_batch_bytes", max_batch_bytes)
+ .field("max_retries", max_retries)
+ .field("enable_compression", enable_compression)
+ .field("ndjson", ndjson)
+ .finish(),
+ }
+ }
+}
+
#[derive(Clone)]
pub enum SamplerConfig {
All,
@@ -273,8 +329,19 @@ impl Config {
self
}
- pub fn with_collector_endpoint(mut self, endpoint: impl Into) -> Self {
- self.collector_endpoint = endpoint.into();
+ pub fn with_collector_endpoint(self, endpoint: impl Into) -> Self {
+ let endpoint = endpoint.into();
+ if endpoint.starts_with("loza://") {
+ self.with_dsn(endpoint)
+ } else {
+ let mut config = self;
+ config.collector_endpoint = endpoint;
+ config
+ }
+ }
+
+ pub fn with_collector_name(mut self, collector_name: impl Into) -> Self {
+ self.collector_name = collector_name.into();
self
}
@@ -282,6 +349,37 @@ impl Config {
self.api_key = api_key.into();
self
}
+ pub fn with_basic_auth(
+ mut self,
+ username: impl Into,
+ password: impl Into,
+ ) -> Self {
+ self.basic_username = Some(username.into());
+ self.basic_password = Some(password.into());
+ self
+ }
+
+ pub fn with_dsn(mut self, raw: impl AsRef) -> Self {
+ match super::dsn::parse(raw.as_ref()) {
+ Ok(dsn) => {
+ self.collector_endpoint = dsn.base_url;
+ self.collector_name = dsn.collector_name;
+ self.environment = dsn.env;
+ if !dsn.service.is_empty() {
+ self.service = dsn.service;
+ }
+ if dsn.username.is_some() && dsn.password.is_some() {
+ self.basic_username = dsn.username;
+ self.basic_password = dsn.password;
+ }
+ self.insecure = !dsn.tls;
+ }
+ Err(_) => {
+ self.collector_endpoint = raw.as_ref().to_string();
+ }
+ }
+ self
+ }
pub fn with_duplicate_policy(mut self, policy: impl Into) -> Self {
self.duplicate_policy = policy.into();
@@ -419,6 +517,37 @@ impl Config {
),
));
}
+ if self.basic_username.is_some() != self.basic_password.is_some() {
+ return Err(crate::errors::LozaError::Validation(
+ crate::errors::ValidationError::new(
+ None,
+ "invalid_basic_auth",
+ "basic username and password must be configured together".to_string(),
+ ),
+ ));
+ }
+ if self.basic_username.is_some()
+ && self.collector_endpoint.starts_with("http://")
+ && (!self.insecure || !is_local_endpoint(&self.collector_endpoint))
+ {
+ return Err(crate::errors::LozaError::Validation(
+ crate::errors::ValidationError::new(
+ Some("collector_endpoint"),
+ "insecure_basic_auth",
+ "credentialed HTTP requires an explicit local/insecure configuration"
+ .to_string(),
+ ),
+ ));
+ }
+ if self.collector_endpoint.starts_with("loza://") {
+ return Err(crate::errors::LozaError::Validation(
+ crate::errors::ValidationError::new(
+ Some("collector_endpoint"),
+ "invalid_endpoint",
+ "loza:// DSNs must be resolved before creating a client".to_string(),
+ ),
+ ));
+ }
Ok(())
}
}
@@ -447,11 +576,27 @@ impl FileConfig {
cfg.async_enabled = value;
}
if let Some(value) = self.collector_endpoint {
- cfg.collector_endpoint = value;
+ if value.starts_with("loza://") {
+ cfg = cfg.with_dsn(value);
+ } else {
+ cfg.collector_endpoint = value;
+ }
+ }
+ if let Some(value) = self.collector_name {
+ cfg.collector_name = value;
}
if let Some(value) = self.api_key {
cfg.api_key = value;
}
+ if let Some(value) = self.basic_username {
+ cfg.basic_username = Some(value);
+ }
+ if let Some(value) = self.basic_password {
+ cfg.basic_password = Some(value);
+ }
+ if let Some(value) = self.insecure {
+ cfg.insecure = value;
+ }
if let Some(value) = self.duplicate_policy {
cfg.duplicate_policy = value;
}
@@ -495,7 +640,7 @@ pub fn new_client(code_config: Config) -> Result Result Result Config {
- // Compare against base defaults to determine what was explicitly set
let defaults = Config::base();
-
if code.service != defaults.service {
base.service = code.service;
}
@@ -553,16 +699,25 @@ fn merge_code_config(mut base: Config, code: Config) -> Config {
if code.collector_endpoint != defaults.collector_endpoint {
base.collector_endpoint = code.collector_endpoint;
}
+ if code.collector_name != defaults.collector_name {
+ base.collector_name = code.collector_name;
+ }
if code.api_key != defaults.api_key {
base.api_key = code.api_key;
}
+ if code.basic_username.is_some() {
+ base.basic_username = code.basic_username;
+ base.basic_password = code.basic_password;
+ }
+ if code.insecure != defaults.insecure {
+ base.insecure = code.insecure;
+ }
if code.duplicate_policy != defaults.duplicate_policy {
base.duplicate_policy = code.duplicate_policy;
}
if code.max_event_bytes != defaults.max_event_bytes {
base.max_event_bytes = code.max_event_bytes;
}
- // For sinks, if code config has non-default sinks, use them
if !(code.sinks.is_empty()
|| code.sinks.len() == 1 && matches!(&code.sinks[0], SinkConfig::Stdout))
{
@@ -608,9 +763,21 @@ fn overlay_file_config(mut base: FileConfig, override_cfg: FileConfig) -> FileCo
if override_cfg.collector_endpoint.is_some() {
base.collector_endpoint = override_cfg.collector_endpoint;
}
+ if override_cfg.collector_name.is_some() {
+ base.collector_name = override_cfg.collector_name;
+ }
if override_cfg.api_key.is_some() {
base.api_key = override_cfg.api_key;
}
+ if override_cfg.basic_username.is_some() {
+ base.basic_username = override_cfg.basic_username;
+ }
+ if override_cfg.basic_password.is_some() {
+ base.basic_password = override_cfg.basic_password;
+ }
+ if override_cfg.insecure.is_some() {
+ base.insecure = override_cfg.insecure;
+ }
if override_cfg.duplicate_policy.is_some() {
base.duplicate_policy = override_cfg.duplicate_policy;
}
@@ -620,6 +787,40 @@ fn overlay_file_config(mut base: FileConfig, override_cfg: FileConfig) -> FileCo
base
}
+fn is_local_endpoint(endpoint: &str) -> bool {
+ let authority = endpoint
+ .strip_prefix("http://")
+ .or_else(|| endpoint.strip_prefix("https://"))
+ .unwrap_or(endpoint)
+ .split('/')
+ .next()
+ .unwrap_or_default()
+ .rsplit_once('@')
+ .map(|(_, host)| host)
+ .unwrap_or_else(|| {
+ endpoint
+ .strip_prefix("http://")
+ .or_else(|| endpoint.strip_prefix("https://"))
+ .unwrap_or(endpoint)
+ .split('/')
+ .next()
+ .unwrap_or_default()
+ });
+ let host = if let Some(ipv6) = authority.strip_prefix('[') {
+ ipv6.split(']').next().unwrap_or_default()
+ } else {
+ authority.split(':').next().unwrap_or_default()
+ };
+ matches!(host, "localhost" | "127.0.0.1" | "::1")
+}
+
+pub(crate) fn collector_events_endpoint(endpoint: &str, collector_name: &str) -> String {
+ let base = endpoint.trim().trim_end_matches('/');
+ if collector_name.is_empty() {
+ return base.to_string();
+ }
+ format!("{base}/collectors/{collector_name}/events")
+}
fn load_file_config(path: impl AsRef) -> Result {
let raw = fs::read_to_string(path)?;
let parsed = serde_yaml::from_str::(&raw).unwrap_or_default();
diff --git a/sdks/rs/src/config/defaults.rs b/sdks/rs/src/config/defaults.rs
index fab0e2b..b0f8945 100644
--- a/sdks/rs/src/config/defaults.rs
+++ b/sdks/rs/src/config/defaults.rs
@@ -57,7 +57,11 @@ impl Config {
strict: false,
async_enabled: false,
collector_endpoint: String::new(),
+ collector_name: String::new(),
api_key: String::new(),
+ basic_username: None,
+ basic_password: None,
+ insecure: false,
duplicate_policy: "canonical_wins".to_string(),
max_event_bytes: 256 * 1024,
sampler: SamplerConfig::All,
diff --git a/sdks/rs/src/config/dsn.rs b/sdks/rs/src/config/dsn.rs
index b28479e..eeebbd5 100644
--- a/sdks/rs/src/config/dsn.rs
+++ b/sdks/rs/src/config/dsn.rs
@@ -1,11 +1,12 @@
use std::fmt;
/// Parsed and resolved loza:// DSN.
-#[derive(Debug, Clone, PartialEq)]
+#[derive(Clone, PartialEq)]
pub struct LozaDSN {
pub scheme: String,
pub host: String,
pub port: u16,
+ pub collector_name: String,
pub project: String,
pub env: String,
pub service: String,
@@ -16,6 +17,36 @@ pub struct LozaDSN {
pub batch_url: String,
pub otlp_url: String,
pub tail_ws_url: String,
+ pub username: Option,
+ pub password: Option,
+}
+
+impl fmt::Debug for LozaDSN {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("LozaDSN")
+ .field("scheme", &self.scheme)
+ .field("host", &self.host)
+ .field("port", &self.port)
+ .field("collector_name", &self.collector_name)
+ .field("project", &self.project)
+ .field("env", &self.env)
+ .field("service", &self.service)
+ .field("tls", &self.tls)
+ .field("transport", &self.transport)
+ .field("base_url", &self.base_url)
+ .field("events_url", &self.events_url)
+ .field("batch_url", &self.batch_url)
+ .field("otlp_url", &self.otlp_url)
+ .field("tail_ws_url", &self.tail_ws_url)
+ .field("credentials", &self.username.as_ref().map(|_| ""))
+ .finish()
+ }
+}
+
+impl fmt::Display for LozaDSN {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.base_url)
+ }
}
#[derive(Debug, Clone)]
@@ -34,8 +65,9 @@ impl std::error::Error for DsnError {}
/// Validation rules:
/// - Scheme must be `loza://`
/// - Host is required
-/// - Project path is required
-/// - No userinfo allowed
+/// - Collector path is required
+/// - Private userinfo must contain a non-empty username and password
+/// - Public lx_pub_... userinfo uses an explicitly empty password
/// - tls must be "true", "false", or "auto"
/// - transport must be "http", "otlp", or "grpc"
/// - Port must be 1-65535 if specified
@@ -65,17 +97,55 @@ pub fn parse(raw: &str) -> Result {
None => (rest, ""),
};
- // Reject userinfo (API keys must not be in the URL).
- if authority.contains('@') {
- return Err(DsnError(
- "do not put API keys in the URL, use LOZA_API_KEY instead".into(),
- ));
- }
+ // Split optional userinfo from the authority. The final `@` is the
+ // delimiter; an additional raw `@` belongs to malformed userinfo.
+ let (userinfo, authority) = match authority.rsplit_once('@') {
+ Some((userinfo, authority)) => {
+ if userinfo.contains('@') {
+ return Err(DsnError("userinfo contains an unescaped @".into()));
+ }
+ (Some(userinfo), authority)
+ }
+ None => (None, authority),
+ };
+
+ let (username, password) = match userinfo {
+ None => (None, None),
+ Some(value) => {
+ let (raw_username, raw_password) = value
+ .split_once(':')
+ .ok_or_else(|| DsnError("userinfo must be username:password".into()))?;
+ if raw_username.is_empty() {
+ return Err(DsnError(
+ "userinfo requires username:password or lx_pub_...:".into(),
+ ));
+ }
+ let username = percent_decode_userinfo(raw_username, "username")?;
+ let password = percent_decode_userinfo(raw_password, "password")?;
+ if username.is_empty()
+ || (password.is_empty() && !is_public_credential_username(&username))
+ {
+ return Err(DsnError(
+ "userinfo requires username:password or lx_pub_...:".into(),
+ ));
+ }
+ if username.contains(':') || username.chars().any(char::is_whitespace) {
+ return Err(DsnError(
+ "userinfo username must not contain ':' or whitespace".into(),
+ ));
+ }
+ (Some(username), Some(password))
+ }
+ };
if authority.is_empty() {
return Err(DsnError("host is required".into()));
}
+ if authority.contains('@') {
+ return Err(DsnError("authority contains an unescaped @".into()));
+ }
+
// Parse host and port from authority.
let (host, port_str) = if authority.starts_with('[') {
// IPv6: [::1] or [::1]:port
@@ -120,10 +190,10 @@ pub fn parse(raw: &str) -> Result {
None => (path_query, ""),
};
- let project = path_part.trim_start_matches('/');
- if project.is_empty() {
+ let collector_name = path_part.trim_start_matches('/');
+ if collector_name.is_empty() {
return Err(DsnError(
- "project path is required, e.g. loza://host/my-project".into(),
+ "collector path is required, e.g. loza://host/my-collector".into(),
));
}
@@ -207,23 +277,83 @@ pub fn parse(raw: &str) -> Result {
let base_url = format!("{scheme}://{host_part}:{port}");
+ let collector_base_url = format!("{base_url}/collectors/{collector_name}");
+ let collector_tail_base_url =
+ format!("{ws_scheme}://{host_part}:{port}/collectors/{collector_name}");
+
Ok(LozaDSN {
scheme: "loza".to_string(),
host: host.to_string(),
port,
- project: project.to_string(),
+ collector_name: collector_name.to_string(),
+ project: collector_name.to_string(),
env,
service,
tls,
transport,
base_url: base_url.clone(),
- events_url: format!("{base_url}/events"),
- batch_url: format!("{base_url}/events/batch"),
- otlp_url: format!("{base_url}/otlp/logs"),
- tail_ws_url: format!("{ws_scheme}://{host_part}:{port}/tail"),
+ events_url: format!("{collector_base_url}/events"),
+ batch_url: format!("{collector_base_url}/events/batch"),
+ otlp_url: format!("{collector_base_url}/otlp/logs"),
+ tail_ws_url: format!("{collector_tail_base_url}/tail"),
+ username,
+ password,
})
}
+fn percent_decode_userinfo(value: &str, field: &str) -> Result {
+ let bytes = value.as_bytes();
+ let mut decoded = Vec::with_capacity(bytes.len());
+ let mut index = 0;
+ while index < bytes.len() {
+ match bytes[index] {
+ b'%' => {
+ if index + 2 >= bytes.len() {
+ return Err(DsnError(format!(
+ "userinfo {field} has an incomplete percent escape"
+ )));
+ }
+ let high = hex_value(bytes[index + 1]).ok_or_else(|| {
+ DsnError(format!("userinfo {field} has an invalid percent escape"))
+ })?;
+ let low = hex_value(bytes[index + 2]).ok_or_else(|| {
+ DsnError(format!("userinfo {field} has an invalid percent escape"))
+ })?;
+ decoded.push((high << 4) | low);
+ index += 3;
+ }
+ byte if is_unreserved(byte) => {
+ decoded.push(byte);
+ index += 1;
+ }
+ _ => {
+ return Err(DsnError(format!(
+ "userinfo {field} contains a character that must be percent-encoded"
+ )))
+ }
+ }
+ }
+ String::from_utf8(decoded).map_err(|_| DsnError(format!("userinfo {field} is not valid UTF-8")))
+}
+
+fn is_unreserved(byte: u8) -> bool {
+ byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
+}
+
+fn hex_value(byte: u8) -> Option {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ b'A'..=b'F' => Some(byte - b'A' + 10),
+ _ => None,
+ }
+}
+
+pub fn is_public_credential_username(username: &str) -> bool {
+ const PREFIX: &str = "lx_pub_";
+ username.starts_with(PREFIX) && username.len() > PREFIX.len()
+}
+
fn is_localhost(host: &str) -> bool {
host == "localhost" || host == "127.0.0.1" || host == "::1"
}
@@ -242,6 +372,24 @@ mod tests {
assert_eq!(dsn.base_url, "http://localhost:9308");
}
+ #[test]
+ fn parses_and_redacts_credentials() {
+ let dsn = parse("loza://key%2Did:s%40cret%3Avalue@host/project").unwrap();
+ assert_eq!(dsn.username.as_deref(), Some("key-id"));
+ assert_eq!(dsn.password.as_deref(), Some("s@cret:value"));
+ assert!(!dsn.base_url.contains("s@cret"));
+ assert!(!format!("{dsn:?}").contains("s@cret"));
+ }
+
+ #[test]
+ fn rejects_invalid_credentials() {
+ assert!(parse("loza://key@host/project").is_err());
+ assert!(parse("loza://key:@host/project").is_err());
+ assert!(parse("loza://:secret@host/project").is_err());
+ assert!(parse("loza://key:secret%ZZ@host/project").is_err());
+ assert!(parse("loza://key%3Aname:secret@host/project").is_err());
+ }
+
#[test]
fn reject_empty() {
assert!(parse("").is_err());
@@ -261,9 +409,4 @@ mod tests {
fn reject_no_project() {
assert!(parse("loza://host").is_err());
}
-
- #[test]
- fn reject_userinfo() {
- assert!(parse("loza://key@host/project").is_err());
- }
}
diff --git a/sdks/rs/src/config/env.rs b/sdks/rs/src/config/env.rs
index 6ac5439..17716e7 100644
--- a/sdks/rs/src/config/env.rs
+++ b/sdks/rs/src/config/env.rs
@@ -1,17 +1,32 @@
use super::config::FileConfig;
pub(crate) fn load_env_config() -> FileConfig {
+ let dsn = env_string("LOZA_DSN").and_then(|raw| super::dsn::parse(&raw).ok());
+ let dsn_service = dsn
+ .as_ref()
+ .map(|value| value.service.clone())
+ .filter(|value| !value.is_empty());
+ let dsn_environment = dsn.as_ref().map(|value| value.env.clone());
+ let dsn_collector_name = dsn.as_ref().map(|value| value.collector_name.clone());
+ let dsn_username = dsn.as_ref().and_then(|value| value.username.clone());
+ let dsn_password = dsn.as_ref().and_then(|value| value.password.clone());
+ let dsn_insecure = dsn.as_ref().map(|value| !value.tls);
FileConfig {
- service: env_string("LOZA_SERVICE_NAME"),
+ service: env_string("LOZA_SERVICE_NAME").or(dsn_service),
version: env_string("LOZA_SERVICE_VERSION"),
- environment: env_string("LOZA_ENVIRONMENT"),
+ environment: env_string("LOZA_ENVIRONMENT").or(dsn_environment),
region: env_string("LOZA_REGION"),
level: env_string("LOZA_LOG_LEVEL"),
strict: env_bool("LOZA_STRICT"),
async_enabled: env_bool("LOZA_ASYNC_ENABLED"),
- collector_endpoint: env_string("LOZA_COLLECTOR_ENDPOINT")
- .or_else(|| env_string("LOZA_COLLECTOR_URL")),
+ collector_endpoint: env_string("LOZA_COLLECTOR_URL")
+ .or_else(|| env_string("LOZA_COLLECTOR_ENDPOINT"))
+ .or_else(|| dsn.as_ref().map(|value| value.base_url.clone())),
+ collector_name: dsn_collector_name,
api_key: env_string("LOZA_API_KEY").or_else(|| env_string("LOZA_COLLECTOR_API_KEY")),
+ basic_username: dsn_username,
+ basic_password: dsn_password,
+ insecure: env_bool("LOZA_INSECURE").or(dsn_insecure),
duplicate_policy: env_string("LOZA_DUPLICATE_POLICY"),
max_event_bytes: env_usize("LOZA_MAX_EVENT_BYTES"),
}
diff --git a/sdks/rs/src/core/client.rs b/sdks/rs/src/core/client.rs
index 7443886..82c0617 100644
--- a/sdks/rs/src/core/client.rs
+++ b/sdks/rs/src/core/client.rs
@@ -1,4 +1,6 @@
+use base64::Engine as _;
use serde_json::Value;
+
use std::collections::BTreeMap;
use std::io;
use std::time::Duration;
@@ -152,10 +154,13 @@ impl HTTPClient {
}
/// Collector-specific HTTP client with envelope support.
-#[derive(Clone, Debug)]
+#[derive(Clone)]
pub struct CollectorHttpClient {
pub endpoint: String,
pub api_key: Option,
+ pub basic_username: Option,
+ pub basic_password: Option,
+ pub insecure: bool,
pub auth_header: String,
pub timeout_ms: u64,
pub sdk_name: String,
@@ -163,12 +168,34 @@ pub struct CollectorHttpClient {
pub service: Option,
}
+impl std::fmt::Debug for CollectorHttpClient {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("CollectorHttpClient")
+ .field("endpoint", &self.endpoint)
+ .field("api_key", &self.api_key.as_ref().map(|_| ""))
+ .field(
+ "basic_credentials",
+ &self.basic_username.as_ref().map(|_| ""),
+ )
+ .field("insecure", &self.insecure)
+ .field("auth_header", &self.auth_header)
+ .field("timeout_ms", &self.timeout_ms)
+ .field("sdk_name", &self.sdk_name)
+ .field("sdk_version", &self.sdk_version)
+ .field("service", &self.service)
+ .finish()
+ }
+}
+
impl CollectorHttpClient {
pub fn new(endpoint: impl Into) -> Self {
let endpoint = normalize_collector_endpoint(endpoint.into());
Self {
endpoint,
api_key: None,
+ basic_username: None,
+ basic_password: None,
+ insecure: false,
auth_header: "Authorization".to_string(),
timeout_ms: 2_000,
sdk_name: "loza-rs".to_string(),
@@ -182,6 +209,21 @@ impl CollectorHttpClient {
self
}
+ pub fn with_basic_auth(
+ mut self,
+ username: impl Into,
+ password: impl Into,
+ ) -> Self {
+ self.basic_username = Some(username.into());
+ self.basic_password = Some(password.into());
+ self
+ }
+
+ pub fn with_insecure(mut self, insecure: bool) -> Self {
+ self.insecure = insecure;
+ self
+ }
+
pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
self.timeout_ms = timeout_ms;
self
@@ -235,6 +277,24 @@ impl CollectorHttpClient {
if let Some(api_key) = &self.api_key {
request = request.with_header(&self.auth_header, format!("Bearer {}", api_key));
+ } else {
+ match (&self.basic_username, &self.basic_password) {
+ (Some(username), Some(password)) => {
+ validate_basic_auth_endpoint(
+ &self.endpoint,
+ username,
+ password,
+ self.insecure,
+ )?;
+ let token = base64::engine::general_purpose::STANDARD
+ .encode(format!("{username}:{password}"));
+ request = request.with_header(&self.auth_header, format!("Basic {token}"));
+ }
+ (None, None) => {}
+ _ => {
+ return Err("basic username and password must be configured together".into());
+ }
+ }
}
request = request
@@ -408,6 +468,38 @@ pub fn inject_http_headers(request: &mut HTTPRequest, event: &crate::EventContex
}
/// Extract trace context from HTTP headers.
+fn validate_basic_auth_endpoint(
+ endpoint: &str,
+ username: &str,
+ _password: &str,
+ insecure: bool,
+) -> Result<(), String> {
+ if username.is_empty() || username.contains(':') || username.chars().any(char::is_whitespace) {
+ return Err(
+ "basic auth username must be non-empty and contain no ':' or whitespace".into(),
+ );
+ }
+ let Some((scheme, rest)) = endpoint.split_once("://") else {
+ return Ok(());
+ };
+ let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
+ if authority.contains('@') {
+ return Err("collector credentials must not be embedded in the endpoint URL".into());
+ }
+ if !scheme.eq_ignore_ascii_case("http") || insecure {
+ return Ok(());
+ }
+ let host = if let Some(ipv6) = authority.strip_prefix('[') {
+ ipv6.split(']').next().unwrap_or(ipv6)
+ } else {
+ authority.split(':').next().unwrap_or(authority)
+ };
+ if matches!(host, "localhost" | "127.0.0.1" | "::1") {
+ return Ok(());
+ }
+ Err("credentialed HTTP requires TLS (set insecure explicitly for local development)".into())
+}
+
pub fn extract_http_headers(
headers: &BTreeMap,
) -> (Option, Option, Option) {
diff --git a/sdks/rs/src/core/options.rs b/sdks/rs/src/core/options.rs
index 91060c1..51abbc1 100644
--- a/sdks/rs/src/core/options.rs
+++ b/sdks/rs/src/core/options.rs
@@ -48,6 +48,11 @@ pub fn with_collector_endpoint(endpoint: impl Into) -> ConfigOption {
Box::new(move |cfg| cfg.with_collector_endpoint(endpoint))
}
+pub fn with_collector_name(collector_name: impl Into) -> ConfigOption {
+ let collector_name = collector_name.into();
+ Box::new(move |cfg| cfg.with_collector_name(collector_name))
+}
+
pub fn with_duplicate_policy(policy: impl Into) -> ConfigOption {
let policy = policy.into();
Box::new(move |cfg| cfg.with_duplicate_policy(policy))
diff --git a/sdks/rs/src/core/version.rs b/sdks/rs/src/core/version.rs
index 908151a..2c0f080 100644
--- a/sdks/rs/src/core/version.rs
+++ b/sdks/rs/src/core/version.rs
@@ -1,8 +1,7 @@
-/// Load SDK version from loza-rs.yaml metadata file.
-///
-/// Falls back to a hardcoded default if the file cannot be found or parsed.
-
-const FALLBACK_VERSION: &str = "0.2.6";
+//! Load SDK version from loza-rs.yaml metadata file.
+//!
+//! Falls back to a hardcoded default if the file cannot be found or parsed.
+const FALLBACK_VERSION: &str = "0.3.0";
/// Read version from loza-rs.yaml, searching standard locations.
/// Returns FALLBACK_VERSION if file not found or parsing fails.
@@ -36,5 +35,5 @@ pub fn sdk_version() -> &'static str {
// Use a once_cell-like pattern for thread-safe lazy initialization
use std::sync::OnceLock;
static VERSION: OnceLock = OnceLock::new();
- VERSION.get_or_init(|| load_version())
+ VERSION.get_or_init(load_version)
}
diff --git a/sdks/rs/src/lib.rs b/sdks/rs/src/lib.rs
index 9abfdc2..52a494d 100644
--- a/sdks/rs/src/lib.rs
+++ b/sdks/rs/src/lib.rs
@@ -1297,6 +1297,9 @@ pub fn OtlpSink(endpoint: impl Into) -> SinkConfig {
SinkConfig::HttpBatch {
endpoint: endpoint.into(),
api_key: None,
+ basic_username: None,
+ basic_password: None,
+ insecure: false,
timeout_ms: 2_000,
max_batch_bytes: 256 * 1024,
max_retries: 3,
@@ -1645,6 +1648,9 @@ pub fn CollectorSinkWithEndpoint(endpoint: impl Into) -> SinkConfig {
SinkConfig::HttpBatch {
endpoint: endpoint.into(),
api_key: None,
+ basic_username: None,
+ basic_password: None,
+ insecure: false,
timeout_ms: 2_000,
max_batch_bytes: 256 * 1024,
max_retries: 3,
@@ -1665,6 +1671,9 @@ pub fn KafkaSink(endpoint: impl Into, _topic: impl Into) -> Sink
SinkConfig::HttpBatch {
endpoint: endpoint.into(),
api_key: None,
+ basic_username: None,
+ basic_password: None,
+ insecure: false,
timeout_ms: 2_000,
max_batch_bytes: 256 * 1024,
max_retries: 3,
@@ -1823,6 +1832,10 @@ pub fn WithCollectorEndpoint(endpoint: impl Into) -> core::options::Conf
core::options::with_collector_endpoint(endpoint)
}
+pub fn WithCollectorName(collector_name: impl Into) -> core::options::ConfigOption {
+ core::options::with_collector_name(collector_name)
+}
+
pub fn WithDuplicatePolicy(policy: impl Into) -> core::options::ConfigOption {
core::options::with_duplicate_policy(policy)
}
diff --git a/sdks/rs/src/logger.rs b/sdks/rs/src/logger.rs
index dbd5653..ed467ec 100644
--- a/sdks/rs/src/logger.rs
+++ b/sdks/rs/src/logger.rs
@@ -733,8 +733,11 @@ fn install_default_collector_sink(config: &mut Config) {
}
let http_batch = crate::SinkConfig::HttpBatch {
- endpoint: endpoint.to_string(),
- api_key: None,
+ endpoint: crate::config::collector_events_endpoint(endpoint, &config.collector_name),
+ api_key: (!config.api_key.is_empty()).then(|| config.api_key.clone()),
+ basic_username: config.basic_username.clone(),
+ basic_password: config.basic_password.clone(),
+ insecure: config.insecure,
timeout_ms: 2_000,
max_batch_bytes: 256 * 1024,
max_retries: 3,
diff --git a/sdks/rs/src/middleware/actix/middleware.rs b/sdks/rs/src/middleware/actix/middleware.rs
index d09edde..36e1968 100644
--- a/sdks/rs/src/middleware/actix/middleware.rs
+++ b/sdks/rs/src/middleware/actix/middleware.rs
@@ -103,7 +103,7 @@ pub mod actix_impl {
Ok(res)
}
Err(err) => {
- let _ = logger.finish_error(&mut ctx, &err.to_string());
+ let _ = logger.finish_error(&mut ctx, err.to_string());
let _ = logger.emit(&ctx);
Err(err)
}
diff --git a/sdks/rs/src/sink.rs b/sdks/rs/src/sink.rs
index 6d8b46d..608c922 100644
--- a/sdks/rs/src/sink.rs
+++ b/sdks/rs/src/sink.rs
@@ -2,6 +2,7 @@ use crate::config::SinkConfig;
use crate::core::client::CollectorHttpClient;
use crate::generated::spec_contract::parse_collector_response_value;
use crate::internal::retry::RetryPolicy;
+use base64::Engine as _;
use flate2::write::GzEncoder;
use flate2::Compression;
use serde_json::Value;
@@ -102,12 +103,24 @@ pub fn write_sink_with_ack(
}
SinkConfig::Noop => Ok(()),
SinkConfig::HttpBatch {
- endpoint, ndjson, ..
+ endpoint,
+ ndjson,
+ api_key,
+ basic_username,
+ basic_password,
+ insecure,
+ ..
} => {
+ let auth = (
+ api_key.as_deref(),
+ basic_username.as_deref(),
+ basic_password.as_deref(),
+ *insecure,
+ );
if *ndjson {
- post_http_ndjson_with_ack(endpoint, &[encoded.to_string()], ack)
+ post_http_ndjson_with_ack(endpoint, &[encoded.to_string()], ack, auth)
} else {
- post_http_batch_with_ack(endpoint, &[encoded.to_string()], ack)
+ post_http_batch_with_ack(endpoint, &[encoded.to_string()], ack, auth)
}
}
}
@@ -127,12 +140,24 @@ pub fn write_batch_sink_with_ack(
) -> io::Result<()> {
match sink {
SinkConfig::HttpBatch {
- endpoint, ndjson, ..
+ endpoint,
+ ndjson,
+ api_key,
+ basic_username,
+ basic_password,
+ insecure,
+ ..
} => {
+ let auth = (
+ api_key.as_deref(),
+ basic_username.as_deref(),
+ basic_password.as_deref(),
+ *insecure,
+ );
if *ndjson {
- post_http_ndjson_with_ack(endpoint, encoded_events, ack)
+ post_http_ndjson_with_ack(endpoint, encoded_events, ack, auth)
} else {
- post_http_batch_with_ack(endpoint, encoded_events, ack)
+ post_http_batch_with_ack(endpoint, encoded_events, ack, auth)
}
}
_ => {
@@ -161,15 +186,16 @@ pub fn close_sink(sink: &SinkConfig) -> io::Result<()> {
#[allow(dead_code)]
fn post_http_batch(endpoint: &str, encoded_events: &[String]) -> io::Result<()> {
- post_http_batch_with_ack(endpoint, encoded_events, None)
+ post_http_batch_with_ack(endpoint, encoded_events, None, (None, None, None, false))
}
fn post_http_batch_with_ack(
endpoint: &str,
encoded_events: &[String],
ack: Option<&CollectorAckHandler>,
+ auth: (Option<&str>, Option<&str>, Option<&str>, bool),
) -> io::Result<()> {
- let client = collector_http_client(endpoint);
+ let client = collector_http_client(endpoint, auth);
let payload = client.envelope(encoded_events);
client
.validate_envelope(&payload)
@@ -193,6 +219,12 @@ fn post_http_batch_with_ack(
api_key.clone()
};
request = request.set(&client.auth_header, &auth_value);
+ } else if let (Some(username), Some(password)) =
+ (&client.basic_username, &client.basic_password)
+ {
+ let token =
+ base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"));
+ request = request.set(&client.auth_header, &format!("Basic {token}"));
}
let retry_policy = RetryPolicy {
max_attempts: 3,
@@ -271,15 +303,16 @@ fn notify_ack(ack: Option<&CollectorAckHandler>, raw: &str) {
#[allow(dead_code)]
fn post_http_ndjson(endpoint: &str, encoded_events: &[String]) -> io::Result<()> {
- post_http_ndjson_with_ack(endpoint, encoded_events, None)
+ post_http_ndjson_with_ack(endpoint, encoded_events, None, (None, None, None, false))
}
fn post_http_ndjson_with_ack(
endpoint: &str,
encoded_events: &[String],
ack: Option<&CollectorAckHandler>,
+ auth: (Option<&str>, Option<&str>, Option<&str>, bool),
) -> io::Result<()> {
- let client = collector_http_client(endpoint);
+ let client = collector_http_client(endpoint, auth);
let body = encoded_events.join("\n");
let body_bytes = body.into_bytes();
@@ -299,6 +332,12 @@ fn post_http_ndjson_with_ack(
api_key.clone()
};
request = request.set(&client.auth_header, &auth_value);
+ } else if let (Some(username), Some(password)) =
+ (&client.basic_username, &client.basic_password)
+ {
+ let token =
+ base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"));
+ request = request.set(&client.auth_header, &format!("Basic {token}"));
}
let retry_policy = RetryPolicy {
max_attempts: 3,
@@ -448,14 +487,23 @@ fn retryable_error_message(value: &Value) -> Option {
})
}
-fn collector_http_client(endpoint: &str) -> CollectorHttpClient {
- let mut client = CollectorHttpClient::new(endpoint.to_string());
- if let Ok(api_key) = env::var("LOZA_COLLECTOR_API_KEY") {
+fn collector_http_client(
+ endpoint: &str,
+ auth: (Option<&str>, Option<&str>, Option<&str>, bool),
+) -> CollectorHttpClient {
+ let (api_key, basic_username, basic_password, insecure) = auth;
+ let mut client = CollectorHttpClient::new(endpoint.to_string()).with_insecure(insecure);
+ if let Some(api_key) = api_key.filter(|value| !value.is_empty()) {
+ client = client.with_api_key(api_key.to_string());
+ } else if let Ok(api_key) = env::var("LOZA_COLLECTOR_API_KEY") {
let api_key = api_key.trim();
if !api_key.is_empty() {
client = client.with_api_key(api_key.to_string());
}
}
+ if let (Some(username), Some(password)) = (basic_username, basic_password) {
+ client = client.with_basic_auth(username.to_string(), password.to_string());
+ }
if let Ok(header) = env::var("LOZA_COLLECTOR_API_KEY_HEADER") {
let header = header.trim();
if !header.is_empty() {
diff --git a/sdks/rs/src/sinks/httpbatch/config.rs b/sdks/rs/src/sinks/httpbatch/config.rs
index ebb15e2..a2a56f2 100644
--- a/sdks/rs/src/sinks/httpbatch/config.rs
+++ b/sdks/rs/src/sinks/httpbatch/config.rs
@@ -3,19 +3,40 @@ use crate::generated::spec_contract::CollectorResponse;
use crate::SinkConfig;
use serde_json::Value;
-#[derive(Clone, Debug)]
+#[derive(Clone)]
pub struct HttpBatchSinkConfig {
pub endpoint: String,
pub api_key: Option,
+ pub basic_username: Option,
+ pub basic_password: Option,
+ pub insecure: bool,
pub timeout_ms: u64,
pub max_batch_bytes: usize,
}
+impl std::fmt::Debug for HttpBatchSinkConfig {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("HttpBatchSinkConfig")
+ .field("endpoint", &self.endpoint)
+ .field("api_key", &self.api_key.as_ref().map(|_| ""))
+ .field(
+ "basic_credentials",
+ &self.basic_username.as_ref().map(|_| ""),
+ )
+ .field("timeout_ms", &self.timeout_ms)
+ .field("max_batch_bytes", &self.max_batch_bytes)
+ .finish()
+ }
+}
+
impl HttpBatchSinkConfig {
pub fn new(endpoint: impl Into) -> Self {
Self {
endpoint: endpoint.into(),
api_key: None,
+ basic_username: None,
+ basic_password: None,
+ insecure: false,
timeout_ms: 2_000,
max_batch_bytes: 256 * 1024,
}
@@ -25,6 +46,20 @@ impl HttpBatchSinkConfig {
self.api_key = Some(api_key.into());
self
}
+ pub fn with_basic_auth(
+ mut self,
+ username: impl Into,
+ password: impl Into,
+ ) -> Self {
+ self.basic_username = Some(username.into());
+ self.basic_password = Some(password.into());
+ self
+ }
+
+ pub fn with_insecure(mut self, insecure: bool) -> Self {
+ self.insecure = insecure;
+ self
+ }
pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
self.timeout_ms = timeout_ms;
@@ -35,6 +70,9 @@ impl HttpBatchSinkConfig {
SinkConfig::HttpBatch {
endpoint: self.endpoint.clone(),
api_key: self.api_key.clone(),
+ basic_username: self.basic_username.clone(),
+ basic_password: self.basic_password.clone(),
+ insecure: self.insecure,
timeout_ms: self.timeout_ms,
max_batch_bytes: self.max_batch_bytes,
max_retries: 3,
@@ -44,10 +82,15 @@ impl HttpBatchSinkConfig {
}
pub fn client(&self) -> CollectorHttpClient {
- let mut client = CollectorHttpClient::new(self.endpoint.clone());
+ let mut client =
+ CollectorHttpClient::new(self.endpoint.clone()).with_insecure(self.insecure);
client.timeout_ms = self.timeout_ms;
if let Some(api_key) = &self.api_key {
client = client.with_api_key(api_key.clone());
+ } else if let (Some(username), Some(password)) =
+ (&self.basic_username, &self.basic_password)
+ {
+ client = client.with_basic_auth(username.clone(), password.clone());
}
client
}
diff --git a/sdks/rs/src/sinks/mod.rs b/sdks/rs/src/sinks/mod.rs
index d95f6a2..d4f3c0a 100644
--- a/sdks/rs/src/sinks/mod.rs
+++ b/sdks/rs/src/sinks/mod.rs
@@ -30,6 +30,9 @@ pub fn otlp_sink(endpoint: impl Into) -> SinkConfig {
SinkConfig::HttpBatch {
endpoint: endpoint.into(),
api_key: None,
+ basic_username: None,
+ basic_password: None,
+ insecure: false,
timeout_ms: 2_000,
max_batch_bytes: 256 * 1024,
max_retries: 3,
diff --git a/sdks/rs/tests/client_creation.rs b/sdks/rs/tests/client_creation.rs
index 30f2daf..7c7701c 100644
--- a/sdks/rs/tests/client_creation.rs
+++ b/sdks/rs/tests/client_creation.rs
@@ -8,3 +8,16 @@ fn client_creation_and_alias_helpers_work() {
assert_eq!(aliased.config().service, "default");
assert_eq!(aliased.config().alias, "audit");
}
+
+#[test]
+fn direct_basic_auth_rejects_remote_plaintext_http() {
+ let client = loza::core::CollectorHttpClient::new("http://collector.example.com")
+ .with_basic_auth("key-id", "secret");
+ let error = client
+ .ingest(&[])
+ .expect_err("remote Basic auth must not use plaintext HTTP");
+ assert!(
+ error.contains("credentialed HTTP requires TLS"),
+ "unexpected error: {error}"
+ );
+}
diff --git a/sdks/rs/tests/collector_e2e.rs b/sdks/rs/tests/collector_e2e.rs
index 5af15db..e7152db 100644
--- a/sdks/rs/tests/collector_e2e.rs
+++ b/sdks/rs/tests/collector_e2e.rs
@@ -28,7 +28,10 @@ fn send_raw_envelope(envelope: &Value) -> (u16, String) {
let client = HTTPClient::with_timeout_ms(5_000);
let body = serde_json::to_vec(envelope).unwrap();
let req = HTTPRequest::new("POST", format!("{}/events", collector_url()))
- .with_header("Authorization", format!("Bearer {}", env::var("LOZA_API_KEY").unwrap_or_default()))
+ .with_header(
+ "Authorization",
+ format!("Bearer {}", env::var("LOZA_API_KEY").unwrap_or_default()),
+ )
.with_header("Content-Type", "application/json")
.with_body(body);
let resp = client.send(&req).unwrap();
diff --git a/sdks/rs/tests/config_env.rs b/sdks/rs/tests/config_env.rs
index a35f421..9e7a387 100644
--- a/sdks/rs/tests/config_env.rs
+++ b/sdks/rs/tests/config_env.rs
@@ -25,6 +25,63 @@ fn env_overrides_file_defaults() {
assert!(!cfg.async_enabled);
}
+#[test]
+fn plaintext_basic_auth_is_allowed_for_local_dsn_only() {
+ let cfg = Config::test("checkout")
+ .with_dsn("loza://localhost/project?tls=false")
+ .with_basic_auth("dsn-user", "dsn-secret");
+ assert!(
+ cfg.validate().is_ok(),
+ "local plaintext DSNs should remain usable"
+ );
+}
+
+#[test]
+fn remote_plaintext_basic_auth_is_rejected() {
+ let cfg = Config::test("checkout")
+ .with_dsn("loza://dsn-user:dsn-secret@collector.example/project?tls=false");
+ assert!(
+ cfg.validate().is_err(),
+ "remote plaintext Basic-auth DSNs must be rejected"
+ );
+}
+
+#[test]
+fn credentialed_dsn_configures_scoped_endpoint_and_preserves_api_key_precedence() {
+ let capability = "lx_pub_6DJvd3D0izOaQx3n5BhKqN";
+ let logger = loza::New(
+ Config::production("checkout")
+ .with_dsn(format!(
+ "loza://{capability}:@collector.example/public-collector?env=prod"
+ ))
+ .with_api_key("api-key"),
+ );
+
+ assert_eq!(
+ logger.config().collector_endpoint,
+ "https://collector.example:443"
+ );
+ assert_eq!(logger.config().collector_name, "public-collector");
+ match logger.config().sinks.first() {
+ Some(loza::SinkConfig::HttpBatch {
+ endpoint,
+ api_key,
+ basic_username,
+ basic_password,
+ ..
+ }) => {
+ assert_eq!(
+ endpoint,
+ "https://collector.example:443/collectors/public-collector/events"
+ );
+ assert_eq!(api_key.as_deref(), Some("api-key"));
+ assert_eq!(basic_username.as_deref(), Some(capability));
+ assert_eq!(basic_password.as_deref(), Some(""));
+ }
+ sink => panic!("expected scoped HTTP batch sink, got {sink:?}"),
+ }
+}
+
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
diff --git a/sdks/rs/tests/domain_helpers_test.rs b/sdks/rs/tests/domain_helpers_test.rs
index c7b3e9e..7699c9b 100644
--- a/sdks/rs/tests/domain_helpers_test.rs
+++ b/sdks/rs/tests/domain_helpers_test.rs
@@ -343,7 +343,7 @@ fn test_collector_api_stubs() {
// Test client construction and URL formatting without making HTTP calls
assert_eq!(client.tail_endpoint(), "http://localhost:9308/tail");
assert_eq!(client.sdk_name, "loza-rs");
- assert_eq!(client.sdk_version, "0.2.6");
+ assert_eq!(client.sdk_version, env!("CARGO_PKG_VERSION"));
// Envelope building test
let envelope = client.envelope(&["{\"event\":\"test\"}".to_string()]);
assert_eq!(
diff --git a/sdks/rs/tests/dsn_test.rs b/sdks/rs/tests/dsn_test.rs
index 67a2d8d..f309c95 100644
--- a/sdks/rs/tests/dsn_test.rs
+++ b/sdks/rs/tests/dsn_test.rs
@@ -1,26 +1,45 @@
-/// Shared test vectors from loza/spec/dsn/test-cases.json (25 cases: 12 valid, 13 invalid).
-
-fn assert_valid(
- input: &str,
- expect_scheme: &str,
- expect_host: &str,
- expect_port: u16,
- expect_project: &str,
- expect_env: &str,
- expect_service: &str,
- expect_tls: bool,
- expect_transport: &str,
- expect_base_url: &str,
- expect_events_url: Option<&str>,
- expect_batch_url: Option<&str>,
- expect_otlp_url: Option<&str>,
- expect_tail_ws_url: Option<&str>,
-) {
- let dsn = loza::dsn::parse(input).expect(&format!("expected valid DSN: {input}"));
+// Shared test vectors from loza/spec/dsn/test-cases.json (25 cases: 12 valid, 13 invalid).
+type ExpectedDsn<'a> = (
+ &'a str,
+ &'a str,
+ u16,
+ &'a str,
+ &'a str,
+ &'a str,
+ bool,
+ &'a str,
+ &'a str,
+ Option<&'a str>,
+ Option<&'a str>,
+ Option<&'a str>,
+ Option<&'a str>,
+);
+
+fn assert_valid(input: &str, expected: ExpectedDsn<'_>) {
+ let (
+ expect_scheme,
+ expect_host,
+ expect_port,
+ expect_project,
+ expect_env,
+ expect_service,
+ expect_tls,
+ expect_transport,
+ expect_base_url,
+ expect_events_url,
+ expect_batch_url,
+ expect_otlp_url,
+ expect_tail_ws_url,
+ ) = expected;
+ let dsn = loza::dsn::parse(input).unwrap_or_else(|_| panic!("expected valid DSN: {input}"));
assert_eq!(dsn.scheme, expect_scheme, "scheme mismatch for {input}");
assert_eq!(dsn.host, expect_host, "host mismatch for {input}");
assert_eq!(dsn.port, expect_port, "port mismatch for {input}");
assert_eq!(dsn.project, expect_project, "project mismatch for {input}");
+ assert_eq!(
+ dsn.collector_name, expect_project,
+ "collector_name mismatch for {input}"
+ );
assert_eq!(dsn.env, expect_env, "env mismatch for {input}");
assert_eq!(dsn.service, expect_service, "service mismatch for {input}");
assert_eq!(dsn.tls, expect_tls, "tls mismatch for {input}");
@@ -57,19 +76,21 @@ fn assert_invalid(input: &str) {
fn localhost_dev_with_explicit_port() {
assert_valid(
"loza://localhost:9308/demo?tls=false",
- "loza",
- "localhost",
- 9308,
- "demo",
- "default",
- "",
- false,
- "http",
- "http://localhost:9308",
- Some("http://localhost:9308/events"),
- Some("http://localhost:9308/events/batch"),
- Some("http://localhost:9308/otlp/logs"),
- Some("ws://localhost:9308/tail"),
+ (
+ "loza",
+ "localhost",
+ 9308,
+ "demo",
+ "default",
+ "",
+ false,
+ "http",
+ "http://localhost:9308",
+ Some("http://localhost:9308/collectors/demo/events"),
+ Some("http://localhost:9308/collectors/demo/events/batch"),
+ Some("http://localhost:9308/collectors/demo/otlp/logs"),
+ Some("ws://localhost:9308/collectors/demo/tail"),
+ ),
);
}
@@ -77,19 +98,21 @@ fn localhost_dev_with_explicit_port() {
fn localhost_default_port_9308() {
assert_valid(
"loza://localhost/demo?tls=false",
- "loza",
- "localhost",
- 9308,
- "demo",
- "default",
- "",
- false,
- "http",
- "http://localhost:9308",
- Some("http://localhost:9308/events"),
- Some("http://localhost:9308/events/batch"),
- Some("http://localhost:9308/otlp/logs"),
- Some("ws://localhost:9308/tail"),
+ (
+ "loza",
+ "localhost",
+ 9308,
+ "demo",
+ "default",
+ "",
+ false,
+ "http",
+ "http://localhost:9308",
+ Some("http://localhost:9308/collectors/demo/events"),
+ Some("http://localhost:9308/collectors/demo/events/batch"),
+ Some("http://localhost:9308/collectors/demo/otlp/logs"),
+ Some("ws://localhost:9308/collectors/demo/tail"),
+ ),
);
}
@@ -97,19 +120,21 @@ fn localhost_default_port_9308() {
fn prod_default_tls_true() {
assert_valid(
"loza://collector.example.com/demo",
- "loza",
- "collector.example.com",
- 443,
- "demo",
- "default",
- "",
- true,
- "http",
- "https://collector.example.com:443",
- Some("https://collector.example.com:443/events"),
- Some("https://collector.example.com:443/events/batch"),
- Some("https://collector.example.com:443/otlp/logs"),
- Some("wss://collector.example.com:443/tail"),
+ (
+ "loza",
+ "collector.example.com",
+ 443,
+ "demo",
+ "default",
+ "",
+ true,
+ "http",
+ "https://collector.example.com:443",
+ Some("https://collector.example.com:443/collectors/demo/events"),
+ Some("https://collector.example.com:443/collectors/demo/events/batch"),
+ Some("https://collector.example.com:443/collectors/demo/otlp/logs"),
+ Some("wss://collector.example.com:443/collectors/demo/tail"),
+ ),
);
}
@@ -117,19 +142,21 @@ fn prod_default_tls_true() {
fn custom_env_and_service() {
assert_valid(
"loza://collector.example.com/demo?env=prod&service=api",
- "loza",
- "collector.example.com",
- 443,
- "demo",
- "prod",
- "api",
- true,
- "http",
- "https://collector.example.com:443",
- None,
- None,
- None,
- None,
+ (
+ "loza",
+ "collector.example.com",
+ 443,
+ "demo",
+ "prod",
+ "api",
+ true,
+ "http",
+ "https://collector.example.com:443",
+ None,
+ None,
+ None,
+ None,
+ ),
);
}
@@ -137,19 +164,21 @@ fn custom_env_and_service() {
fn otlp_transport() {
assert_valid(
"loza://collector.example.com/demo?transport=otlp",
- "loza",
- "collector.example.com",
- 443,
- "demo",
- "default",
- "",
- true,
- "otlp",
- "https://collector.example.com:443",
- None,
- None,
- Some("https://collector.example.com:443/otlp/logs"),
- None,
+ (
+ "loza",
+ "collector.example.com",
+ 443,
+ "demo",
+ "default",
+ "",
+ true,
+ "otlp",
+ "https://collector.example.com:443",
+ None,
+ None,
+ Some("https://collector.example.com:443/collectors/demo/otlp/logs"),
+ None,
+ ),
);
}
@@ -157,19 +186,21 @@ fn otlp_transport() {
fn grpc_transport() {
assert_valid(
"loza://collector.example.com/demo?transport=grpc",
- "loza",
- "collector.example.com",
- 443,
- "demo",
- "default",
- "",
- true,
- "grpc",
- "https://collector.example.com:443",
- None,
- None,
- None,
- None,
+ (
+ "loza",
+ "collector.example.com",
+ 443,
+ "demo",
+ "default",
+ "",
+ true,
+ "grpc",
+ "https://collector.example.com:443",
+ None,
+ None,
+ None,
+ None,
+ ),
);
}
@@ -177,19 +208,21 @@ fn grpc_transport() {
fn loopback_127_defaults_tls_false() {
assert_valid(
"loza://127.0.0.1/demo",
- "loza",
- "127.0.0.1",
- 9308,
- "demo",
- "default",
- "",
- false,
- "http",
- "http://127.0.0.1:9308",
- None,
- None,
- None,
- None,
+ (
+ "loza",
+ "127.0.0.1",
+ 9308,
+ "demo",
+ "default",
+ "",
+ false,
+ "http",
+ "http://127.0.0.1:9308",
+ None,
+ None,
+ None,
+ None,
+ ),
);
}
@@ -197,19 +230,21 @@ fn loopback_127_defaults_tls_false() {
fn ipv6_loopback_defaults_tls_false() {
assert_valid(
"loza://[::1]/demo",
- "loza",
- "::1",
- 9308,
- "demo",
- "default",
- "",
- false,
- "http",
- "http://[::1]:9308",
- None,
- None,
- None,
- None,
+ (
+ "loza",
+ "::1",
+ 9308,
+ "demo",
+ "default",
+ "",
+ false,
+ "http",
+ "http://[::1]:9308",
+ None,
+ None,
+ None,
+ None,
+ ),
);
}
@@ -217,19 +252,21 @@ fn ipv6_loopback_defaults_tls_false() {
fn tls_auto_keeps_localhost_default() {
assert_valid(
"loza://localhost/demo?tls=auto",
- "loza",
- "localhost",
- 9308,
- "demo",
- "default",
- "",
- false,
- "http",
- "http://localhost:9308",
- None,
- None,
- None,
- None,
+ (
+ "loza",
+ "localhost",
+ 9308,
+ "demo",
+ "default",
+ "",
+ false,
+ "http",
+ "http://localhost:9308",
+ None,
+ None,
+ None,
+ None,
+ ),
);
}
@@ -237,19 +274,21 @@ fn tls_auto_keeps_localhost_default() {
fn tls_auto_keeps_remote_default() {
assert_valid(
"loza://collector.example.com/demo?tls=auto",
- "loza",
- "collector.example.com",
- 443,
- "demo",
- "default",
- "",
- true,
- "http",
- "https://collector.example.com:443",
- None,
- None,
- None,
- None,
+ (
+ "loza",
+ "collector.example.com",
+ 443,
+ "demo",
+ "default",
+ "",
+ true,
+ "http",
+ "https://collector.example.com:443",
+ None,
+ None,
+ None,
+ None,
+ ),
);
}
@@ -257,19 +296,21 @@ fn tls_auto_keeps_remote_default() {
fn explicit_tls_true_on_localhost() {
assert_valid(
"loza://localhost:8443/demo?tls=true",
- "loza",
- "localhost",
- 8443,
- "demo",
- "default",
- "",
- true,
- "http",
- "https://localhost:8443",
- None,
- None,
- None,
- None,
+ (
+ "loza",
+ "localhost",
+ 8443,
+ "demo",
+ "default",
+ "",
+ true,
+ "http",
+ "https://localhost:8443",
+ None,
+ None,
+ None,
+ None,
+ ),
);
}
@@ -277,19 +318,21 @@ fn explicit_tls_true_on_localhost() {
fn explicit_port_4318_with_otlp() {
assert_valid(
"loza://collector.example.com:4318/backend?env=staging&service=auth&transport=otlp",
- "loza",
- "collector.example.com",
- 4318,
- "backend",
- "staging",
- "auth",
- true,
- "otlp",
- "https://collector.example.com:4318",
- None,
- None,
- None,
- None,
+ (
+ "loza",
+ "collector.example.com",
+ 4318,
+ "backend",
+ "staging",
+ "auth",
+ true,
+ "otlp",
+ "https://collector.example.com:4318",
+ None,
+ None,
+ None,
+ None,
+ ),
);
}
@@ -336,8 +379,32 @@ fn reject_userinfo_key() {
}
#[test]
-fn reject_userinfo_with_password() {
- assert_invalid("loza://user:pass@collector.example.com/demo");
+fn parse_userinfo_credentials() {
+ let dsn = loza::dsn::parse("loza://user:pass@collector.example.com/demo")
+ .expect("credentialed DSN should parse");
+ assert_eq!(dsn.username.as_deref(), Some("user"));
+ assert_eq!(dsn.password.as_deref(), Some("pass"));
+ assert!(!dsn.base_url.contains("user"));
+ assert!(!dsn.base_url.contains("pass"));
+ assert!(!format!("{dsn:?}").contains("Some(\"pass\")"));
+}
+
+#[test]
+fn public_credentials_route_and_redact_bearer_capability() {
+ let capability = "lx_pub_6DJvd3D0izOaQx3n5BhKqN";
+ let dsn = loza::dsn::parse(&format!(
+ "loza://{capability}:@collector.example.com/public-collector"
+ ))
+ .expect("public DSN must parse");
+
+ assert_eq!(dsn.username.as_deref(), Some(capability));
+ assert_eq!(dsn.password.as_deref(), Some(""));
+ assert_eq!(dsn.collector_name, "public-collector");
+ assert_eq!(
+ dsn.events_url,
+ "https://collector.example.com:443/collectors/public-collector/events"
+ );
+ assert!(!format!("{dsn:?}").contains(capability));
}
#[test]
diff --git a/spec/conformance/manifest.json b/spec/conformance/manifest.json
index dc6e5bb..bfe3d27 100644
--- a/spec/conformance/manifest.json
+++ b/spec/conformance/manifest.json
@@ -1,6 +1,6 @@
{
"version": "v1",
- "product_version": "0.2.6",
+ "product_version": "0.3.0",
"strict_schema": "../schema/event.strict.schema.json",
"loose_schema": "../schema/event.loose.schema.json",
"valid": [
diff --git a/spec/docs/sdk-parity-manifest.json b/spec/docs/sdk-parity-manifest.json
index b0da7b2..3cfee69 100644
--- a/spec/docs/sdk-parity-manifest.json
+++ b/spec/docs/sdk-parity-manifest.json
@@ -1,8 +1,8 @@
{
- "version": "0.2.6",
+ "version": "0.3.0",
"package": "loza",
"scope": "lightweight-sdk",
- "stability": "product-parity-v0.2.6",
+ "stability": "product-parity-v0.3.0",
"policy": "full-product-method-family",
"sdks": ["go", "python", "rust", "javascript"],
"lifecycle": [
diff --git a/spec/dsn/README.md b/spec/dsn/README.md
index 2d51414..b877dc4 100644
--- a/spec/dsn/README.md
+++ b/spec/dsn/README.md
@@ -36,6 +36,34 @@ fmt.Println(d.EventsURL) // http://localhost:9308/events
fmt.Println(d.TailWSURL) // ws://localhost:9308/tail
```
+## Credentialed DSNs
+
+Credentials are optional userinfo in PostgreSQL-style form:
+
+```text
+loza://:@/?env=prod
+```
+
+The username is the Collector key ID and the password is that key's secret.
+Both values are percent-decoded by the parser; URL-reserved password characters
+must be percent-encoded (for example, `s%40cret%3Avalue`). Empty credentials,
+malformed escapes, usernames containing `:` or whitespace, and unencoded
+reserved password characters are rejected.
+
+Parsed credentials are exposed as `Username` and `Password`, but never appear
+in `BaseURL`, `EventsURL`, `BatchURL`, `OTLPURL`, or `TailWSURL`. SDKs send
+credentialed DSNs as HTTP Basic authentication and use TLS by default. Do not
+place secrets in logs or general-purpose DSN strings; use a redacted
+representation when displaying configuration.
+
+For SDK configuration, explicitly supplied code credentials take precedence
+over credentials in a code-supplied DSN, which take precedence over
+environment DSN credentials (`LOZA_DSN`). `LOZA_API_KEY` remains the
+highest-priority token credential. `LOZA_COLLECTOR_URL` changes only the
+endpoint and does not override DSN-derived environment, service, or
+credentials. A DSN without userinfo does not clear credentials configured
+separately.
+
## Cross-SDK validation
Each SDK should load `test-cases.json` and run its parser against all cases,
diff --git a/spec/dsn/dsn.go b/spec/dsn/dsn.go
index 25e66c5..e7c4876 100644
--- a/spec/dsn/dsn.go
+++ b/spec/dsn/dsn.go
@@ -5,11 +5,18 @@
//
// Format:
//
-// loza://[host][:port]/[project]?env=&service=&tls=&transport=
+// loza://[username:password@][host][:port]/[collector]?env=&service=&tls=&transport=
+//
+// A private credential is username:password. A public bearer capability uses
+// lx_pub_...: with an explicitly empty password.
+// Userinfo credentials are percent-decoded and exposed in Username/Password.
+// They are never included in resolved endpoint URLs.
//
// Examples:
//
-// loza://localhost:9308/my-app?env=dev&tls=false
+// loza://localhost:9308/demo?env=dev&tls=false
+// loza://key-id:s%40cret@collector.example.com/my-app?env=prod
+// loza://lx_pub_...:@collector.example.com/my-app?env=prod
// loza://collector.example.com/my-app?env=prod&tls=true
// loza://loza.internal:4318/backend?env=staging&service=auth&transport=otlp
package dsn
@@ -19,23 +26,37 @@ import (
"net/url"
"strconv"
"strings"
+ "unicode"
)
// LozaDSN holds the parsed and resolved values from a loza:// connection URI.
type LozaDSN struct {
- Scheme string // always "loza"
- Host string // hostname (no port)
- Port int // resolved port number
- Project string // path segment (project name)
- Env string // environment name (default: "default")
- Service string // optional service name
- TLS bool // whether to use HTTPS
- Transport string // "http", "otlp", or "grpc" (default: "http")
- BaseURL string // resolved http(s)://host:port
- EventsURL string // base + /events
- BatchURL string // base + /events/batch
- OTLPURL string // base + /otlp/logs
- TailWSURL string // ws(s)://host:port/tail
+ Scheme string // always "loza"
+ Username string // percent-decoded private key ID or public bearer capability
+ Password string // percent-decoded private key secret; empty for public capabilities
+ Host string // hostname (no port)
+ Port int // resolved port number
+ CollectorName string // canonical collector slug from the required path
+ Project string // deprecated compatibility alias for CollectorName
+ Env string // environment name (default: "default")
+ Service string // optional service name
+ TLS bool // whether to use HTTPS
+ Transport string // "http", "otlp", or "grpc" (default: "http")
+ BaseURL string // resolved http(s)://host:port; never includes credentials
+ EventsURL string // base + /collectors/{collector}/events
+ BatchURL string // base + /collectors/{collector}/events/batch
+ OTLPURL string // base + /collectors/{collector}/otlp/logs
+ TailWSURL string // ws(s)://host:port/collectors/{collector}/tail
+}
+
+// String returns a credential-free representation suitable for logs.
+func (d LozaDSN) String() string {
+ return d.BaseURL
+}
+
+// GoString returns a credential-free representation for %#v formatting.
+func (d LozaDSN) GoString() string {
+ return fmt.Sprintf("dsn.LozaDSN{BaseURL:%q}", d.BaseURL)
}
// Parse parses a raw loza:// connection URI into a LozaDSN.
@@ -43,8 +64,10 @@ type LozaDSN struct {
// Validation rules:
// - Scheme must be loza://
// - Host is required (loza:// or loza:///project are rejected)
-// - Project path is required (loza://host is rejected)
-// - No userinfo allowed (loza://user:pass@host/project is rejected)
+// - Collector path is required (loza://host is rejected)
+// - Private userinfo must contain non-empty username/password
+// - Public userinfo is lx_pub_...: with an explicitly empty password
+// - Username cannot contain a colon or whitespace after decoding
// - tls must be "true", "false", or "auto"
// - transport must be "http", "otlp", or "grpc"
// - Port must be 1-65535 if specified
@@ -69,12 +92,25 @@ func Parse(raw string) (*LozaDSN, error) {
// Parse as URL. The loza:// scheme is valid for url.Parse.
u, err := url.Parse(raw)
if err != nil {
- return nil, fmt.Errorf("invalid Loza DSN: %w", err)
+ // Do not wrap URL parser details: malformed input may contain secrets.
+ return nil, fmt.Errorf("invalid Loza DSN: malformed URL")
}
- // Reject userinfo (API keys must not be in the URL).
+ username := ""
+ password := ""
if u.User != nil {
- return nil, fmt.Errorf("invalid Loza DSN: do not put API keys in the URL, use LOZA_API_KEY instead")
+ var hasPassword bool
+ username = u.User.Username()
+ password, hasPassword = u.User.Password()
+ if !hasPassword || username == "" || (password == "" && !IsPublicCredentialUsername(username)) {
+ return nil, fmt.Errorf("invalid Loza DSN: credentials require username:password or lx_pub_...:")
+ }
+ if strings.Contains(username, ":") || hasWhitespace(username) {
+ return nil, fmt.Errorf("invalid Loza DSN: username contains an invalid character")
+ }
+ if err := validateRawPassword(raw); err != nil {
+ return nil, err
+ }
}
host := u.Hostname()
@@ -84,10 +120,11 @@ func Parse(raw string) (*LozaDSN, error) {
portStr := u.Port()
- // Project is the path segment without leading slash.
- project := strings.TrimPrefix(u.Path, "/")
- if project == "" {
- return nil, fmt.Errorf("invalid Loza DSN: project path is required, e.g. loza://host/my-project")
+ // The required path is the canonical collector identity. Project remains
+ // available as a compatibility alias for existing SDK consumers.
+ collectorName := strings.TrimPrefix(u.Path, "/")
+ if collectorName == "" {
+ return nil, fmt.Errorf("invalid Loza DSN: collector path is required, e.g. loza://host/my-collector")
}
q := u.Query()
@@ -161,23 +198,100 @@ func Parse(raw string) (*LozaDSN, error) {
baseURL := fmt.Sprintf("%s://%s:%d", scheme, hostPart, port)
+ collectorPath := url.PathEscape(collectorName)
+ collectorBaseURL := baseURL + "/collectors/" + collectorPath
+ collectorTailBaseURL := fmt.Sprintf("%s://%s:%d/collectors/%s", wsScheme, hostPart, port, collectorPath)
+
return &LozaDSN{
- Scheme: "loza",
- Host: host,
- Port: port,
- Project: project,
- Env: env,
- Service: service,
- TLS: tls,
- Transport: transport,
- BaseURL: baseURL,
- EventsURL: baseURL + "/events",
- BatchURL: baseURL + "/events/batch",
- OTLPURL: baseURL + "/otlp/logs",
- TailWSURL: fmt.Sprintf("%s://%s:%d/tail", wsScheme, hostPart, port),
+ Scheme: "loza",
+ Username: username,
+ Password: password,
+ Host: host,
+ Port: port,
+ CollectorName: collectorName,
+ Project: collectorName,
+ Env: env,
+ Service: service,
+ TLS: tls,
+ Transport: transport,
+ BaseURL: baseURL,
+ EventsURL: collectorBaseURL + "/events",
+ BatchURL: collectorBaseURL + "/events/batch",
+ OTLPURL: collectorBaseURL + "/otlp/logs",
+ TailWSURL: collectorTailBaseURL + "/tail",
}, nil
}
+func hasWhitespace(value string) bool {
+ for _, r := range value {
+ if unicode.IsSpace(r) {
+ return true
+ }
+ }
+ return false
+}
+
+// validateRawPassword enforces that URL-reserved password bytes are encoded.
+// url.Parse has already validated percent escapes and decoded u.User.
+func validateRawPassword(raw string) error {
+ const prefix = "loza://"
+ authorityEnd := len(raw)
+ for i := len(prefix); i < len(raw); i++ {
+ switch raw[i] {
+ case '/', '?', '#':
+ authorityEnd = i
+ i = len(raw)
+ }
+ }
+
+ authority := raw[len(prefix):authorityEnd]
+ at := strings.LastIndexByte(authority, '@')
+ if at < 0 {
+ return fmt.Errorf("invalid Loza DSN: malformed credentials")
+ }
+ userinfo := authority[:at]
+ colon := strings.IndexByte(userinfo, ':')
+ if colon < 0 {
+ return fmt.Errorf("invalid Loza DSN: credentials require non-empty username and password")
+ }
+
+ rawPassword := userinfo[colon+1:]
+ for i := range rawPassword {
+ if rawPassword[i] == '%' {
+ if i+2 >= len(rawPassword) || !isHexDigit(rawPassword[i+1]) || !isHexDigit(rawPassword[i+2]) {
+ return fmt.Errorf("invalid Loza DSN: malformed credentials")
+ }
+ continue
+ }
+ if isURLReserved(rawPassword[i]) {
+ return fmt.Errorf("invalid Loza DSN: password contains an unencoded reserved character")
+ }
+ }
+ return nil
+}
+
+func isURLReserved(value byte) bool {
+ switch value {
+ case ':', '/', '?', '#', '[', ']', '@', '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=':
+ return true
+ default:
+ return false
+ }
+}
+
+func isHexDigit(value byte) bool {
+ return (value >= '0' && value <= '9') ||
+ (value >= 'a' && value <= 'f') ||
+ (value >= 'A' && value <= 'F')
+}
+
+// IsPublicCredentialUsername reports whether username is the public DSN bearer
+// capability form. Its empty Basic password is intentional.
+func IsPublicCredentialUsername(username string) bool {
+ const prefix = "lx_pub_"
+ return strings.HasPrefix(username, prefix) && len(username) > len(prefix)
+}
+
// isLocalhost returns true for localhost, 127.0.0.1, or ::1.
func isLocalhost(host string) bool {
return host == "localhost" || host == "127.0.0.1" || host == "::1"
diff --git a/spec/dsn/dsn_test.go b/spec/dsn/dsn_test.go
index 896ad74..a8f8f08 100644
--- a/spec/dsn/dsn_test.go
+++ b/spec/dsn/dsn_test.go
@@ -1,6 +1,8 @@
package dsn
import (
+ "fmt"
+ "strings"
"testing"
)
@@ -9,6 +11,8 @@ func TestParse(t *testing.T) {
name string
input string
valid bool
+ username string
+ password string
host string
port int
project string
@@ -34,10 +38,10 @@ func TestParse(t *testing.T) {
tls: false,
transport: "http",
baseURL: "http://localhost:9308",
- eventsURL: "http://localhost:9308/events",
- batchURL: "http://localhost:9308/events/batch",
- otlpURL: "http://localhost:9308/otlp/logs",
- tailWSURL: "ws://localhost:9308/tail",
+ eventsURL: "http://localhost:9308/collectors/demo/events",
+ batchURL: "http://localhost:9308/collectors/demo/events/batch",
+ otlpURL: "http://localhost:9308/collectors/demo/otlp/logs",
+ tailWSURL: "ws://localhost:9308/collectors/demo/tail",
},
{
name: "localhost default port 9308",
@@ -49,8 +53,8 @@ func TestParse(t *testing.T) {
tls: false,
transport: "http",
baseURL: "http://localhost:9308",
- eventsURL: "http://localhost:9308/events",
- tailWSURL: "ws://localhost:9308/tail",
+ eventsURL: "http://localhost:9308/collectors/demo/events",
+ tailWSURL: "ws://localhost:9308/collectors/demo/tail",
},
{
name: "prod default tls=true",
@@ -63,10 +67,10 @@ func TestParse(t *testing.T) {
tls: true,
transport: "http",
baseURL: "https://collector.example.com:443",
- eventsURL: "https://collector.example.com:443/events",
- batchURL: "https://collector.example.com:443/events/batch",
- otlpURL: "https://collector.example.com:443/otlp/logs",
- tailWSURL: "wss://collector.example.com:443/tail",
+ eventsURL: "https://collector.example.com:443/collectors/demo/events",
+ batchURL: "https://collector.example.com:443/collectors/demo/events/batch",
+ otlpURL: "https://collector.example.com:443/collectors/demo/otlp/logs",
+ tailWSURL: "wss://collector.example.com:443/collectors/demo/tail",
},
{
name: "custom env and service",
@@ -102,10 +106,10 @@ func TestParse(t *testing.T) {
tls: true,
transport: "grpc",
baseURL: "https://collector.example.com:443",
- eventsURL: "https://collector.example.com:443/events",
- batchURL: "https://collector.example.com:443/events/batch",
- otlpURL: "https://collector.example.com:443/otlp/logs",
- tailWSURL: "wss://collector.example.com:443/tail",
+ eventsURL: "https://collector.example.com:443/collectors/demo/events",
+ batchURL: "https://collector.example.com:443/collectors/demo/events/batch",
+ otlpURL: "https://collector.example.com:443/collectors/demo/otlp/logs",
+ tailWSURL: "wss://collector.example.com:443/collectors/demo/tail",
},
{
name: "127.0.0.1 defaults to tls=false and port 9308",
@@ -158,20 +162,104 @@ func TestParse(t *testing.T) {
baseURL: "https://localhost:8443",
},
{
- name: "explicit port 4318 with otlp",
- input: "loza://collector.example.com:4318/backend?env=staging&service=auth&transport=otlp",
- valid: true,
- host: "collector.example.com",
- port: 4318,
- project: "backend",
- env: "staging",
- service: "auth",
- tls: true,
+ name: "explicit port 4318 with otlp",
+ input: "loza://collector.example.com:4318/backend?env=staging&service=auth&transport=otlp",
+ valid: true,
+ host: "collector.example.com",
+ port: 4318,
+ project: "backend",
+ env: "staging",
+ service: "auth",
+ tls: true,
transport: "otlp",
- baseURL: "https://collector.example.com:4318",
+ baseURL: "https://collector.example.com:4318",
+ },
+ {
+ name: "credentialed DSN with percent-encoded password",
+ input: "loza://key-id:s%40cret%3Avalue%2Fpart@collector.example.com/demo",
+ valid: true,
+ username: "key-id",
+ password: "s@cret:value/part",
+ host: "collector.example.com",
+ port: 443,
+ project: "demo",
+ env: "default",
+ tls: true,
+ transport: "http",
+ baseURL: "https://collector.example.com:443",
+ },
+ {
+ name: "public credential DSN",
+ input: "loza://lx_pub_6DJvd3D0izOaQx3n5BhKqN:@collector.example.com/public-collector?env=prod",
+ valid: true,
+ username: "lx_pub_6DJvd3D0izOaQx3n5BhKqN",
+ password: "",
+ host: "collector.example.com",
+ port: 443,
+ project: "public-collector",
+ env: "prod",
+ tls: true,
+ transport: "http",
+ baseURL: "https://collector.example.com:443",
+ eventsURL: "https://collector.example.com:443/collectors/public-collector/events",
+ batchURL: "https://collector.example.com:443/collectors/public-collector/events/batch",
+ otlpURL: "https://collector.example.com:443/collectors/public-collector/otlp/logs",
+ tailWSURL: "wss://collector.example.com:443/collectors/public-collector/tail",
+ },
+ {
+ name: "credentialed DSN with percent-encoded username",
+ input: "loza://key%2Did:secret@collector.example.com/demo?env=prod",
+ valid: true,
+ username: "key-id",
+ password: "secret",
+ host: "collector.example.com",
+ port: 443,
+ project: "demo",
+ env: "prod",
+ tls: true,
+ transport: "http",
+ baseURL: "https://collector.example.com:443",
+ },
+
+ // ── Invalid credential cases ─────────────────────────────────────────
+ {
+ name: "reject empty username",
+ input: "loza://:secret@collector.example.com/demo",
+ valid: false,
+ },
+ {
+ name: "reject empty password",
+ input: "loza://key:@collector.example.com/demo",
+ valid: false,
+ },
+ {
+ name: "reject userinfo without password",
+ input: "loza://key@collector.example.com/demo",
+ valid: false,
+ },
+ {
+ name: "reject username containing encoded colon",
+ input: "loza://key%3Aid:secret@collector.example.com/demo",
+ valid: false,
+ },
+ {
+ name: "reject username containing encoded whitespace",
+ input: "loza://key%20id:secret@collector.example.com/demo",
+ valid: false,
+ },
+ {
+ name: "reject unencoded reserved password character",
+ input: "loza://key:secret:part@collector.example.com/demo",
+ valid: false,
+ },
+ {
+ name: "reject malformed percent escape in credentials",
+ input: "loza://key:secret%ZZ@collector.example.com/demo",
+ valid: false,
},
// ── Invalid cases ────────────────────────────────────────────────────
+
{
name: "reject empty string",
input: "",
@@ -207,16 +295,6 @@ func TestParse(t *testing.T) {
input: "loza://collector.example.com/",
valid: false,
},
- {
- name: "reject userinfo (API key in URL)",
- input: "loza://key@collector.example.com/demo",
- valid: false,
- },
- {
- name: "reject userinfo with password",
- input: "loza://user:pass@collector.example.com/demo",
- valid: false,
- },
{
name: "reject invalid tls value",
input: "loza://collector.example.com/demo?tls=maybe",
@@ -271,6 +349,15 @@ func TestParse(t *testing.T) {
if dsn.Project != tt.project {
t.Errorf("Project = %q, want %q", dsn.Project, tt.project)
}
+ if dsn.CollectorName != tt.project {
+ t.Errorf("CollectorName = %q, want %q", dsn.CollectorName, tt.project)
+ }
+ if dsn.Username != tt.username {
+ t.Errorf("Username = %q, want %q", dsn.Username, tt.username)
+ }
+ if dsn.Password != tt.password {
+ t.Errorf("Password = %q, want %q", dsn.Password, tt.password)
+ }
if dsn.TLS != tt.tls {
t.Errorf("TLS = %v, want %v", dsn.TLS, tt.tls)
}
@@ -304,6 +391,43 @@ func TestParse(t *testing.T) {
}
}
+func TestParseCredentialErrorsDoNotEchoPassword(t *testing.T) {
+ const password = "super-secret"
+ _, err := Parse("loza://key:" + password + ":part@collector.example.com/demo")
+ if err == nil {
+ t.Fatal("expected an error for an unencoded reserved password character")
+ }
+ if strings.Contains(err.Error(), password) {
+ t.Fatalf("credential error echoed password: %q", err)
+ }
+}
+
+func TestParseStringRedactsCredentials(t *testing.T) {
+ dsn, err := Parse("loza://key-id:s%40cret%3Avalue@collector.example.com/demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, format := range []string{"%v", "%+v", "%#v"} {
+ rendered := fmt.Sprintf(format, dsn)
+ if strings.Contains(rendered, "s@cret:value") || strings.Contains(rendered, "key-id") {
+ t.Fatalf("format %q exposed credentials: %s", format, rendered)
+ }
+ }
+}
+
+func TestParsePublicCredentialRedactsBearerCapability(t *testing.T) {
+ const capability = "lx_pub_6DJvd3D0izOaQx3n5BhKqN"
+ dsn, err := Parse("loza://" + capability + ":@collector.example.com/demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, format := range []string{"%v", "%+v", "%#v"} {
+ if rendered := fmt.Sprintf(format, dsn); strings.Contains(rendered, capability) {
+ t.Fatalf("format %q leaked public capability: %q", format, rendered)
+ }
+ }
+}
+
func TestParseEnvDefault(t *testing.T) {
dsn, err := Parse("loza://localhost/demo?tls=false")
if err != nil {
diff --git a/spec/dsn/test-cases.json b/spec/dsn/test-cases.json
index ce526dd..ca4caee 100644
--- a/spec/dsn/test-cases.json
+++ b/spec/dsn/test-cases.json
@@ -17,10 +17,10 @@
"tls": false,
"transport": "http",
"baseURL": "http://localhost:9308",
- "eventsURL": "http://localhost:9308/events",
- "batchURL": "http://localhost:9308/events/batch",
- "otlpURL": "http://localhost:9308/otlp/logs",
- "tailWSURL": "ws://localhost:9308/tail"
+ "eventsURL": "http://localhost:9308/collectors/demo/events",
+ "batchURL": "http://localhost:9308/collectors/demo/events/batch",
+ "otlpURL": "http://localhost:9308/collectors/demo/otlp/logs",
+ "tailWSURL": "ws://localhost:9308/collectors/demo/tail"
}
},
{
@@ -37,10 +37,10 @@
"tls": false,
"transport": "http",
"baseURL": "http://localhost:9308",
- "eventsURL": "http://localhost:9308/events",
- "batchURL": "http://localhost:9308/events/batch",
- "otlpURL": "http://localhost:9308/otlp/logs",
- "tailWSURL": "ws://localhost:9308/tail"
+ "eventsURL": "http://localhost:9308/collectors/demo/events",
+ "batchURL": "http://localhost:9308/collectors/demo/events/batch",
+ "otlpURL": "http://localhost:9308/collectors/demo/otlp/logs",
+ "tailWSURL": "ws://localhost:9308/collectors/demo/tail"
}
},
{
@@ -57,10 +57,10 @@
"tls": true,
"transport": "http",
"baseURL": "https://collector.example.com:443",
- "eventsURL": "https://collector.example.com:443/events",
- "batchURL": "https://collector.example.com:443/events/batch",
- "otlpURL": "https://collector.example.com:443/otlp/logs",
- "tailWSURL": "wss://collector.example.com:443/tail"
+ "eventsURL": "https://collector.example.com:443/collectors/demo/events",
+ "batchURL": "https://collector.example.com:443/collectors/demo/events/batch",
+ "otlpURL": "https://collector.example.com:443/collectors/demo/otlp/logs",
+ "tailWSURL": "wss://collector.example.com:443/collectors/demo/tail"
}
},
{
@@ -93,7 +93,7 @@
"tls": true,
"transport": "otlp",
"baseURL": "https://collector.example.com:443",
- "otlpURL": "https://collector.example.com:443/otlp/logs"
+ "otlpURL": "https://collector.example.com:443/collectors/demo/otlp/logs"
}
},
{
@@ -208,6 +208,67 @@
"baseURL": "https://collector.example.com:4318"
}
},
+ {
+ "name": "credentialed DSN with percent-encoded password",
+ "input": "loza://key-id:s%40cret%3Avalue%2Fpart@collector.example.com/demo",
+ "valid": true,
+ "expected": {
+ "scheme": "loza",
+ "username": "key-id",
+ "password": "s@cret:value/part",
+ "host": "collector.example.com",
+ "port": 443,
+ "project": "demo",
+ "collectorName": "demo",
+ "env": "default",
+ "service": "",
+ "tls": true,
+ "transport": "http",
+ "baseURL": "https://collector.example.com:443"
+ }
+ },
+ {
+ "name": "credentialed DSN with percent-encoded username",
+ "input": "loza://key%2Did:secret@collector.example.com/demo?env=prod",
+ "valid": true,
+ "expected": {
+ "scheme": "loza",
+ "username": "key-id",
+ "password": "secret",
+ "host": "collector.example.com",
+ "port": 443,
+ "project": "demo",
+ "env": "prod",
+ "service": "",
+ "tls": true,
+ "transport": "http",
+ "baseURL": "https://collector.example.com:443",
+ "collectorName": "demo"
+ }
+ },
+ {
+ "name": "public credential DSN",
+ "input": "loza://lx_pub_6DJvd3D0izOaQx3n5BhKqN:@collector.example.com/public-collector?env=prod",
+ "valid": true,
+ "expected": {
+ "scheme": "loza",
+ "username": "lx_pub_6DJvd3D0izOaQx3n5BhKqN",
+ "password": "",
+ "host": "collector.example.com",
+ "port": 443,
+ "project": "public-collector",
+ "collectorName": "public-collector",
+ "env": "prod",
+ "service": "",
+ "tls": true,
+ "transport": "http",
+ "baseURL": "https://collector.example.com:443",
+ "eventsURL": "https://collector.example.com:443/collectors/public-collector/events",
+ "batchURL": "https://collector.example.com:443/collectors/public-collector/events/batch",
+ "otlpURL": "https://collector.example.com:443/collectors/public-collector/otlp/logs",
+ "tailWSURL": "wss://collector.example.com:443/collectors/public-collector/tail"
+ }
+ },
{
"name": "reject empty string",
"input": "",
@@ -251,14 +312,44 @@
"expected": null
},
{
- "name": "reject userinfo (API key in URL)",
+ "name": "reject empty username",
+ "input": "loza://:secret@collector.example.com/demo",
+ "valid": false,
+ "expected": null
+ },
+ {
+ "name": "reject empty password",
+ "input": "loza://key:@collector.example.com/demo",
+ "valid": false,
+ "expected": null
+ },
+ {
+ "name": "reject userinfo without password",
"input": "loza://key@collector.example.com/demo",
"valid": false,
"expected": null
},
{
- "name": "reject userinfo with password",
- "input": "loza://user:pass@collector.example.com/demo",
+ "name": "reject username containing encoded colon",
+ "input": "loza://key%3Aid:secret@collector.example.com/demo",
+ "valid": false,
+ "expected": null
+ },
+ {
+ "name": "reject username containing encoded whitespace",
+ "input": "loza://key%20id:secret@collector.example.com/demo",
+ "valid": false,
+ "expected": null
+ },
+ {
+ "name": "reject unencoded reserved password character",
+ "input": "loza://key:secret:part@collector.example.com/demo",
+ "valid": false,
+ "expected": null
+ },
+ {
+ "name": "reject malformed percent escape in credentials",
+ "input": "loza://key:secret%ZZ@collector.example.com/demo",
"valid": false,
"expected": null
},
diff --git a/spec/generated/conformance_manifest.json b/spec/generated/conformance_manifest.json
index dc6e5bb..bfe3d27 100644
--- a/spec/generated/conformance_manifest.json
+++ b/spec/generated/conformance_manifest.json
@@ -1,6 +1,6 @@
{
"version": "v1",
- "product_version": "0.2.6",
+ "product_version": "0.3.0",
"strict_schema": "../schema/event.strict.schema.json",
"loose_schema": "../schema/event.loose.schema.json",
"valid": [
diff --git a/spec/generated/contract/conformance_manifest.json b/spec/generated/contract/conformance_manifest.json
index dc6e5bb..bfe3d27 100644
--- a/spec/generated/contract/conformance_manifest.json
+++ b/spec/generated/contract/conformance_manifest.json
@@ -1,6 +1,6 @@
{
"version": "v1",
- "product_version": "0.2.6",
+ "product_version": "0.3.0",
"strict_schema": "../schema/event.strict.schema.json",
"loose_schema": "../schema/event.loose.schema.json",
"valid": [
diff --git a/spec/generated/contract/loza-contract.json b/spec/generated/contract/loza-contract.json
index 607c254..b6e827b 100644
--- a/spec/generated/contract/loza-contract.json
+++ b/spec/generated/contract/loza-contract.json
@@ -1,5 +1,5 @@
{
- "product_version": "0.2.6",
+ "product_version": "0.3.0",
"spec_version": "v1",
"api_version": "v1",
"event_version": "v1",
diff --git a/spec/generated/python/loza_contract.py b/spec/generated/python/loza_contract.py
index 7aa0b55..98bc2a4 100644
--- a/spec/generated/python/loza_contract.py
+++ b/spec/generated/python/loza_contract.py
@@ -6,7 +6,7 @@
from typing import Any, Iterable
CONTRACT = {
- "product_version": "0.2.6",
+ "product_version": "0.3.0",
"spec_version": "v1",
"api_version": "v1",
"event_version": "v1",
@@ -298,7 +298,7 @@
},
"fixtures": {
"version": "v1",
- "product_version": "0.2.6",
+ "product_version": "0.3.0",
"strict_schema": "../schema/event.strict.schema.json",
"loose_schema": "../schema/event.loose.schema.json",
"valid": [
diff --git a/spec/generated/rust/contract.rs b/spec/generated/rust/contract.rs
index 620d4e6..3351914 100644
--- a/spec/generated/rust/contract.rs
+++ b/spec/generated/rust/contract.rs
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
pub const CONTRACT_JSON: &str = r#"{
- \"product_version\": \"0.2.6\",
+ \"product_version\": \"0.3.0\",
\"spec_version\": \"v1\",
\"api_version\": \"v1\",
\"event_version\": \"v1\",
@@ -292,7 +292,7 @@ pub const CONTRACT_JSON: &str = r#"{
},
\"fixtures\": {
\"version\": \"v1\",
- \"product_version\": \"0.2.6\",
+ \"product_version\": \"0.3.0\",
\"strict_schema\": \"../schema/event.strict.schema.json\",
\"loose_schema\": \"../schema/event.loose.schema.json\",
\"valid\": [
diff --git a/spec/loza-spec.yaml b/spec/loza-spec.yaml
index 9453dae..fa88881 100644
--- a/spec/loza-spec.yaml
+++ b/spec/loza-spec.yaml
@@ -1,6 +1,6 @@
name: loza-spec
kind: spec
-version: 0.2.6
+version: 0.3.0
description: Shared LOZA wire, schema, event, and conformance contract
license: MIT
repository: https://github.com/Astraive/loza
diff --git a/tests/cross-sdk-equivalence/test_equivalence.py b/tests/cross-sdk-equivalence/test_equivalence.py
index 06b4e36..dbfdc90 100644
--- a/tests/cross-sdk-equivalence/test_equivalence.py
+++ b/tests/cross-sdk-equivalence/test_equivalence.py
@@ -18,11 +18,13 @@
"service": "equivalence-test",
"event": "test.cross-sdk",
"kind": "http",
- "outcome": "success",
+ "method": "GET",
+ "path": "/api/test",
+ "http": {
+ "status": 200,
+ },
+ "status_code": 200,
"attrs": {
- "http.method": "GET",
- "http.path": "/api/test",
- "http.status_code": 200,
"custom.label": "equivalence-check",
},
}
@@ -56,9 +58,11 @@ def emit_event_via_api(event: dict) -> dict | None:
def query_events(marker: str) -> list[dict]:
- """Query stored events from the collector using /query with SQL."""
- # Query the raw column for events matching the test marker
- sql = f"SELECT raw FROM events WHERE raw LIKE '%{marker}%' ORDER BY event_id DESC"
+ """Query projected event fields without relying on encrypted raw payloads."""
+ sql = (
+ "SELECT event_id, event_name, service, outcome, http_method, http_path, status_code "
+ f"FROM events WHERE event_id = '{marker}' ORDER BY event_id DESC"
+ )
payload = json.dumps({"query": sql}).encode("utf-8")
req = urllib.request.Request(
f"{COLLECTOR_URL}/query",
@@ -69,34 +73,23 @@ def query_events(marker: str) -> list[dict]:
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
- rows = data.get("rows", [])
- events = []
- for row in rows:
- raw = row.get("raw")
- if raw:
- try:
- events.append(json.loads(raw) if isinstance(raw, str) else raw)
- except json.JSONDecodeError:
- pass
- return events
+ return data.get("rows", [])
except (urllib.error.URLError, OSError) as e:
print(f" WARN: Query failed: {e}", file=sys.stderr)
return []
-def canonical_match(a: dict, b: dict) -> bool:
- """Check if two events match on canonical fields."""
- fields = ["service", "event", "kind", "outcome"]
- for f in fields:
- if a.get(f) != b.get(f):
- return False
- # Check attrs subset
- a_attrs = a.get("attrs", {})
- b_attrs = b.get("attrs", {})
- for key in CANONICAL_EVENT["attrs"]:
- if a_attrs.get(key) != b_attrs.get(key):
- return False
- return True
+def canonical_match(projected: dict, expected: dict) -> bool:
+ """Check canonical fields exposed by the Collector's encrypted schema."""
+ return (
+ projected.get("event_id") == expected.get("event_id")
+ and projected.get("event_name") == expected.get("event")
+ and projected.get("service") == expected.get("service")
+ and projected.get("outcome") == expected.get("outcome")
+ and projected.get("http_method") == expected.get("method")
+ and projected.get("http_path") == expected.get("path")
+ and projected.get("status_code") == expected.get("status_code")
+ )
def main():
@@ -115,6 +108,7 @@ def main():
# Emit the canonical event with a unique marker
marker = f"equiv-{int(time.time())}"
event = dict(CANONICAL_EVENT)
+ event["event_id"] = marker
event["attrs"] = dict(CANONICAL_EVENT["attrs"])
event["attrs"]["test.marker"] = marker
@@ -136,17 +130,8 @@ def main():
print("FAIL: No events found for marker={marker}")
sys.exit(1)
- # Find our event by marker
- found = None
- for ev in events:
- if ev.get("attrs", {}).get("test.marker") == marker:
- found = ev
- break
+ found = events[0]
- if found is None:
- print("FAIL: Could not find emitted event by marker")
- print(f" Queried {len(events)} event(s)")
- sys.exit(1)
print(" Found matching event. Verifying canonical fields...")
@@ -155,8 +140,8 @@ def main():
print("PASS: Canonical fields match between emitted and stored event")
else:
print("FAIL: Canonical fields do not match")
- print(f" Expected: {json.dumps({k: event[k] for k in ['service','event','kind','outcome']})}")
- print(f" Got: {json.dumps({k: found.get(k) for k in ['service','event','kind','outcome']})}")
+ print(f" Expected: {json.dumps({k: event.get(k) for k in ['event_id', 'service', 'event', 'outcome']})}")
+ print(f" Got: {json.dumps({k: found.get(k) for k in ['event_id', 'event_name', 'service', 'outcome']})}")
sys.exit(1)
# ── v0.2.0: Release field equivalence ──────────────────────────────────
@@ -168,8 +153,9 @@ def main():
print("1. Release field equivalence...")
release_event = dict(CANONICAL_EVENT)
release_event["attrs"] = dict(CANONICAL_EVENT["attrs"])
- release_event["release"] = "1.2.3"
release_marker = f"equiv-release-{int(time.time())}"
+ release_event["event_id"] = release_marker
+ release_event["release"] = "1.2.3"
release_event["attrs"]["test.marker"] = release_marker
result = emit_event_via_api(release_event)
@@ -178,25 +164,21 @@ def main():
else:
time.sleep(0.5)
events = query_events(release_marker)
- found_release = None
- for ev in events:
- if ev.get("attrs", {}).get("test.marker") == release_marker:
- found_release = ev
- break
- if found_release:
- rel = found_release.get("release")
- if rel == "1.2.3":
- print(" PASS: Release field preserved correctly")
- else:
- print(f" WARN: Release field mismatch: expected 1.2.3, got {rel}")
+ if events and canonical_match(events[0], release_event):
+ print(" PASS: Release event accepted and stored")
else:
- print(" WARN: Release field event not found")
+ print(" WARN: Release event was not found in projected storage")
# 2. Notice level equivalence
print("2. Notice level equivalence...")
notice_marker = f"equiv-notice-{int(time.time())}"
- notice_event = dict(CANONICAL_EVENT, event=f"test.notice.{int(time.time())}", level="notice",
- attrs=dict(CANONICAL_EVENT["attrs"], **{"test.marker": notice_marker}))
+ notice_event = dict(
+ CANONICAL_EVENT,
+ event=f"test.notice.{int(time.time())}",
+ event_id=notice_marker,
+ level="notice",
+ attrs=dict(CANONICAL_EVENT["attrs"], **{"test.marker": notice_marker}),
+ )
result = emit_event_via_api(notice_event)
if result:
print(" PASS: Notice-level event submitted")
@@ -207,8 +189,15 @@ def main():
print("3. Agent/ai kind equivalence...")
for kind in ("agent", "ai"):
kind_marker = f"equiv-kind-{kind}-{int(time.time())}"
- result = emit_event_via_api(dict(CANONICAL_EVENT, event=f"test.{kind}.{int(time.time())}", kind=kind,
- attrs=dict(CANONICAL_EVENT["attrs"], **{"test.marker": kind_marker})))
+ result = emit_event_via_api(
+ dict(
+ CANONICAL_EVENT,
+ event=f"test.{kind}.{int(time.time())}",
+ event_id=kind_marker,
+ kind=kind,
+ attrs=dict(CANONICAL_EVENT["attrs"], **{"test.marker": kind_marker}),
+ )
+ )
if result:
print(f" PASS: kind={kind} submitted")
else:
@@ -217,7 +206,7 @@ def main():
# 4. Domain helper equivalence (money, percent, httpStatus)
print("4. Domain helper equivalence...")
domain_marker = f"equiv-domain-{int(time.time())}"
- domain_event = dict(CANONICAL_EVENT, event=f"test.domain.{int(time.time())}")
+ domain_event = dict(CANONICAL_EVENT, event=f"test.domain.{int(time.time())}", event_id=domain_marker)
domain_event["attrs"] = {
**CANONICAL_EVENT["attrs"],
"test.marker": domain_marker,
@@ -229,15 +218,10 @@ def main():
if result:
time.sleep(0.5)
events = query_events(domain_marker)
- found_domain = None
- for ev in events:
- if ev.get("attrs", {}).get("test.marker") == domain_marker:
- found_domain = ev
- break
- if found_domain:
- print(" PASS: Domain helper fields preserved")
+ if events and canonical_match(events[0], domain_event):
+ print(" PASS: Domain helper fields preserved in projected storage")
else:
- print(" WARN: Domain event not found in query")
+ print(" WARN: Domain event was not found in projected storage")
else:
print(" WARN: Domain event emit failed")
diff --git a/tests/integration/verification_matrix.py b/tests/integration/verification_matrix.py
index 4dd1059..79a5ce1 100644
--- a/tests/integration/verification_matrix.py
+++ b/tests/integration/verification_matrix.py
@@ -311,15 +311,15 @@ def run_collector_smoke() -> list[StepResult]:
)
unauthorized_status, unauthorized_body = _http_request("GET", collector.base_url + "/status")
+ unauthorized_ok = (
+ unauthorized_status == 401
+ and unauthorized_body.strip() == '{"error":"unauthorized"}'
+ )
results.append(
StepResult(
id="collector.unauthenticated_request",
category="collector_runtime",
- status=(
- "implemented_and_passing"
- if unauthorized_status == 401 and unauthorized_body.strip() == '{"error":"unauthorized"}'
- else "implemented_and_failing"
- ),
+ status="implemented_and_passing" if unauthorized_ok else "implemented_and_failing",
duration_s=0.0,
details={"response": unauthorized_body},
)
@@ -499,17 +499,18 @@ def run_cortex_full_stack() -> list[StepResult]:
up.details["reason"] = "docker engine is unavailable"
results.append(up)
if up.status != "implemented_and_passing":
- results.append(
- _run(
- "cortex.compose.down",
- "cortex_runtime",
- ["docker", "compose", "-f", "configs/docker-compose.yml", "down", "--remove-orphans"],
- CORTEX_ROOT,
- env=compose_env,
- ok_returncodes=(0,),
- timeout_s=60,
+ if up.status != "environment_blocked":
+ results.append(
+ _run(
+ "cortex.compose.down",
+ "cortex_runtime",
+ ["docker", "compose", "-f", "configs/docker-compose.yml", "down", "--remove-orphans"],
+ CORTEX_ROOT,
+ env=compose_env,
+ ok_returncodes=(0,),
+ timeout_s=60,
+ )
)
- )
return results
try: