diff --git a/CLAUDE.md b/CLAUDE.md index db461abef..c33f24f4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,6 +143,7 @@ make lint # Run linter - `j/k` or `↑/↓` - Navigate rows - `Enter` - Drill down into selection - `Esc` or `Backspace` - Go back +- `m` - Cycle through Email, Texts, and Meetings modes - `Tab` - Cycle views (Senders → Sender Names → Recipients → Recipient Names → Domains → Labels → Lists → Time) - `s` - Cycle sort field (Name → Count → Size) - `r` - Reverse sort direction @@ -156,6 +157,7 @@ make lint # Run linter - `d` - Stage selected for deletion - `D` - Stage all messages matching current filter - `/` - Search +- `,` - Open Settings (keyboard-only) - `?` - Help - `q` - Quit diff --git a/api/openapi.yaml b/api/openapi.yaml index f9f223d7d..7575087e0 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1101,6 +1101,19 @@ components: - enabled - books type: object + CardDAVAddressBookIdentityResponse: + additionalProperties: true + properties: + id: + format: int64 + minimum: 1 + type: integer + name: + type: string + required: + - id + - name + type: object CardDAVBookResponse: additionalProperties: true properties: @@ -1157,68 +1170,120 @@ components: CardDAVConflictDetailResponse: additionalProperties: true properties: - address_book_id: - format: int64 - type: integer - href: + address_book: + $ref: "#/components/schemas/CardDAVAddressBookIdentityResponse" + allowed_resolutions: + items: + enum: + - keep_local + - keep_remote + type: string + type: array + base: + $ref: "#/components/schemas/CardDAVContactSummaryResponse" + created_at: + format: date-time type: string id: format: int64 + minimum: 1 type: integer - local_tombstone: - type: boolean - local_vcard: + local: + $ref: "#/components/schemas/CardDAVContactSummaryResponse" + remote: + $ref: "#/components/schemas/CardDAVContactSummaryResponse" + resolution: + enum: + - keep_local + - keep_remote type: string - remote_tombstone: - type: boolean - remote_vcard: + resolved_at: + format: date-time type: string status: + enum: + - unresolved + - resolved + type: string + updated_at: + format: date-time type: string required: - id - - address_book_id - - href - - local_tombstone - - remote_tombstone + - address_book - status + - base + - local + - remote + - allowed_resolutions + - created_at + - updated_at type: object CardDAVConflictResolutionResponse: additionalProperties: true properties: id: format: int64 + minimum: 1 type: integer + resolution: + enum: + - keep_local + - keep_remote + type: string status: + enum: + - resolved type: string required: - id - status + - resolution type: object CardDAVConflictResponse: additionalProperties: true properties: - address_book_id: - format: int64 - type: integer - href: - type: string + address_book: + $ref: "#/components/schemas/CardDAVAddressBookIdentityResponse" + allowed_resolutions: + items: + enum: + - keep_local + - keep_remote + type: string + type: array id: format: int64 + minimum: 1 type: integer - local_tombstone: - type: boolean - remote_tombstone: - type: boolean + local_state: + enum: + - present + - deleted + - unavailable + type: string + remote_state: + enum: + - present + - deleted + - unavailable + type: string status: + enum: + - unresolved + - resolved + type: string + updated_at: + format: date-time type: string required: - id - - address_book_id - - href - - local_tombstone - - remote_tombstone + - address_book - status + - local_state + - remote_state + - allowed_resolutions + - updated_at type: object CardDAVConflictsResponse: additionalProperties: true @@ -1226,26 +1291,67 @@ components: conflicts: items: $ref: "#/components/schemas/CardDAVConflictResponse" - type: - - array - - "null" + type: array required: - conflicts type: object + CardDAVContactSummaryResponse: + additionalProperties: true + properties: + display_name: + type: string + emails: + items: + type: string + type: array + phones: + items: + type: string + type: array + state: + enum: + - present + - deleted + - unavailable + type: string + truncated: + type: boolean + required: + - state + - emails + - phones + type: object CardDAVPublicationResponse: additionalProperties: true properties: + address_book: + $ref: "#/components/schemas/CardDAVAddressBookIdentityResponse" + conflict_id: + format: int64 + minimum: 1 + type: integer desired: type: boolean - href: - type: string pending_operation: + enum: + - create + - update + - delete type: string person_id: format: int64 + minimum: 1 type: integer + state: + enum: + - unpublished + - published + - pending + - conflict + type: string required: - person_id + - state - desired type: object CardDAVResolveRequest: @@ -1259,6 +1365,135 @@ components: required: - choice type: object + CardDAVRunResponse: + additionalProperties: true + properties: + books: + format: int64 + type: integer + created: + format: int64 + type: integer + error_code: + enum: + - cancelled + - retry_after + - authentication_failed + - upstream_failed + - safety_limit + - sync_failed + - unsafe_error_redacted + - daemon_restarted + type: string + error_message: + type: string + finished_at: + format: date-time + type: string + full: + type: boolean + id: + format: int64 + type: integer + removed: + format: int64 + type: integer + started_at: + format: date-time + type: string + state: + enum: + - running + - succeeded + - failed + - cancelled + - partial + type: string + trigger: + enum: + - manual + - scheduled + type: string + updated: + format: int64 + type: integer + required: + - id + - trigger + - full + - state + - started_at + - books + - created + - updated + - removed + type: object + CardDAVRunsResponse: + additionalProperties: true + properties: + next_before_id: + format: int64 + type: integer + runs: + items: + $ref: "#/components/schemas/CardDAVRunResponse" + type: array + required: + - runs + type: object + CardDAVStatusAccount: + additionalProperties: true + properties: + base_url: + type: string + username: + type: string + required: + - base_url + - username + type: object + CardDAVStatusResponse: + additionalProperties: true + properties: + account: + $ref: "#/components/schemas/CardDAVStatusAccount" + active: + $ref: "#/components/schemas/CardDAVRunResponse" + available: + type: boolean + configured: + type: boolean + credential_configured: + type: boolean + enabled: + type: boolean + latest: + $ref: "#/components/schemas/CardDAVRunResponse" + latest_successful: + $ref: "#/components/schemas/CardDAVRunResponse" + next_scheduled_at: + format: date-time + type: string + repair_reason: + enum: + - account_missing + - credential_missing + - credential_mismatch + - credential_unavailable + - runtime_unavailable + type: string + schedule: + type: string + scheduled: + type: boolean + required: + - configured + - available + - credential_configured + - enabled + - scheduled + - schedule + type: object CardDAVSyncRequest: additionalProperties: false properties: @@ -2443,6 +2678,51 @@ components: - source_identifier - source_message_id type: object + DirectoryPeopleResponse: + additionalProperties: true + properties: + next_cursor: + type: string + people: + items: + $ref: "#/components/schemas/DirectoryPersonSummary" + type: array + required: + - people + type: object + DirectoryPersonSummary: + additionalProperties: true + properties: + categories: + items: + type: string + type: array + contact_state: + type: string + display_name: + type: string + id: + format: int64 + type: integer + last_contact_at: + format: date-time + type: string + organizations: + items: + type: string + type: array + primary_channel: + type: string + revision: + format: int64 + type: integer + required: + - id + - revision + - contact_state + - categories + - organizations + type: object DiscoverError: additionalProperties: true properties: @@ -5319,43 +5599,398 @@ components: required: - name type: object - OperationHealth: + NetworkEdge: additionalProperties: true properties: - busy: - type: boolean + end_date: + type: string + id: + type: string + kind: + enum: + - relationship + - employment + type: string label: type: string - started_at: - format: date-time + relationship_type_slug: + type: string + source_node_id: + type: string + start_date: + type: string + target_node_id: type: string required: - - busy + - id + - kind + - source_node_id + - target_node_id + - label type: object - Organization: + NetworkNode: additionalProperties: true properties: - created_at: - format: date-time - type: string - description: - type: string - id: + entity_id: format: int64 type: integer - kind: - type: string - merged_into_id: + hop: format: int64 type: integer - name: + id: type: string - primary_domain: + kind: + enum: + - person + - organization type: string - retired_at: - format: date-time + label: type: string - revision: + required: + - id + - kind + - entity_id + - label + - hop + type: object + OperationHealth: + additionalProperties: true + properties: + busy: + type: boolean + label: + type: string + started_at: + format: date-time + type: string + required: + - busy + type: object + OperationLaneStatus: + additionalProperties: true + properties: + active: + $ref: "#/components/schemas/OperationRunSummary" + configured: + type: boolean + history_availability: + enum: + - available + - unavailable + type: string + kind: + enum: + - carddav_sync + - document_embedding + - document_extraction + - message_embedding + - person_embedding + - person_enrichment + - person_sweep + - source_sync + - visual_embedding + type: string + lane: + enum: + - contacts + - documents + - messages + - person_facts + - visual_attachments + type: string + latest: + $ref: "#/components/schemas/OperationRunSummary" + latest_successful: + $ref: "#/components/schemas/OperationRunSummary" + related_status: + enum: + - listSourceStatus + - getDocumentIndexStatus + - getDocumentVectorStatus + - getVisualAttachmentStatus + - getCardDAVStatus + type: string + supported_actions: + items: + enum: + - carddav_sync + - visual_build + - visual_resume + type: string + type: array + unavailable_code: + type: string + required: + - kind + - lane + - configured + - history_availability + - supported_actions + type: object + OperationPublicCounter: + additionalProperties: true + properties: + name: + enum: + - processed + - added + - updated + - item_errors + - attempted + - succeeded + - failed + - projected_writes + - books + - created + - removed + type: string + unit: + enum: + - messages + - people + - writes + - books + - contacts + type: string + value: + format: int64 + type: integer + required: + - name + - unit + - value + type: object + OperationPublicError: + additionalProperties: true + properties: + code: + enum: + - source_sync_failed + - person_sweep_failed + - policy + - budget + - lease_lost + - rate_limited + - timeout + - provider_http + - invalid_output + - archive_gap + - internal + - cancelled + - retry_after + - authentication_failed + - upstream_failed + - safety_limit + - sync_failed + - unsafe_error_redacted + - daemon_restarted + - carddav_sync_failed + type: string + message: + type: string + required: + - code + - message + type: object + OperationRunDetail: + additionalProperties: true + properties: + counters: + items: + $ref: "#/components/schemas/OperationPublicCounter" + type: array + error: + $ref: "#/components/schemas/OperationPublicError" + finished_at: + format: date-time + type: string + id: + type: string + kind: + enum: + - carddav_sync + - document_embedding + - document_extraction + - message_embedding + - person_embedding + - person_enrichment + - person_sweep + - source_sync + - visual_embedding + type: string + lane: + enum: + - contacts + - documents + - messages + - person_facts + - visual_attachments + type: string + started_at: + format: date-time + type: string + state: + enum: + - cancelled + - failed + - partial + - queued + - running + - succeeded + type: string + trigger: + enum: + - manual + - scheduled + type: string + required: + - id + - kind + - lane + - state + - started_at + - counters + type: object + OperationRunSummary: + additionalProperties: true + properties: + counters: + items: + $ref: "#/components/schemas/OperationPublicCounter" + type: array + error: + $ref: "#/components/schemas/OperationPublicError" + finished_at: + format: date-time + type: string + id: + type: string + kind: + enum: + - carddav_sync + - document_embedding + - document_extraction + - message_embedding + - person_embedding + - person_enrichment + - person_sweep + - source_sync + - visual_embedding + type: string + lane: + enum: + - contacts + - documents + - messages + - person_facts + - visual_attachments + type: string + started_at: + format: date-time + type: string + state: + enum: + - cancelled + - failed + - partial + - queued + - running + - succeeded + type: string + trigger: + enum: + - manual + - scheduled + type: string + required: + - id + - kind + - lane + - state + - started_at + - counters + type: object + OperationRunsResponse: + additionalProperties: true + properties: + next_cursor: + type: string + runs: + items: + $ref: "#/components/schemas/OperationRunSummary" + type: array + unavailable_kinds: + items: + $ref: "#/components/schemas/OperationUnavailableKind" + type: array + required: + - runs + - unavailable_kinds + type: object + OperationStatusResponse: + additionalProperties: true + properties: + lanes: + items: + $ref: "#/components/schemas/OperationLaneStatus" + type: array + required: + - lanes + type: object + OperationUnavailableKind: + additionalProperties: true + properties: + kind: + enum: + - carddav_sync + - document_embedding + - document_extraction + - message_embedding + - person_embedding + - person_enrichment + - person_sweep + - source_sync + - visual_embedding + type: string + lane: + enum: + - contacts + - documents + - messages + - person_facts + - visual_attachments + type: string + unavailable_code: + type: string + required: + - kind + - lane + - unavailable_code + type: object + Organization: + additionalProperties: true + properties: + created_at: + format: date-time + type: string + description: + type: string + id: + format: int64 + type: integer + kind: + type: string + merged_into_id: + format: int64 + type: integer + name: + type: string + primary_domain: + type: string + retired_at: + format: date-time + type: string + revision: format: int64 type: integer updated_at: @@ -5503,6 +6138,10 @@ components: type: - string - "null" + source_resource_uid: + type: + - string + - "null" street_address: type: - string @@ -5707,6 +6346,10 @@ components: type: - string - "null" + source_resource_uid: + type: + - string + - "null" type_label: type: - string @@ -5842,6 +6485,10 @@ components: type: - string - "null" + source_resource_uid: + type: + - string + - "null" type_label: type: - string @@ -5976,6 +6623,10 @@ components: type: - string - "null" + source_resource_uid: + type: + - string + - "null" type_label: type: - string @@ -6099,6 +6750,10 @@ components: type: - string - "null" + source_resource_uid: + type: + - string + - "null" type_label: type: - string @@ -6206,6 +6861,10 @@ components: type: - string - "null" + source_resource_uid: + type: + - string + - "null" type_label: type: - string @@ -7136,6 +7795,158 @@ components: - days - total_count type: object + PersonEnrichmentProviderSetting: + additionalProperties: true + properties: + allow_sensitive_targets: + type: boolean + allowed_identifiers: + items: + type: string + type: + - array + - "null" + credential: + $ref: "#/components/schemas/SecretSettingState" + credential_id: + type: string + enabled: + type: boolean + endpoint: + type: string + kind: + enum: + - exa + - sixtyfour + type: string + max_job_age: + type: string + max_requests_per_day: + format: int64 + type: integer + max_requests_per_run: + format: int64 + type: integer + max_retries: + format: int64 + type: integer + mode: + type: string + name: + type: string + num_results: + format: int64 + type: integer + poll_endpoint: + type: string + poll_interval: + type: string + refresh_interval: + type: string + request_timeout: + type: string + retention_posture: + type: string + target_keys: + items: + type: string + type: + - array + - "null" + tier: + type: string + training_posture: + type: string + required: + - name + - kind + - enabled + - endpoint + - allowed_identifiers + - target_keys + - allow_sensitive_targets + - retention_posture + - training_posture + - refresh_interval + - request_timeout + - poll_interval + - max_job_age + - max_retries + - max_requests_per_run + - max_requests_per_day + - credential_id + type: object + PersonEnrichmentProviderUpdate: + additionalProperties: false + properties: + allow_sensitive_targets: + type: boolean + allowed_identifiers: + items: + type: string + type: + - array + - "null" + enabled: + type: boolean + endpoint: + type: string + kind: + enum: + - exa + - sixtyfour + type: string + max_job_age: + type: string + max_requests_per_day: + format: int64 + type: integer + max_requests_per_run: + format: int64 + type: integer + max_retries: + format: int64 + type: integer + mode: + type: string + num_results: + format: int64 + type: integer + poll_endpoint: + type: string + poll_interval: + type: string + refresh_interval: + type: string + request_timeout: + type: string + retention_posture: + type: string + target_keys: + items: + type: string + type: + - array + - "null" + tier: + type: string + training_posture: + type: string + required: + - kind + - enabled + - endpoint + - allowed_identifiers + - target_keys + - allow_sensitive_targets + - retention_posture + - training_posture + - refresh_interval + - request_timeout + - max_retries + - max_requests_per_run + - max_requests_per_day + type: object PersonFactClaim: additionalProperties: true properties: @@ -8275,6 +9086,36 @@ components: - array - "null" type: object + PersonNetwork: + additionalProperties: true + properties: + depth: + format: int64 + type: integer + edges: + items: + $ref: "#/components/schemas/NetworkEdge" + type: + - array + - "null" + nodes: + items: + $ref: "#/components/schemas/NetworkNode" + type: + - array + - "null" + root_person_id: + format: int64 + type: integer + truncated: + type: boolean + required: + - root_person_id + - depth + - truncated + - nodes + - edges + type: object PersonProfile: additionalProperties: true properties: @@ -8775,6 +9616,29 @@ components: - roles - directions type: object + ProviderCredentialResponse: + additionalProperties: true + properties: + credential_id: + type: string + pending_restart: + type: boolean + state: + $ref: "#/components/schemas/SecretSettingState" + required: + - credential_id + - state + - pending_restart + type: object + ProviderCredentialWriteRequest: + additionalProperties: false + properties: + value: + minLength: 1 + type: string + required: + - value + type: object ProviderUsage: additionalProperties: true properties: @@ -9594,6 +10458,12 @@ components: properties: configured: type: boolean + source: + enum: + - stored + - environment + - none + type: string required: - configured type: object @@ -9756,15 +10626,27 @@ components: Setting: additionalProperties: true properties: + credential_id: + type: string + description: + type: string group: enum: - browser - server - archive + - sync + - logging - search - sources + - attachments + - activity + - backup + - enrichment - integrations type: string + inherited: + type: boolean key: type: string kind: @@ -9776,6 +10658,8 @@ components: - string_array - secret type: string + label: + type: string options: items: type: string @@ -9790,14 +10674,32 @@ components: $ref: "#/components/schemas/SecretSettingState" testable: type: boolean + validation: + $ref: "#/components/schemas/SettingValidation" value: $ref: "#/components/schemas/SettingValue" required: - key - group + - label + - description - kind - restart_required type: object + SettingGroup: + additionalProperties: true + properties: + description: + type: string + id: + type: string + label: + type: string + required: + - id + - label + - description + type: object SettingUpdate: additionalProperties: false properties: @@ -9810,6 +10712,20 @@ components: required: - key type: object + SettingValidation: + additionalProperties: true + properties: + hint: + type: string + maximum: + format: double + type: number + minimum: + format: double + type: number + required: + type: boolean + type: object SettingValue: oneOf: - additionalProperties: false @@ -9854,8 +10770,6 @@ components: SettingsPatchRequest: additionalProperties: false properties: - confirm_api_key_restart: - type: boolean updates: items: $ref: "#/components/schemas/SettingUpdate" @@ -9867,14 +10781,28 @@ components: SettingsResponse: additionalProperties: true properties: + credential_etag: + type: string + groups: + items: + $ref: "#/components/schemas/SettingGroup" + type: array pending_restart: type: boolean + person_enrichment_providers: + items: + $ref: "#/components/schemas/PersonEnrichmentProviderSetting" + type: + - array + - "null" settings: items: $ref: "#/components/schemas/Setting" type: array required: + - groups - settings + - credential_etag - pending_restart type: object SimilarSearchResponse: @@ -12701,6 +13629,101 @@ paths: summary: Publish a person to CardDAV tags: - API + /api/v1/carddav/runs: + get: + operationId: listCardDAVRuns + parameters: + - description: Maximum runs to return (default 25, max 100) + in: query + name: limit + schema: + format: int64 + maximum: 100 + minimum: 1 + type: integer + - description: Return runs with IDs lower than this cursor + in: query + name: before_id + schema: + format: int64 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CardDAVRunsResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List CardDAV synchronization runs + tags: + - API + /api/v1/carddav/status: + get: + operationId: getCardDAVStatus + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CardDAVStatusResponse" + description: OK + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + headers: + Retry-After: + description: Seconds until CardDAV retry is safe + schema: + format: int64 + minimum: 0 + type: integer + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get CardDAV synchronization status + tags: + - API /api/v1/carddav/sync: post: operationId: syncCardDAV @@ -17004,10 +18027,198 @@ paths: schema: format: int64 type: integer - - description: External task ID - in: path - name: task_id - required: true + - description: External task ID + in: path + name: task_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TaskLinkMutationResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Unlink a task from an archived email + tags: + - API + /api/v1/multimodal/build: + post: + operationId: startVisualAttachmentBuild + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/VisualBuildRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Consent and run one bounded visual attachment embedding pass + tags: + - API + /api/v1/multimodal/retire: + post: + operationId: retireVisualAttachmentGeneration + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/VisualRetireRequest" + required: true + responses: + "204": + description: No Content + default: + description: Error + security: + - apiKey: [] + summary: Retire the visual attachment generation + tags: + - Search + /api/v1/multimodal/retry: + post: + operationId: retryVisualAttachmentOwner + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/VisualRetryRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Retry one visual attachment owner + tags: + - API + /api/v1/multimodal/run: + post: + operationId: resumeVisualAttachmentBuild + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Resume one bounded visual attachment embedding pass + tags: + - API + /api/v1/multimodal/status: + get: + operationId: getVisualAttachmentStatus + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get visual attachment embedding status + tags: + - API + /api/v1/operations/runs: + get: + operationId: listOperationRuns + parameters: + - description: Exact operation kind + in: query + name: kind + schema: + enum: + - carddav_sync + - document_embedding + - document_extraction + - message_embedding + - person_embedding + - person_enrichment + - person_sweep + - source_sync + - visual_embedding + type: string + - description: Exact semantic operation lane + in: query + name: lane + schema: + enum: + - contacts + - documents + - messages + - person_facts + - visual_attachments + type: string + - description: Exact operation state + in: query + name: state + schema: + enum: + - cancelled + - failed + - partial + - queued + - running + - succeeded + type: string + - description: Maximum runs to return (default 25, max 100) + in: query + name: limit + schema: + format: int64 + maximum: 100 + minimum: 1 + type: integer + - description: Opaque cursor bound to this archive and the exact kind, lane, and state filters + in: query + name: cursor schema: type: string responses: @@ -17015,102 +18226,78 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TaskLinkMutationResponse" + $ref: "#/components/schemas/OperationRunsResponse" description: OK - default: + "400": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - security: - - apiKey: [] - summary: Unlink a task from an archived email - tags: - - API - /api/v1/multimodal/build: - post: - operationId: startVisualAttachmentBuild - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/VisualBuildRequest" - required: true - responses: - "200": + "500": content: application/json: schema: - $ref: "#/components/schemas/Status" - description: OK - default: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - security: - - apiKey: [] - summary: Consent and run one bounded visual attachment embedding pass - tags: - - API - /api/v1/multimodal/retire: - post: - operationId: retireVisualAttachmentGeneration - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/VisualRetireRequest" - required: true - responses: - "204": - description: No Content default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" description: Error security: - apiKey: [] - summary: Retire the visual attachment generation + summary: List normalized operation history tags: - - Search - /api/v1/multimodal/retry: - post: - operationId: retryVisualAttachmentOwner - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/VisualRetryRequest" - required: true + - API + /api/v1/operations/runs/{id}: + get: + operationId: getOperationRun + parameters: + - description: Opaque archive-bound operation run ID + in: path + name: id + required: true + schema: + type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/Status" + $ref: "#/components/schemas/OperationRunDetail" description: OK - default: + "400": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - security: - - apiKey: [] - summary: Retry one visual attachment owner - tags: - - API - /api/v1/multimodal/run: - post: - operationId: resumeVisualAttachmentBuild - responses: - "200": + "404": content: application/json: schema: - $ref: "#/components/schemas/Status" - description: OK + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error default: content: application/json: @@ -17119,18 +18306,18 @@ paths: description: Error security: - apiKey: [] - summary: Resume one bounded visual attachment embedding pass + summary: Get one normalized operation run tags: - API - /api/v1/multimodal/status: + /api/v1/operations/status: get: - operationId: getVisualAttachmentStatus + operationId: getOperationStatus responses: "200": content: application/json: schema: - $ref: "#/components/schemas/Status" + $ref: "#/components/schemas/OperationStatusResponse" description: OK default: content: @@ -17140,7 +18327,7 @@ paths: description: Error security: - apiKey: [] - summary: Get visual attachment embedding status + summary: Get normalized operation lane status tags: - API /api/v1/organizations: @@ -18401,6 +19588,98 @@ paths: summary: Promote a participant cluster to a durable person tags: - API + /api/v1/people/directory: + get: + description: Returns one stable, non-sensitive page of promoted durable people. + operationId: listDirectoryPeople + parameters: + - description: Lexical query over person names, contact points, and organizations + in: query + name: q + schema: + type: string + - description: Opaque cursor returned by the previous Directory page + in: query + name: cursor + schema: + type: string + - description: Maximum rows to return (default 50, max 100) + in: query + name: limit + schema: + format: int64 + type: integer + - description: "Current contact state: active or inactive" + in: query + name: contact_state + schema: + type: string + - description: Current person category + in: query + name: category + schema: + type: string + - description: Current organization + in: query + name: organization + schema: + type: string + - description: Primary communication channel + in: query + name: primary_channel + schema: + type: string + - description: Return people contacted at or after this RFC3339 timestamp + in: query + name: last_contact_after + schema: + format: date-time + type: string + - description: Return people contacted at or before this RFC3339 timestamp + in: query + name: last_contact_before + schema: + format: date-time + type: string + - description: "Directory order: name, last_contact_desc, or last_contact_asc" + in: query + name: sort + schema: + enum: + - name + - last_contact_desc + - last_contact_asc + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DirectoryPeopleResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Query durable people for the Directory + tags: + - API /api/v1/people/search: post: description: Searches only the curated person vector corpus and returns durable person roots in relevance order. @@ -19641,37 +20920,96 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/PersonMergeResult" + $ref: "#/components/schemas/PersonMergeResult" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Merge one durable person profile into another + tags: + - API + /api/v1/people/{id}/merges: + get: + operationId: listPersonMerges + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Maximum results + in: query + name: limit + schema: + format: int64 + type: integer + - description: Results to skip + in: query + name: offset + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonMergesResponse" description: OK - headers: - ETag: - description: Strong person profile revision tag for optimistic concurrency - schema: - type: string - "400": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error "404": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "409": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "428": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error "500": content: application/json: @@ -19692,12 +21030,13 @@ paths: description: Error security: - apiKey: [] - summary: Merge one durable person profile into another + summary: List merge history for a durable person tags: - API - /api/v1/people/{id}/merges: + /api/v1/people/{id}/network: get: - operationId: listPersonMerges + description: Returns declared person relationships and employments only; archive-derived associations are excluded. + operationId: getPersonNetwork parameters: - description: Durable person ID in: path @@ -19706,32 +21045,34 @@ paths: schema: format: int64 type: integer - - description: Maximum results + - description: Breadth-first depth (default 1, minimum 1, maximum 3) in: query - name: limit + name: depth schema: + default: 1 format: int64 + maximum: 3 + minimum: 1 type: integer - - description: Results to skip + - description: Include ended relationships and employment records in: query - name: offset + name: include_ended schema: - format: int64 - type: integer + type: boolean responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PersonMergesResponse" + $ref: "#/components/schemas/PersonNetwork" description: OK - "404": + "400": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "500": + "404": content: application/json: schema: @@ -19751,7 +21092,7 @@ paths: description: Error security: - apiKey: [] - summary: List merge history for a durable person + summary: Get a bounded curated person network tags: - API /api/v1/people/{id}/notes/append: @@ -22245,6 +23586,10 @@ paths: $ref: "#/components/schemas/SettingsResponse" description: OK headers: + Credential-ETag: + description: Strong content hash for the independent provider credential store + schema: + type: string ETag: description: Strong content hash for optimistic concurrency schema: @@ -22283,6 +23628,10 @@ paths: $ref: "#/components/schemas/SettingsResponse" description: OK headers: + Credential-ETag: + description: Strong content hash for the independent provider credential store + schema: + type: string ETag: description: Strong content hash for optimistic concurrency schema: @@ -22328,6 +23677,227 @@ paths: summary: Update browser-managed settings tags: - API + /api/v1/settings/person-enrichment/providers/{name}: + put: + operationId: putSettingsPersonEnrichmentProvider + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PersonEnrichmentProviderUpdate" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/SettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Create or update one named person-enrichment provider + tags: + - API + /api/v1/settings/provider-credentials/{credential_id}: + delete: + operationId: deleteSettingsProviderCredential + parameters: + - in: path + name: credential_id + required: true + schema: + type: string + - description: Strong ETag for the provider credential store + in: header + name: If-Match + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderCredentialResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Clear a stored provider credential + tags: + - API + put: + operationId: putSettingsProviderCredential + parameters: + - in: path + name: credential_id + required: true + schema: + type: string + - description: Strong ETag for the provider credential store + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderCredentialWriteRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderCredentialResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Set a write-only provider credential + tags: + - API /api/v1/sources/status: get: operationId: listSourceStatus diff --git a/cmd/msgvault/cmd/carddav.go b/cmd/msgvault/cmd/carddav.go index eefeb8a93..ee664cd74 100644 --- a/cmd/msgvault/cmd/carddav.go +++ b/cmd/msgvault/cmd/carddav.go @@ -124,7 +124,7 @@ func newCardDAVCmd() *cobra.Command { books.AddCommand(setRole) conflicts := &cobra.Command{Use: "conflicts", Short: "Inspect and resolve CardDAV conflicts"} conflicts.AddCommand(&cobra.Command{Use: cmdUseList, Short: "List unresolved CardDAV conflicts", Args: cobra.NoArgs, RunE: runCardDAVConflicts}) - conflicts.AddCommand(&cobra.Command{Use: "show ", Short: "Show retained local and remote versions of a CardDAV conflict", Args: cobra.ExactArgs(1), RunE: runCardDAVConflictShow}) + conflicts.AddCommand(&cobra.Command{Use: "show ", Short: "Show safe base, local, and remote summaries for a CardDAV conflict", Args: cobra.ExactArgs(1), RunE: runCardDAVConflictShow}) resolve := &cobra.Command{Use: "resolve ", Short: "Resolve one CardDAV conflict", Args: cobra.ExactArgs(2), RunE: runCardDAVResolve} conflicts.AddCommand(resolve) root.AddCommand(books, conflicts) diff --git a/cmd/msgvault/cmd/carddav_test.go b/cmd/msgvault/cmd/carddav_test.go index 25c0f60b5..36b2752e1 100644 --- a/cmd/msgvault/cmd/carddav_test.go +++ b/cmd/msgvault/cmd/carddav_test.go @@ -36,6 +36,7 @@ func TestCardDAVCommandsExposeSafeOperatorSurface(t *testing.T) { show, _, err := root.Find([]string{"conflicts", "show"}) require.NoError(err) assert.Equal("show", show.Name()) + assert.Equal("Show safe base, local, and remote summaries for a CardDAV conflict", show.Short) } func TestCardDAVCLIProductionRoutes(t *testing.T) { @@ -65,16 +66,16 @@ func TestCardDAVCLIProductionRoutes(t *testing.T) { case "GET /api/v1/carddav/conflicts": _, _ = w.Write([]byte(`{"conflicts":[]}`)) case "GET /api/v1/carddav/conflicts/7": - _, _ = w.Write([]byte(`{"id":7,"address_book_id":9,"href":"/alice.vcf","local_tombstone":false,"local_vcard":"BEGIN:VCARD\\r\\nFN:Local Alice\\r\\nEND:VCARD\\r\\n","remote_tombstone":false,"remote_vcard":"BEGIN:VCARD\\r\\nFN:Remote Alice\\r\\nEND:VCARD\\r\\n","status":"open"}`)) + _, _ = w.Write([]byte(`{"id":7,"address_book":{"id":9,"name":"Personal"},"status":"unresolved","base":{"state":"unavailable","emails":[],"phones":[]},"local":{"state":"present","display_name":"Local Alice","emails":["local@example.test"],"phones":[]},"remote":{"state":"present","display_name":"Remote Alice","emails":[],"phones":["+12025550123"]},"allowed_resolutions":["keep_local","keep_remote"],"created_at":"2026-08-28T09:10:11Z","updated_at":"2026-08-28T10:11:12Z"}`)) case "POST /api/v1/carddav/conflicts/7/resolve": var body map[string]string assert.NoError(json.NewDecoder(r.Body).Decode(&body)) assert.Equal("keep_remote", body["choice"]) - _, _ = w.Write([]byte(`{"id":7,"address_book_id":9,"href":"/alice.vcf","local_tombstone":false,"remote_tombstone":false,"status":"resolved"}`)) + _, _ = w.Write([]byte(`{"id":7,"status":"resolved","resolution":"keep_remote"}`)) case "POST /api/v1/carddav/publications/11": - _, _ = w.Write([]byte(`{"person_id":11,"desired":true}`)) + _, _ = w.Write([]byte(`{"person_id":11,"state":"published","desired":true,"address_book":{"id":9,"name":"Personal"}}`)) case "DELETE /api/v1/carddav/publications/11": - _, _ = w.Write([]byte(`{"person_id":11,"desired":false}`)) + _, _ = w.Write([]byte(`{"person_id":11,"state":"unpublished","desired":false,"address_book":{"id":9,"name":"Personal"}}`)) default: http.NotFound(w, r) } @@ -128,19 +129,30 @@ func TestCardDAVCLIProductionRoutes(t *testing.T) { assert.NotContains(strings.Join(requests, "\n"), "synthetic-password") } -func TestCardDAVConflictShowPrintsSnapshotsAsSafeJSON(t *testing.T) { +func TestCardDAVConflictShowPrintsSafeSummariesWithoutRawVCardFields(t *testing.T) { assert := assert.New(t) require := require.New(t) - localVCard := "BEGIN:VCARD\r\nFN:\x1b[31mLocal Alice\x1b[0m\r\nEND:VCARD\r\n" - remoteVCard := "BEGIN:VCARD\r\nFN:Remote Alice\r\nEND:VCARD\r\n" + const localRawMarker = "synthetic-local-raw-card" + const remoteRawMarker = "synthetic-remote-raw-card" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(http.MethodGet, r.Method) assert.Equal("/api/v1/carddav/conflicts/7", r.URL.Path) w.Header().Set("Content-Type", "application/json") assert.NoError(json.NewEncoder(w).Encode(map[string]any{ - "id": 7, "address_book_id": 9, "href": "/alice.vcf", - "local_tombstone": false, "local_vcard": localVCard, - "remote_tombstone": false, "remote_vcard": remoteVCard, "status": "open", + "id": 7, "address_book": map[string]any{"id": 9, "name": "Personal"}, + "status": "resolved", "resolution": "keep_remote", + "base": map[string]any{"state": "unavailable", "emails": []string{}, "phones": []string{}}, + "local": map[string]any{ + "state": "present", "display_name": "Local Alice", + "emails": []string{"local@example.test"}, "phones": []string{"+12025550123"}, + }, + "remote": map[string]any{"state": "deleted", "emails": []string{}, "phones": []string{}}, + "allowed_resolutions": []string{}, + "created_at": "2026-08-28T09:10:11Z", + "updated_at": "2026-08-28T10:11:12Z", + "resolved_at": "2026-08-28T11:12:13Z", + "local_vcard": localRawMarker, + "remote_vcard": remoteRawMarker, })) })) t.Cleanup(server.Close) @@ -155,16 +167,23 @@ func TestCardDAVConflictShowPrintsSnapshotsAsSafeJSON(t *testing.T) { cmd.SetOut(&stdout) cmd.SetArgs([]string{"conflicts", "show", "7"}) require.NoError(cmd.Execute()) - assert.NotContains(stdout.String(), "\x1b") - var got struct { - LocalVCard *string `json:"local_vcard"` - RemoteVCard *string `json:"remote_vcard"` - } - require.NoError(json.Unmarshal(stdout.Bytes(), &got)) - require.NotNil(got.LocalVCard) - require.NotNil(got.RemoteVCard) - assert.Equal(localVCard, *got.LocalVCard) - assert.Equal(remoteVCard, *got.RemoteVCard) + assert.JSONEq(`{ + "id":7, + "address_book":{"id":9,"name":"Personal"}, + "status":"resolved", + "resolution":"keep_remote", + "base":{"state":"unavailable","emails":[],"phones":[]}, + "local":{"state":"present","display_name":"Local Alice","emails":["local@example.test"],"phones":["+12025550123"]}, + "remote":{"state":"deleted","emails":[],"phones":[]}, + "allowed_resolutions":[], + "created_at":"2026-08-28T09:10:11Z", + "updated_at":"2026-08-28T10:11:12Z", + "resolved_at":"2026-08-28T11:12:13Z" + }`, stdout.String()) + assert.NotContains(stdout.String(), "local_vcard") + assert.NotContains(stdout.String(), "remote_vcard") + assert.NotContains(stdout.String(), localRawMarker) + assert.NotContains(stdout.String(), remoteRawMarker) } func TestCardDAVBooksSanitizesTerminalControls(t *testing.T) { diff --git a/cmd/msgvault/cmd/embed_vector.go b/cmd/msgvault/cmd/embed_vector.go index 40f2bfc70..6485a536c 100644 --- a/cmd/msgvault/cmd/embed_vector.go +++ b/cmd/msgvault/cmd/embed_vector.go @@ -12,6 +12,7 @@ import ( "time" "github.com/spf13/cobra" + "go.kenn.io/msgvault/internal/providercredentials" "go.kenn.io/msgvault/internal/scheduler" "go.kenn.io/msgvault/internal/store" "go.kenn.io/msgvault/internal/vector" @@ -150,6 +151,17 @@ func runEmbed(cmd *cobra.Command) error { personGate := vector.NewPinnedExactSemanticPersonEmbeddingGate( cfg.Vector, currentSemanticPersonVectorConfigSource(), s, ) + credentialSnapshot, err := providercredentials.Read(cfg.TokensDir()) + if err != nil { + return fmt.Errorf("load provider credentials: %w", err) + } + embeddingAPIKey, err := resolveProviderCredentialFromSnapshot( + credentialSnapshot, providercredentials.VectorEmbeddingsID, + cfg.Vector.Embeddings.Endpoint, cfg.Vector.Embeddings.APIKeyEnv, + ) + if err != nil { + return fmt.Errorf("resolve text embedding credential: %w", err) + } runtime, err := newEmbeddingRuntime(cfg.Vector, embeddingRuntimeDeps{ Backend: backend, VectorsDB: vectorsDB, MainDB: s.DB(), Store: s, @@ -157,6 +169,7 @@ func runEmbed(cmd *cobra.Command) error { TotalPending: totalPending, Progress: newProgressPrinter(errOut, totalPending, cfg.Vector.Embeddings.ETAWindow), PersonGate: personGate, + APIKey: embeddingAPIKey, }) if err != nil { return fmt.Errorf("configure embedding runtime: %w", err) diff --git a/cmd/msgvault/cmd/embed_vector_test.go b/cmd/msgvault/cmd/embed_vector_test.go index 66f08095d..63888b62b 100644 --- a/cmd/msgvault/cmd/embed_vector_test.go +++ b/cmd/msgvault/cmd/embed_vector_test.go @@ -21,6 +21,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/providercredentials" "go.kenn.io/msgvault/internal/scheduler" "go.kenn.io/msgvault/internal/store" "go.kenn.io/msgvault/internal/vector" @@ -529,6 +530,38 @@ func TestSetupVectorFeatures_AppliesVoyageEmbeddingPrefixes(t *testing.T) { check.Len(calls[4].Inputs[0][0], len(maxChunk)+len("search_document: ")) } +func TestSetupVectorFeaturesUsesStoredCredentialSnapshotWithoutEnvironment(t *testing.T) { + t.Setenv("TEXT_EMBEDDING_KEY", "") + var authorization string + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{{"embedding": []float32{1, 0, 0, 0}, "index": 0}}, + "model": "text-embedding-test", + })) + })) + t.Cleanup(provider.Close) + var storedETag string + vf := setupVectorFeaturesFixture(t, vector.APIFormatOpenAI, false, func(c *config.Config) { + c.Vector.Embeddings.Endpoint = provider.URL + c.Vector.Embeddings.APIKeyEnv = "TEXT_EMBEDDING_KEY" + empty, err := providercredentials.Read(c.TokensDir()) + require.NoError(t, err) + stored, err := providercredentials.Put(c.TokensDir(), empty.ETag, + providercredentials.VectorEmbeddingsID, provider.URL, "stored-at-startup") + require.NoError(t, err) + storedETag = stored.ETag + }) + _, err := providercredentials.Put(cfg.TokensDir(), storedETag, + providercredentials.VectorEmbeddingsID, provider.URL, "stored-after-startup") + require.NoError(t, err) + + _, err = vf.DocumentQueryClient.EmbedQuery(t.Context(), "private query") + require.NoError(t, err) + assert.Equal(t, "Bearer stored-at-startup", authorization) +} + func TestSetupVectorFeatures_SelectsRunnerByAPIFormat(t *testing.T) { t.Run("implicit OpenAI", func(t *testing.T) { vf := setupVectorFeaturesFixture(t, "", false) diff --git a/cmd/msgvault/cmd/multimodal_probe.go b/cmd/msgvault/cmd/multimodal_probe.go index c95241a4d..3476cb483 100644 --- a/cmd/msgvault/cmd/multimodal_probe.go +++ b/cmd/msgvault/cmd/multimodal_probe.go @@ -11,6 +11,7 @@ import ( "go.kenn.io/docbank/document/voyage" "go.kenn.io/msgvault/internal/fileutil" + "go.kenn.io/msgvault/internal/providercredentials" ) var ( @@ -53,9 +54,20 @@ capability profile.`, if err := cfg.Vector.Multimodal.Validate(); err != nil { return err } - apiKey := cfg.Vector.Multimodal.APIKey() + credentials, err := providercredentials.Read(cfg.TokensDir()) + if err != nil { + return fmt.Errorf("load provider credentials: %w", err) + } + apiKey, err := resolveProviderCredentialFromSnapshot( + credentials, providercredentials.VectorMultimodalID, + cfg.Vector.Multimodal.Endpoint, cfg.Vector.Multimodal.APIKeyEnv, + ) + if err != nil { + return fmt.Errorf("resolve visual embedding credential: %w", err) + } if apiKey == "" { - return fmt.Errorf("environment variable %s is not set", cfg.Vector.Multimodal.APIKeyEnv) + return fmt.Errorf("visual embedding credential is not configured: store one for %s in Settings "+ + "or set environment variable %s", providercredentials.VectorMultimodalID, cfg.Vector.Multimodal.APIKeyEnv) } if !multimodalProbeYes { return usageErr(cmd, errors.New("the probe sends synthetic fixture media to the configured provider; pass --yes to continue")) @@ -86,7 +98,9 @@ capability profile.`, if err := voyage.ValidateProbeFixtures(ctx, policy, fixtures); err != nil { return fmt.Errorf("validate probe fixtures: %w", err) } - client, err := voyage.NewClient(policy, voyage.ClientConfig{APIKey: apiKey}) + client, err := voyage.NewClient(policy, voyage.ClientConfig{ + APIKey: apiKey, HTTPClient: providerHTTPClientWithoutRedirects(nil), + }) if err != nil { return fmt.Errorf("voyage client: %w", err) } diff --git a/cmd/msgvault/cmd/operations_api_e2e_test.go b/cmd/msgvault/cmd/operations_api_e2e_test.go new file mode 100644 index 000000000..f44da5c9d --- /dev/null +++ b/cmd/msgvault/cmd/operations_api_e2e_test.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/api" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/operations" + "go.kenn.io/msgvault/internal/testutil" +) + +// TestOperationHistoryAPIServesThroughProductionAdapter protects the daemon +// seam: serve.go passes storeAPIAdapter, never *store.Store, to both API +// capabilities used by archive-bound operation history. +func TestOperationHistoryAPIServesThroughProductionAdapter(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewSQLiteTestStore(t) + source, err := st.GetOrCreateSource("gmail", "private-operation-owner@example.invalid") + require.NoError(err) + startedAt := time.Date(2026, 8, 29, 14, 0, 0, 0, time.UTC) + _, err = st.DB().ExecContext(t.Context(), `INSERT INTO sync_runs ( + source_id, started_at, completed_at, status, messages_processed, + messages_added, messages_updated, errors_count, error_message + ) VALUES (?, ?, ?, 'completed', 3, 1, 1, 0, ?)`, + source.ID, startedAt.Format("2006-01-02 15:04:05"), + startedAt.Add(time.Second).Format("2006-01-02 15:04:05"), + "private-operation-error") + require.NoError(err) + + adapter := &storeAPIAdapter{store: st} + srv := api.NewServerWithOptions(api.ServerOptions{ + Config: &config.Config{}, Store: adapter, OperationHistoryReader: adapter, + Logger: slog.New(slog.DiscardHandler), + }) + httpSrv := httptest.NewServer(srv.Router()) + t.Cleanup(httpSrv.Close) + + response, err := http.Get(httpSrv.URL + "/api/v1/operations/runs?kind=source_sync") + require.NoError(err) + defer func() { _ = response.Body.Close() }() + require.Equal(http.StatusOK, response.StatusCode) + listBody, err := io.ReadAll(response.Body) + require.NoError(err) + assert.NotContains(string(listBody), "private-operation-owner") + assert.NotContains(string(listBody), "private-operation-error") + var page api.OperationRunsResponse + require.NoError(json.Unmarshal(listBody, &page)) + require.Len(page.Runs, 1) + assert.Equal(operations.KindSourceSync, page.Runs[0].Kind) + assert.NotContains(page.Runs[0].ID, "source_sync") + + detailResponse, err := http.Get(httpSrv.URL + "/api/v1/operations/runs/" + page.Runs[0].ID) + require.NoError(err) + defer func() { _ = detailResponse.Body.Close() }() + require.Equal(http.StatusOK, detailResponse.StatusCode) + detailBody, err := io.ReadAll(detailResponse.Body) + require.NoError(err) + assert.NotContains(string(detailBody), "private-operation-owner") + assert.NotContains(string(detailBody), "private-operation-error") + var detail api.OperationRunDetail + require.NoError(json.Unmarshal(detailBody, &detail)) + assert.Equal(page.Runs[0], detail.OperationRunSummary) +} diff --git a/cmd/msgvault/cmd/person_enrichment.go b/cmd/msgvault/cmd/person_enrichment.go index dea22ddc4..579f2d773 100644 --- a/cmd/msgvault/cmd/person_enrichment.go +++ b/cmd/msgvault/cmd/person_enrichment.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" "go.kenn.io/msgvault/internal/personenrichment" + "go.kenn.io/msgvault/internal/providercredentials" "go.kenn.io/msgvault/internal/store" ) @@ -31,6 +32,7 @@ type personEnrichmentCommandDeps struct { config func() personenrichment.Config openStore func() (*store.Store, func(), error) lookupEnv personenrichment.CredentialLookup + proxyLookupEnv personenrichment.CredentialLookup isDaemonSubprocess func() bool proxyArgs func(*cobra.Command, []string, map[string]string) error newManualWorker func(context.Context, *store.Store, personenrichment.Config) (personEnrichmentScheduleWorker, error) @@ -45,27 +47,28 @@ func defaultPersonEnrichmentCommandDeps() personEnrichmentCommandDeps { } return cfg.People.Enrichment }, - openStore: openWritableStoreAndInit, - lookupEnv: os.LookupEnv, + openStore: openWritableStoreAndInit, + lookupEnv: func(name string) (string, bool) { + return personEnrichmentEnvironmentLookup(cfg)(name) + }, + proxyLookupEnv: os.LookupEnv, isDaemonSubprocess: isDaemonCLISubprocess, proxyArgs: func(command *cobra.Command, args []string, env map[string]string) error { return runDaemonCLICommandHTTPWithEnv(command, args, env, false, false) }, - newManualWorker: newPersonEnrichmentCLIWorker, - clock: time.Now, + newManualWorker: func( + ctx context.Context, st *store.Store, enrichmentConfig personenrichment.Config, + ) (personEnrichmentScheduleWorker, error) { + return newPersonEnrichmentCLIWorker( + ctx, st, enrichmentConfig, + personEnrichmentEnvironmentLookup(cfg), + personEnrichmentProviderCredentialLookup(cfg), + ) + }, + clock: time.Now, } } -func localPersonEnrichmentCommandDeps( - config personenrichment.Config, st *store.Store, -) personEnrichmentCommandDeps { - deps := defaultPersonEnrichmentCommandDeps() - deps.config = func() personenrichment.Config { return config } - deps.openStore = func() (*store.Store, func(), error) { return st, func() {}, nil } - deps.isDaemonSubprocess = func() bool { return true } - return deps -} - func newPersonEnrichmentCommand(deps personEnrichmentCommandDeps) *cobra.Command { command := &cobra.Command{Use: "enrichment", Short: "Manage external person enrichment"} command.AddCommand( @@ -95,11 +98,15 @@ func proxyPersonEnrichmentCommandWithEnv( return err } env := make(map[string]string, len(names)) + lookup := deps.proxyLookupEnv + if lookup == nil { + lookup = deps.lookupEnv + } for _, name := range names { - if name == "" { + if name == "" || name == providercredentials.StoredSuppressionEnvironment { continue } - if value, ok := deps.lookupEnv(name); ok && value != "" { + if value, ok := lookup(name); ok && value != "" { env[name] = value } } @@ -688,8 +695,13 @@ func personEnrichmentProviderConfig( func newPersonEnrichmentCLIWorker( ctx context.Context, st *store.Store, config personenrichment.Config, + suppressionLookup personenrichment.CredentialLookup, + providerLookup personenrichment.ProviderCredentialLookup, ) (personEnrichmentScheduleWorker, error) { - hasher, err := loadPersonEnrichmentSuppressionHasher(ctx, st, config, os.LookupEnv) + if suppressionLookup == nil || providerLookup == nil { + return nil, errors.New("person enrichment worker requires suppression and provider credential lookups") + } + hasher, err := loadPersonEnrichmentSuppressionHasher(ctx, st, config, suppressionLookup) if err != nil { return nil, err } @@ -723,7 +735,7 @@ func newPersonEnrichmentCLIWorker( } } } - gate, err := personenrichment.NewEgressGate(st, st, hasher, os.LookupEnv) + gate, err := personenrichment.NewProviderBoundEgressGate(st, st, hasher, providerLookup) if err != nil { return nil, err } diff --git a/cmd/msgvault/cmd/person_enrichment_schedule_test.go b/cmd/msgvault/cmd/person_enrichment_schedule_test.go index 18817969a..daaf51a19 100644 --- a/cmd/msgvault/cmd/person_enrichment_schedule_test.go +++ b/cmd/msgvault/cmd/person_enrichment_schedule_test.go @@ -188,7 +188,8 @@ func TestPersonEnrichmentScheduleRegistersWithoutResolvingProviderCredentials(t }, } sched := scheduler.New(nil) - require.NoError(registerPersonEnrichmentJob(t.Context(), sched, f.Store, config)) + require.NoError(registerPersonEnrichmentJob(t.Context(), sched, f.Store, config, + testPersonEnrichmentRuntimeCredentials(t))) assert.True(sched.IsJobScheduled(personEnrichmentJob)) var profiles int @@ -435,7 +436,7 @@ func TestRegisterPersonEnrichmentJobCancelsWorkForUnavailableProfiles(t *testing requirements.NoError(registerPersonEnrichmentJob(t.Context(), sched, f.Store, personenrichment.Config{ Enabled: enrichmentEnabled, Schedule: "*/15 * * * *", BatchSize: 25, LeaseDuration: time.Minute, SuppressionKeyEnv: "UNAVAILABLE_PROFILE_SUPPRESSION_KEY", Providers: providers, - })) + }, testPersonEnrichmentRuntimeCredentials(t))) stored, err := f.Store.GetPersonEnrichmentAttemptContext(t.Context(), attempt.ID) requirements.NoError(err) diff --git a/cmd/msgvault/cmd/person_enrichment_test.go b/cmd/msgvault/cmd/person_enrichment_test.go index fa4f76e5b..d0b35ef37 100644 --- a/cmd/msgvault/cmd/person_enrichment_test.go +++ b/cmd/msgvault/cmd/person_enrichment_test.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/config" "go.kenn.io/msgvault/internal/personenrichment" "go.kenn.io/msgvault/internal/store" "go.kenn.io/msgvault/internal/testutil/storetest" @@ -248,8 +249,8 @@ func TestPersonEnrichmentSuppressPersonPersistsCurrentIdentityDigests(t *testing key := strings.Repeat("s", 32) hasher, err := personenrichment.NewSuppressionHasher([]byte(key)) requirements.NoError(err) - deps := localPersonEnrichmentCommandDeps(config, f.Store) - deps.lookupEnv = func(string) (string, bool) { return key, true } + deps := testPersonEnrichmentCommandDeps(t, config, f.Store) + t.Setenv("TEST_SUPPRESSION_KEY", key) stdout, stderr, err := executePersonEnrichmentCommand(t, deps, "", "suppress", "--person", strconv.FormatInt(person.ID, 10), "--reason", "opt_out") @@ -319,8 +320,8 @@ func TestPersonEnrichmentSuppressDaemonRejectsMismatchedDurableKey(t *testing.T) requirements.NoError(err) newDigest := newHasher.Digest(namespace, personenrichment.SuppressionEmail, personenrichment.EmailNormalizationV1, "new@example.test") - deps := localPersonEnrichmentCommandDeps(config, f.Store) - deps.lookupEnv = func(string) (string, bool) { return strings.Repeat("n", 32), true } + deps := testPersonEnrichmentCommandDeps(t, config, f.Store) + t.Setenv("TEST_SUPPRESSION_KEY", strings.Repeat("n", 32)) _, _, err = executePersonEnrichmentCommand(t, deps, "", "suppress", "--provider-namespace", namespace, "--identifier-class", "email", "--normalization-version", personenrichment.EmailNormalizationV1, @@ -344,8 +345,8 @@ func TestPersonEnrichmentSuppressDaemonRejectsWrongConfiguredKeyOnEmptyLedger(t requirements.NoError(err) wrong := wrongHasher.Digest(namespace, personenrichment.SuppressionEmail, personenrichment.EmailNormalizationV1, "wrong@example.test") - deps := localPersonEnrichmentCommandDeps(config, f.Store) - deps.lookupEnv = func(string) (string, bool) { return strings.Repeat("c", 32), true } + deps := testPersonEnrichmentCommandDeps(t, config, f.Store) + t.Setenv("TEST_SUPPRESSION_KEY", strings.Repeat("c", 32)) _, _, err = executePersonEnrichmentCommand(t, deps, "", "suppress", "--provider-namespace", namespace, "--identifier-class", "email", "--normalization-version", personenrichment.EmailNormalizationV1, @@ -374,8 +375,8 @@ func TestPersonEnrichmentSuppressDaemonConcurrentDifferingKeysPersistOnlyConfigu personenrichment.EmailNormalizationV1, "configured@example.test") wrong := wrongHasher.Digest(namespace, personenrichment.SuppressionEmail, personenrichment.EmailNormalizationV1, "wrong@example.test") - deps := localPersonEnrichmentCommandDeps(config, f.Store) - deps.lookupEnv = func(string) (string, bool) { return configuredKey, true } + deps := testPersonEnrichmentCommandDeps(t, config, f.Store) + t.Setenv("TEST_SUPPRESSION_KEY", configuredKey) start := make(chan struct{}) errs := make(chan error, 2) for _, digest := range []personenrichment.SuppressionDigest{configured, wrong} { @@ -426,7 +427,7 @@ func TestPersonEnrichmentConsentProfilesStatusAndRevoke(t *testing.T) { f := storetest.New(t) provider, profile, _ := scheduleWorkerProfile(t, f, "cli-controls", "TEST_CLI_PROVIDER_KEY") config := personenrichment.Config{Enabled: true, SuppressionKeyEnv: "TEST_SUPPRESSION_KEY", Providers: []personenrichment.ProviderConfig{provider}} - deps := localPersonEnrichmentCommandDeps(config, f.Store) + deps := testPersonEnrichmentCommandDeps(t, config, f.Store) profiles, _, err := executePersonEnrichmentCommand(t, deps, "", "profiles", "--json") requirements.NoError(err) @@ -457,7 +458,7 @@ func TestPersonEnrichmentManualRunPersistsAndReusesRunIDBeforeWork(t *testing.T) requirements.NoError(err) config := personenrichment.Config{Enabled: true, SuppressionKeyEnv: "TEST_SUPPRESSION_KEY", Providers: []personenrichment.ProviderConfig{provider}} var observed []int64 - deps := localPersonEnrichmentCommandDeps(config, f.Store) + deps := testPersonEnrichmentCommandDeps(t, config, f.Store) now := time.Date(2026, 8, 23, 20, 0, 0, 0, time.UTC) deps.clock = func() time.Time { return now } deps.newManualWorker = func(_ context.Context, st *store.Store, _ personenrichment.Config) (personEnrichmentScheduleWorker, error) { @@ -510,7 +511,7 @@ func TestPersonEnrichmentManualRunRejectsUntrackedPersonWithoutRunOrWork(t *test _, _, err = f.Store.GrantPersonEnrichmentConsent(t.Context(), profile.Fingerprint, "test") requirements.NoError(err) config := personenrichment.Config{Enabled: true, SuppressionKeyEnv: "TEST_SUPPRESSION_KEY", Providers: []personenrichment.ProviderConfig{provider}} - deps := localPersonEnrichmentCommandDeps(config, f.Store) + deps := testPersonEnrichmentCommandDeps(t, config, f.Store) deps.newManualWorker = func(context.Context, *store.Store, personenrichment.Config) (personEnrichmentScheduleWorker, error) { require.FailNow(t, "untracked manual run must not construct a worker") return nil, errors.New("unreachable test worker construction") @@ -547,7 +548,7 @@ func TestPersonEnrichmentManualRunRejectsGloballyDisabledEnrichment(t *testing.T var workBefore int64 requirements.NoError(f.Store.DB().QueryRowContext(t.Context(), f.Store.Rebind( `SELECT COUNT(*) FROM person_enrichment_work WHERE person_id = ?`), person.ID).Scan(&workBefore)) - deps := localPersonEnrichmentCommandDeps(config, f.Store) + deps := testPersonEnrichmentCommandDeps(t, config, f.Store) deps.newManualWorker = func(context.Context, *store.Store, personenrichment.Config) (personEnrichmentScheduleWorker, error) { require.FailNow(t, "disabled manual enrichment must not construct a worker") return nil, errors.New("unreachable test worker construction") @@ -580,7 +581,7 @@ func TestPersonEnrichmentManualRunKeepsRunIDOnLeaseAndAttemptAndReportsFinalCoun config := personenrichment.Config{Enabled: true, SuppressionKeyEnv: "TEST_SUPPRESSION_KEY", Providers: []personenrichment.ProviderConfig{provider}} now := time.Date(2026, 8, 23, 21, 0, 0, 0, time.UTC) completionClock := now - deps := localPersonEnrichmentCommandDeps(config, f.Store) + deps := testPersonEnrichmentCommandDeps(t, config, f.Store) deps.clock = func() time.Time { current := completionClock completionClock = completionClock.Add(time.Minute) @@ -667,3 +668,40 @@ func personEnrichmentCLIConfig(suppressionEnv string) personenrichment.Config { } return personenrichment.Config{Enabled: true, SuppressionKeyEnv: suppressionEnv, Providers: []personenrichment.ProviderConfig{provider}} } + +// providerCredentialTestConfig returns a config whose data directory is a +// private temporary tree, so the daemon's credential lookups read a throwaway +// provider credential store and fall back to the test environment. +func providerCredentialTestConfig(t *testing.T) *config.Config { + t.Helper() + testConfig := config.NewDefaultConfig() + testConfig.Data.DataDir = t.TempDir() + return testConfig +} + +func testPersonEnrichmentRuntimeCredentials(t *testing.T) personEnrichmentRuntimeCredentials { + t.Helper() + testConfig := providerCredentialTestConfig(t) + return personEnrichmentRuntimeCredentials{ + Suppression: personEnrichmentEnvironmentLookup(testConfig), + Provider: personEnrichmentProviderCredentialLookup(testConfig), + } +} + +// testPersonEnrichmentCommandDeps composes the production command +// dependencies against an already open store. Credential lookups are the ones +// the daemon builds; only the store, config source, and daemon-subprocess +// detection are replaced. +func testPersonEnrichmentCommandDeps( + t *testing.T, enrichment personenrichment.Config, st *store.Store, +) personEnrichmentCommandDeps { + t.Helper() + saved := cfg + t.Cleanup(func() { cfg = saved }) + cfg = providerCredentialTestConfig(t) + deps := defaultPersonEnrichmentCommandDeps() + deps.config = func() personenrichment.Config { return enrichment } + deps.openStore = func() (*store.Store, func(), error) { return st, func() {}, nil } + deps.isDaemonSubprocess = func() bool { return true } + return deps +} diff --git a/cmd/msgvault/cmd/provider_credentials.go b/cmd/msgvault/cmd/provider_credentials.go new file mode 100644 index 000000000..8884d4ae9 --- /dev/null +++ b/cmd/msgvault/cmd/provider_credentials.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "errors" + "net/http" + "os" + + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/personenrichment" + "go.kenn.io/msgvault/internal/providercredentials" +) + +func providerHTTPClientWithoutRedirects(client *http.Client) *http.Client { + if client == nil { + client = http.DefaultClient + } + isolated := *client + isolated.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + return &isolated +} + +func resolveProviderCredentialFromSnapshot( + snapshot providercredentials.Snapshot, + id, endpoint, environment string, +) (string, error) { + credential, _, err := snapshot.Resolve(id, endpoint, environment, os.LookupEnv) + if err != nil { + return "", err + } + return credential, nil +} + +func personEnrichmentProviderCredentialLookup( + cfg *config.Config, +) personenrichment.ProviderCredentialLookup { + return func(profile personenrichment.ProviderProfile) (string, bool, error) { + if cfg == nil { + return "", false, errors.New("provider credential config is unavailable") + } + credentialEndpoint, err := (personenrichment.ProviderConfig{ + Kind: profile.Kind, Endpoint: profile.Endpoint, PollEndpoint: profile.PollEndpoint, + }).CredentialEndpoint() + if err != nil { + return "", false, err + } + snapshot, err := providercredentials.Read(cfg.TokensDir()) + if err != nil { + return "", false, err + } + credential, state, err := snapshot.Resolve( + providercredentials.PersonEnrichmentID(profile.Name), + credentialEndpoint, profile.APIKeyEnv, os.LookupEnv, + ) + return credential, state.Configured, err + } +} + +func resolvePersonEnrichmentSuppression(cfg *config.Config) (string, error) { + if cfg == nil { + return "", errors.New("person enrichment suppression config is unavailable") + } + if cfg.People.Enrichment.SuppressionKeyEnv != providercredentials.StoredSuppressionEnvironment { + value, ok := os.LookupEnv(cfg.People.Enrichment.SuppressionKeyEnv) + if !ok || value == "" { + return "", errors.New("person enrichment suppression key is unavailable") + } + return value, nil + } + snapshot, err := providercredentials.Read(cfg.TokensDir()) + if err != nil { + return "", err + } + value, configured, err := snapshot.ResolveSuppression() + if err != nil { + return "", err + } + if !configured || value == "" { + return "", errors.New("stored person enrichment suppression key is unavailable") + } + return value, nil +} + +func personEnrichmentEnvironmentLookup(cfg *config.Config) personenrichment.CredentialLookup { + return func(name string) (string, bool) { + if name != providercredentials.StoredSuppressionEnvironment { + return os.LookupEnv(name) + } + value, err := resolvePersonEnrichmentSuppression(cfg) + return value, err == nil && value != "" + } +} diff --git a/cmd/msgvault/cmd/provider_credentials_test.go b/cmd/msgvault/cmd/provider_credentials_test.go new file mode 100644 index 000000000..d1a7e8616 --- /dev/null +++ b/cmd/msgvault/cmd/provider_credentials_test.go @@ -0,0 +1,233 @@ +package cmd + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/peoplesweep" + "go.kenn.io/msgvault/internal/personenrichment" + "go.kenn.io/msgvault/internal/providercredentials" +) + +type activeEnrichmentConsent struct{ events *[]string } + +func (c activeEnrichmentConsent) HasActivePersonEnrichmentConsent(context.Context, string) (bool, error) { + *c.events = append(*c.events, "consent") + return true, nil +} + +type installingEnrichmentSuppression struct { + events *[]string + keyID string + install func() error +} + +func (s *installingEnrichmentSuppression) ListPersonEnrichmentSuppressionKeyIDsContext( + context.Context, +) ([]string, error) { + *s.events = append(*s.events, "key_ids") + return []string{s.keyID}, nil +} + +func (s *installingEnrichmentSuppression) HasPersonEnrichmentSuppressionContext( + context.Context, personenrichment.SuppressionLookup, +) (bool, error) { + *s.events = append(*s.events, "suppression") + if err := s.install(); err != nil { + return false, err + } + return false, nil +} + +func TestVectorProviderCredentialResolutionUsesOneStartupSnapshot(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + t.Setenv("TEXT_EMBEDDING_KEY", "environment-secret") + cfg := config.NewDefaultConfig() + cfg.Data.DataDir = t.TempDir() + cfg.Vector.Embeddings.Endpoint = "https://embeddings.example.test/v1" + cfg.Vector.Embeddings.APIKeyEnv = "TEXT_EMBEDDING_KEY" + + empty, err := providercredentials.Read(cfg.TokensDir()) + requirements.NoError(err) + firstStore, err := providercredentials.Put(cfg.TokensDir(), empty.ETag, + providercredentials.VectorEmbeddingsID, cfg.Vector.Embeddings.Endpoint, "stored-first") + requirements.NoError(err) + startup, err := providercredentials.Read(cfg.TokensDir()) + requirements.NoError(err) + + first, err := resolveProviderCredentialFromSnapshot(startup, + providercredentials.VectorEmbeddingsID, cfg.Vector.Embeddings.Endpoint, + cfg.Vector.Embeddings.APIKeyEnv) + requirements.NoError(err) + assertions.Equal("stored-first", first) + + _, err = providercredentials.Put(cfg.TokensDir(), firstStore.ETag, + providercredentials.VectorEmbeddingsID, cfg.Vector.Embeddings.Endpoint, "stored-second") + requirements.NoError(err) + stillFirst, err := resolveProviderCredentialFromSnapshot(startup, + providercredentials.VectorEmbeddingsID, cfg.Vector.Embeddings.Endpoint, + cfg.Vector.Embeddings.APIKeyEnv) + requirements.NoError(err) + assertions.Equal("stored-first", stillFirst) + + restarted, err := providercredentials.Read(cfg.TokensDir()) + requirements.NoError(err) + second, err := resolveProviderCredentialFromSnapshot(restarted, + providercredentials.VectorEmbeddingsID, cfg.Vector.Embeddings.Endpoint, + cfg.Vector.Embeddings.APIKeyEnv) + requirements.NoError(err) + assertions.Equal("stored-second", second) +} + +func TestPersonEnrichmentProviderCredentialLookupUsesStableNameAndReloadsStore(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + t.Setenv("SHARED_EXA_KEY", "environment-secret") + cfg := config.NewDefaultConfig() + cfg.Data.DataDir = t.TempDir() + profile := personenrichment.ProviderProfile{ + Name: "exa-primary", Kind: personenrichment.ProviderExa, + Endpoint: "https://api.example.test/search", APIKeyEnv: "SHARED_EXA_KEY", + } + empty, err := providercredentials.Read(cfg.TokensDir()) + requirements.NoError(err) + firstStore, err := providercredentials.Put(cfg.TokensDir(), empty.ETag, + providercredentials.PersonEnrichmentID(profile.Name), profile.Endpoint, "stored-first") + requirements.NoError(err) + lookup := personEnrichmentProviderCredentialLookup(cfg) + + first, configured, err := lookup(profile) + requirements.NoError(err) + assertions.True(configured) + assertions.Equal("stored-first", first) + + _, err = providercredentials.Put(cfg.TokensDir(), firstStore.ETag, + providercredentials.PersonEnrichmentID(profile.Name), profile.Endpoint, "stored-second") + requirements.NoError(err) + second, configured, err := lookup(profile) + requirements.NoError(err) + assertions.True(configured) + assertions.Equal("stored-second", second) +} + +func TestStoredSuppressionLookupFeedsReservedRuntimeEnvironmentAndFailsClosed(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + cfg := config.NewDefaultConfig() + cfg.Data.DataDir = t.TempDir() + cfg.People.Enrichment.SuppressionKeyEnv = providercredentials.StoredSuppressionEnvironment + empty, err := providercredentials.Read(cfg.TokensDir()) + requirements.NoError(err) + _, err = providercredentials.PutSuppression(cfg.TokensDir(), empty.ETag, "stored-suppression-key-0123456789012345") + requirements.NoError(err) + lookup := personEnrichmentEnvironmentLookup(cfg) + + value, configured := lookup(providercredentials.StoredSuppressionEnvironment) + assertions.True(configured) + assertions.Equal("stored-suppression-key-0123456789012345", value) + + path := filepath.Join(cfg.TokensDir(), providercredentials.Filename) + requirements.NoError(os.WriteFile(path, []byte(`{"version":1,"credentials":`), 0o600)) + value, configured = lookup(providercredentials.StoredSuppressionEnvironment) + assertions.False(configured) + assertions.Empty(value) +} + +func TestDefaultPersonEnrichmentLookupReadsRuntimeConfig(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + configured := config.NewDefaultConfig() + configured.Data.DataDir = t.TempDir() + configured.People.Enrichment.SuppressionKeyEnv = providercredentials.StoredSuppressionEnvironment + empty, err := providercredentials.Read(configured.TokensDir()) + requirements.NoError(err) + _, err = providercredentials.PutSuppression( + configured.TokensDir(), empty.ETag, "runtime-suppression-key-0123456789012345", + ) + requirements.NoError(err) + + withTestConfig(t, nil) + deps := defaultPersonEnrichmentCommandDeps() + cfg = configured + + value, ok := deps.lookupEnv(providercredentials.StoredSuppressionEnvironment) + assertions.True(ok) + assertions.Equal("runtime-suppression-key-0123456789012345", value) +} + +func TestPersonEnrichmentGateLoadsStableStoredCredentialOnlyAfterSuppression(t *testing.T) { + requirements := require.New(t) + t.Setenv("SCHEDULE_PROVIDER_KEY", "") + cfg := config.NewDefaultConfig() + cfg.Data.DataDir = t.TempDir() + profile := scheduleTestEnrichmentProfile(t) + empty, err := providercredentials.Read(cfg.TokensDir()) + requirements.NoError(err) + hasher, err := personenrichment.NewSuppressionHasher([]byte("0123456789abcdef0123456789abcdef")) + requirements.NoError(err) + keyID, err := hasher.KeyID() + requirements.NoError(err) + events := []string{} + suppressions := &installingEnrichmentSuppression{ + events: &events, keyID: keyID, + install: func() error { + _, putErr := providercredentials.Put(cfg.TokensDir(), empty.ETag, + providercredentials.PersonEnrichmentID(profile.Name), profile.Endpoint, + "stored-after-suppression") + return putErr + }, + } + gate, err := personenrichment.NewProviderBoundEgressGate( + activeEnrichmentConsent{events: &events}, suppressions, hasher, + personEnrichmentProviderCredentialLookup(cfg), + ) + requirements.NoError(err) + + authorization, err := gate.Authorize(t.Context(), personenrichment.EgressInput{ + Request: personenrichment.Request{Identity: personenrichment.Identity{Email: "person@example.com"}}, + Profile: profile, + }) + requirements.NoError(err) + assert.Equal(t, "stored-after-suppression", authorization.Credential) + assert.Equal(t, []string{"consent", "key_ids", "suppression"}, events) +} + +func TestDefaultCLIProxyLookupsNeverForwardStoredCredentials(t *testing.T) { + requireStoredCredentialStorePlatform(t) + assertions := assert.New(t) + requirements := require.New(t) + t.Setenv("TEST_PROVIDER_KEY", "") + cfg := config.NewDefaultConfig() + cfg.Data.DataDir = t.TempDir() + cfg.People.Sweep = personProviderTestConfig() + cfg.People.Enrichment = personEnrichmentCLIConfig(providercredentials.StoredSuppressionEnvironment) + sweepName, sweepProvider, err := cfg.People.Sweep.ActiveProviderConfig() + requirements.NoError(err) + sweepProvider.Credential = peoplesweep.CredentialStored + sweepProvider.CredentialEnv = "" + cfg.People.Sweep.Providers[sweepName] = sweepProvider + requirements.NoError(peoplesweep.NewFileCredentialStore(cfg.TokensDir()).Save( + sweepName, peoplesweep.NewCredential(sweepProvider.Auth, "stored-sweep-secret"))) + empty, err := providercredentials.Read(cfg.TokensDir()) + requirements.NoError(err) + stored, err := providercredentials.PutSuppression(cfg.TokensDir(), empty.ETag, "stored-suppression-secret-0123456789") + requirements.NoError(err) + _, err = providercredentials.Put(cfg.TokensDir(), stored.ETag, + providercredentials.PersonEnrichmentID(cfg.People.Enrichment.Providers[0].Name), + cfg.People.Enrichment.Providers[0].Endpoint, "stored-enrichment-secret") + requirements.NoError(err) + withTestConfig(t, cfg) + + sweepDeps := defaultPersonSweepCommandDeps() + assertions.Empty(personSweepForwardEnv(cfg.People.Sweep, sweepDeps.lookupEnv)) + enrichmentDeps := defaultPersonEnrichmentCommandDeps() + value, ok := enrichmentDeps.proxyLookupEnv(providercredentials.StoredSuppressionEnvironment) + assertions.False(ok) + assertions.Empty(value) +} diff --git a/cmd/msgvault/cmd/serve.go b/cmd/msgvault/cmd/serve.go index c9e756417..03252b7ed 100644 --- a/cmd/msgvault/cmd/serve.go +++ b/cmd/msgvault/cmd/serve.go @@ -30,6 +30,7 @@ import ( "go.kenn.io/msgvault/internal/microsoft" "go.kenn.io/msgvault/internal/notionmeetings" "go.kenn.io/msgvault/internal/oauth" + "go.kenn.io/msgvault/internal/operations" "go.kenn.io/msgvault/internal/personenrichment" "go.kenn.io/msgvault/internal/personfacts" "go.kenn.io/msgvault/internal/query" @@ -244,6 +245,9 @@ func runServe(cmd *cobra.Command, args []string) error { return fmt.Errorf("init schema: %w", err) } logger.Info("daemon startup step complete", "step", "init_archive_schema") + if err := recoverCardDAVSyncRunsAtStartup(cmd.Context(), s, logger); err != nil { + return err + } // Legacy [identity] migration is deferred to the first scheduled sync's // runPostSourceCreateMigrations call, which fires AFTER that sync's // confirmDefaultIdentity. Calling the migration here would race @@ -333,7 +337,7 @@ func runServe(cmd *cobra.Command, args []string) error { // Create and configure scheduler sched := scheduler.New(syncFunc).WithLogger(logger). WithWorkTracker(combineWorkTrackers(idleTracker, labelWorkTracker(operationGate, "a scheduled sync"))) - cardDAVController, err := api.NewCardDAVController(cfg, s) + cardDAVController, err := api.NewCardDAVController(cfg, s, logger) if err != nil { return fmt.Errorf("configure CardDAV: %w", err) } @@ -437,7 +441,10 @@ func runServe(cmd *cobra.Command, args []string) error { return fmt.Errorf("schedule people sweep: %w", err) } if err := registerPersonEnrichmentJob( - ctx, sched, s, cfg.People.Enrichment); err != nil { + ctx, sched, s, cfg.People.Enrichment, personEnrichmentRuntimeCredentials{ + Suppression: personEnrichmentEnvironmentLookup(cfg), + Provider: personEnrichmentProviderCredentialLookup(cfg), + }); err != nil { return fmt.Errorf("schedule person enrichment: %w", err) } @@ -585,7 +592,7 @@ func runServe(cmd *cobra.Command, args []string) error { meetingImporter: meetingImporter, analyticsDir: cfg.AnalyticsDir(), personEnrichmentConfig: cfg.People.Enrichment, - lookupEnv: os.LookupEnv, + lookupEnv: personEnrichmentEnvironmentLookup(cfg), } schedAdapter := &schedulerAdapter{scheduler: sched} @@ -612,6 +619,7 @@ func runServe(cmd *cobra.Command, args []string) error { AnalyticsInitializationActive: analyticsAsync, IdleTracker: idleTracker, OperationGate: operationGate, + OperationHistoryReader: storeAdapter, BlobStore: blobStore, } applyServerRuntimeConfig(&apiOpts, cfg) @@ -777,7 +785,7 @@ func reconcileCardDAVSchedulerJob(sched *scheduler.Scheduler, cardDAVConfig conf if err := sched.AddJob(scheduler.Job{ Name: api.CardDAVJobName, Schedule: cardDAVConfig.Schedule, Run: func(ctx context.Context) error { - _, err := service.Sync(ctx, carddav.SyncOptions{}) + _, err := service.Sync(ctx, carddav.SyncOptions{Trigger: store.CardDAVSyncTriggerScheduled}) return err }, }); err != nil { @@ -786,6 +794,17 @@ func reconcileCardDAVSchedulerJob(sched *scheduler.Scheduler, cardDAVConfig conf return nil } +func recoverCardDAVSyncRunsAtStartup(ctx context.Context, st *store.Store, logger *slog.Logger) error { + recovered, err := st.RecoverCardDAVSyncRunsContext(ctx) + if err != nil { + return fmt.Errorf("recover CardDAV sync runs at daemon startup: %w", err) + } + if recovered > 0 { + logger.Info("recovered orphaned CardDAV sync runs", "count", recovered) + } + return nil +} + func daemonCacheRefreshError(ctx context.Context, err error) error { if err == nil { return nil @@ -1198,6 +1217,7 @@ var _ api.IdentityMatchStore = (*storeAPIAdapter)(nil) var _ api.PersonProfileStore = (*storeAPIAdapter)(nil) var _ api.PersonCompletionStore = (*storeAPIAdapter)(nil) var _ api.PersonTrackingStore = (*storeAPIAdapter)(nil) +var _ api.PersonNetworkStore = (*storeAPIAdapter)(nil) var _ api.PersonProfileValueStore = (*storeAPIAdapter)(nil) var _ api.CommunicationServiceStore = (*storeAPIAdapter)(nil) var _ api.AttributeDefinitionStore = (*storeAPIAdapter)(nil) @@ -1210,6 +1230,7 @@ var _ api.ClusterLookupStore = (*storeAPIAdapter)(nil) var _ api.ConversationWindowStore = (*storeAPIAdapter)(nil) var _ api.ChangedMessageLister = (*storeAPIAdapter)(nil) var _ api.ArchiveIdentifier = (*storeAPIAdapter)(nil) +var _ operations.HistoryReader = (*storeAPIAdapter)(nil) var _ api.DocumentSearchStore = (*storeAPIAdapter)(nil) var _ api.DocumentStatusStore = (*storeAPIAdapter)(nil) var _ api.DocumentVectorStatusStore = (*storeAPIAdapter)(nil) @@ -1287,6 +1308,24 @@ func (a *storeAPIAdapter) ArchiveUIDContext(ctx context.Context) (string, error) return a.store.ArchiveUIDContext(ctx) } +func (a *storeAPIAdapter) Kinds() []operations.Kind { + return a.store.Kinds() +} + +func (a *storeAPIAdapter) ListRuns(ctx context.Context, query operations.Query) ([]operations.Run, error) { + return a.store.ListRuns(ctx, query) +} + +func (a *storeAPIAdapter) GetRun(ctx context.Context, id operations.StableID) (operations.Run, error) { + return a.store.GetRun(ctx, id) +} + +func (a *storeAPIAdapter) LaneStatus( + ctx context.Context, kind operations.Kind, +) (operations.LaneHistoryStatus, error) { + return a.store.LaneStatus(ctx, kind) +} + func (a *storeAPIAdapter) SearchDocuments( ctx context.Context, request store.DocumentSearchRequest, @@ -2267,6 +2306,18 @@ func (a *storeAPIAdapter) ListPersonsContext(ctx context.Context) ([]store.Perso return a.store.ListPersonsContext(ctx) } +func (a *storeAPIAdapter) DirectoryPeoplePageContext( + ctx context.Context, query store.DirectoryPeopleQuery, +) (*store.DirectoryPeoplePage, error) { + return a.store.DirectoryPeoplePageContext(ctx, query) +} + +func (a *storeAPIAdapter) GetPersonNetworkContext( + ctx context.Context, personID int64, opts store.PersonNetworkOptions, +) (store.PersonNetwork, error) { + return a.store.GetPersonNetworkContext(ctx, personID, opts) +} + func (a *storeAPIAdapter) UpdatePersonDisplayNameContext( ctx context.Context, id, expectedRevision int64, displayName *string, ) (*store.Person, error) { @@ -2919,11 +2970,17 @@ func canonicalPersonEnrichmentOccurrence(occurrence time.Time) string { return occurrence.UTC().Truncate(time.Minute).Format(time.RFC3339) } +type personEnrichmentRuntimeCredentials struct { + Suppression personenrichment.CredentialLookup + Provider personenrichment.ProviderCredentialLookup +} + func registerPersonEnrichmentJob( ctx context.Context, sched *scheduler.Scheduler, st *store.Store, enrichmentConfig personenrichment.Config, + credentials personEnrichmentRuntimeCredentials, ) error { if !enrichmentConfig.Enabled { if st == nil { @@ -2940,7 +2997,10 @@ func registerPersonEnrichmentJob( if err := enrichmentConfig.Validate(); err != nil { return err } - suppressionKey, ok := os.LookupEnv(enrichmentConfig.SuppressionKeyEnv) + if credentials.Suppression == nil || credentials.Provider == nil { + return errors.New("person enrichment schedule requires suppression and provider credential lookups") + } + suppressionKey, ok := credentials.Suppression(enrichmentConfig.SuppressionKeyEnv) if !ok || suppressionKey == "" { return fmt.Errorf("person enrichment suppression key environment %q is not set", enrichmentConfig.SuppressionKeyEnv) @@ -2989,7 +3049,7 @@ func registerPersonEnrichmentJob( if err := st.CancelPersonEnrichmentWorkOutsideProfilesContext(ctx, activeFingerprints); err != nil { return fmt.Errorf("cancel unavailable person enrichment work: %w", err) } - gate, err := personenrichment.NewEgressGate(st, st, hasher, os.LookupEnv) + gate, err := personenrichment.NewProviderBoundEgressGate(st, st, hasher, credentials.Provider) if err != nil { return fmt.Errorf("configure person enrichment egress: %w", err) } diff --git a/cmd/msgvault/cmd/serve_api_wiring_test.go b/cmd/msgvault/cmd/serve_api_wiring_test.go index 1d2a8f06f..43ad94de1 100644 --- a/cmd/msgvault/cmd/serve_api_wiring_test.go +++ b/cmd/msgvault/cmd/serve_api_wiring_test.go @@ -135,6 +135,7 @@ func TestStoreAPIAdapterServesProfileAndCommunicationServiceRoutes(t *testing.T) }{ {"communication services", "/api/v1/communication-services"}, {"structured profile", fmt.Sprintf("/api/v1/people/%d/profile", person.ID)}, + {"person network", fmt.Sprintf("/api/v1/people/%d/network", person.ID)}, } { t.Run(test.name, func(t *testing.T) { request := httptest.NewRequest(http.MethodGet, test.path, nil) diff --git a/cmd/msgvault/cmd/serve_carddav_test.go b/cmd/msgvault/cmd/serve_carddav_test.go index d855f459e..e5855efb0 100644 --- a/cmd/msgvault/cmd/serve_carddav_test.go +++ b/cmd/msgvault/cmd/serve_carddav_test.go @@ -1,8 +1,10 @@ package cmd import ( + "bytes" "context" "log/slog" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -12,12 +14,47 @@ import ( "go.kenn.io/msgvault/internal/config" "go.kenn.io/msgvault/internal/scheduler" "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" ) -type scheduledCardDAVFixture struct{ syncs int } +type scheduledCardDAVFixture struct { + syncs int + options []carddav.SyncOptions +} + +func TestRecoverCardDAVSyncRunsAtStartupTerminalizesOrphansAndLogsOnlyCount(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + _, err := st.StartCardDAVSyncRunContext(t.Context(), store.CardDAVSyncRunStart{ + Trigger: store.CardDAVSyncTriggerScheduled, + }) + require.NoError(err) + var logs bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logs, nil)) + require.NoError(recoverCardDAVSyncRunsAtStartup(t.Context(), st, logger)) + runs, err := st.ListCardDAVSyncRunsContext(t.Context(), 10, nil) + require.NoError(err) + require.Len(runs, 1) + assert.Equal(store.CardDAVSyncRunFailed, runs[0].State) + assert.Equal("daemon_restarted", runs[0].ErrorCode) + assert.Contains(logs.String(), "count=1") + assert.NotContains(strings.ToLower(logs.String()), "error_message") +} + +func TestRecoverCardDAVSyncRunsAtStartupReturnsFailure(t *testing.T) { + st := testutil.NewTestStore(t) + _, err := st.DB().Exec(`DROP TABLE carddav_sync_runs`) + require.NoError(t, err) + + err = recoverCardDAVSyncRunsAtStartup(t.Context(), st, slog.New(slog.DiscardHandler)) + require.Error(t, err) + assert.ErrorContains(t, err, "recover CardDAV sync runs") +} -func (f *scheduledCardDAVFixture) Sync(context.Context, carddav.SyncOptions) (carddav.SyncResult, error) { +func (f *scheduledCardDAVFixture) Sync(_ context.Context, options carddav.SyncOptions) (carddav.SyncResult, error) { f.syncs++ + f.options = append(f.options, options) return carddav.SyncResult{}, nil } func (f *scheduledCardDAVFixture) ListBooks(context.Context) ([]store.CardDAVAddressBook, error) { @@ -26,15 +63,15 @@ func (f *scheduledCardDAVFixture) ListBooks(context.Context) ([]store.CardDAVAdd func (f *scheduledCardDAVFixture) SetBookRoles(context.Context, int64, carddav.BookRoles) error { return nil } -func (f *scheduledCardDAVFixture) Publication(context.Context, int64) (*store.CardDAVPublication, error) { - return &store.CardDAVPublication{}, nil +func (f *scheduledCardDAVFixture) PublicationView(context.Context, int64) (*carddav.PublicationView, error) { + return &carddav.PublicationView{}, nil } func (f *scheduledCardDAVFixture) PublishPerson(context.Context, int64) error { return nil } func (f *scheduledCardDAVFixture) UnpublishPerson(context.Context, int64) error { return nil } -func (f *scheduledCardDAVFixture) ListConflicts(context.Context) ([]store.CardDAVConflict, error) { +func (f *scheduledCardDAVFixture) ListConflictViews(context.Context) ([]carddav.ConflictListItem, error) { return nil, nil } -func (f *scheduledCardDAVFixture) GetConflict(context.Context, int64) (*store.CardDAVConflict, error) { +func (f *scheduledCardDAVFixture) GetConflictView(context.Context, int64) (*carddav.ConflictDetail, error) { return nil, store.ErrCardDAVConflictNotFound } func (f *scheduledCardDAVFixture) ResolveConflict(context.Context, int64, carddav.ResolutionChoice) error { @@ -96,6 +133,8 @@ func TestReconcileCardDAVSchedulerJobUpdatesRunsAndRemovesStableJob(t *testing.T require.NoError(reconcileCardDAVSchedulerJob(sched, config.CardDAVConfig{Enabled: true, Schedule: "0 1 * * *"}, service, logger)) require.NoError(sched.TriggerJob(api.CardDAVJobName)) assert.Equal(1, service.syncs) + require.Len(service.options, 1) + assert.Equal(store.CardDAVSyncTriggerScheduled, service.options[0].Trigger) begin, done := tracker.counts() assert.Equal(1, begin) assert.Equal(1, done) diff --git a/cmd/msgvault/cmd/serve_vector.go b/cmd/msgvault/cmd/serve_vector.go index 7c9fa6655..ed42aeafe 100644 --- a/cmd/msgvault/cmd/serve_vector.go +++ b/cmd/msgvault/cmd/serve_vector.go @@ -9,6 +9,7 @@ import ( "fmt" "go.kenn.io/docbank/document/voyage" "log/slog" + "net/http" "os" "path/filepath" "slices" @@ -16,6 +17,7 @@ import ( "strings" "time" + "go.kenn.io/msgvault/internal/providercredentials" "go.kenn.io/msgvault/internal/scheduler" "go.kenn.io/msgvault/internal/store" "go.kenn.io/msgvault/internal/vector" @@ -53,6 +55,9 @@ type embeddingRuntimeDeps struct { PersonGate vector.SemanticPersonEmbeddingGate DocumentGate embed.BeforeRequestFunc QueryGate embed.BeforeRequestFunc + // APIKey is the text embedding credential resolved once from the provider + // credential store (or its environment fallback) at runtime start. + APIKey string } type legacyConvergenceChecker struct { @@ -242,14 +247,16 @@ func newEmbeddingRuntime(vectorCfg vector.Config, deps embeddingRuntimeDeps) (*e if err != nil { return nil, err } + apiKey := deps.APIKey switch vectorCfg.Embeddings.EffectiveAPIFormat() { case vector.APIFormatOpenAI: clientConfig := embed.Config{ - Endpoint: vectorCfg.Embeddings.Endpoint, APIKey: vectorCfg.Embeddings.APIKey(), + Endpoint: vectorCfg.Embeddings.Endpoint, APIKey: apiKey, Model: vectorCfg.Embeddings.Model, Dimension: vectorCfg.Embeddings.Dimension, Timeout: vectorCfg.Embeddings.Timeout, MaxRetries: vectorCfg.Embeddings.MaxRetries, - DocumentPrefix: vectorCfg.Embeddings.DocumentPrefix, - QueryPrefix: vectorCfg.Embeddings.QueryPrefix, + DocumentPrefix: vectorCfg.Embeddings.DocumentPrefix, + QueryPrefix: vectorCfg.Embeddings.QueryPrefix, + RejectRedirects: true, } messageClient := embed.NewClient(clientConfig) documentClientConfig := clientConfig @@ -291,11 +298,12 @@ func newEmbeddingRuntime(vectorCfg vector.Config, deps embeddingRuntimeDeps) (*e return nil, errors.New("voyage contextual embeddings require a document publisher backend") } clientConfig := embed.VoyageConfig{ - Endpoint: vectorCfg.Embeddings.Endpoint, APIKey: vectorCfg.Embeddings.APIKey(), + Endpoint: vectorCfg.Embeddings.Endpoint, APIKey: apiKey, Model: vectorCfg.Embeddings.Model, Dimension: vectorCfg.Embeddings.Dimension, Timeout: vectorCfg.Embeddings.Timeout, MaxRetries: vectorCfg.Embeddings.MaxRetries, - DocumentPrefix: vectorCfg.Embeddings.DocumentPrefix, - QueryPrefix: vectorCfg.Embeddings.QueryPrefix, + DocumentPrefix: vectorCfg.Embeddings.DocumentPrefix, + QueryPrefix: vectorCfg.Embeddings.QueryPrefix, + RejectRedirects: true, Limits: embed.RequestLimits{MaxDocuments: vectorCfg.Embeddings.BatchSize, MaxChunks: 16_000, MaxUTF8Bytes: contextualDocumentUTF8Limit}, } @@ -450,6 +458,30 @@ func setupVectorFeatures(ctx context.Context, mainStore *store.Store, mainPath s if err != nil { return nil, fmt.Errorf("vector embed scope: %w", err) } + credentialSnapshot, err := providercredentials.Read(cfg.TokensDir()) + if err != nil { + return nil, fmt.Errorf("load provider credentials: %w", err) + } + var embeddingAPIKey string + if vecCfg.Enabled { + embeddingAPIKey, err = resolveProviderCredentialFromSnapshot( + credentialSnapshot, providercredentials.VectorEmbeddingsID, + vecCfg.Embeddings.Endpoint, vecCfg.Embeddings.APIKeyEnv, + ) + if err != nil { + return nil, fmt.Errorf("resolve text embedding credential: %w", err) + } + } + var multimodalAPIKey string + if vecCfg.Multimodal.Enabled { + multimodalAPIKey, err = resolveProviderCredentialFromSnapshot( + credentialSnapshot, providercredentials.VectorMultimodalID, + vecCfg.Multimodal.Endpoint, vecCfg.Multimodal.APIKeyEnv, + ) + if err != nil { + return nil, fmt.Errorf("resolve visual embedding credential: %w", err) + } + } mainDB := mainStore.DB() // Resolve the dialect once from the main DSN. The worker is @@ -541,6 +573,7 @@ func setupVectorFeatures(ctx context.Context, mainStore *store.Store, mainPath s PersonGate: personGate, DocumentGate: documentVectorRequestGate(mainStore, vecCfg, "document_embedding"), QueryGate: documentVectorRequestGate(mainStore, vecCfg, "query_embedding"), + APIKey: embeddingAPIKey, }) if err != nil { _ = closeFn() @@ -626,7 +659,8 @@ func setupVectorFeatures(ctx context.Context, mainStore *store.Store, mainPath s _ = closeFn() return nil, errors.New("configure multimodal runtime: attachment content store is unavailable") } - visualRuntime, err := newVisualRuntime(ctx, vecCfg, mainStore, backend, openers[0]) + visualRuntime, err := newVisualRuntime(ctx, vecCfg, mainStore, backend, openers[0], + visualRuntimeCredential{APIKey: multimodalAPIKey}) switch { case err != nil && !vecCfg.Enabled: // Multimodal is the only configured lane: swallowing its @@ -684,7 +718,24 @@ func documentVectorRequestGate(st *store.Store, vectorCfg vector.Config, purpose } } -func newVisualRuntime(ctx context.Context, vecCfg vector.Config, mainStore *store.Store, backend vector.Backend, opener visual.StreamOpener) (*visualFeatures, error) { +type visualRuntimeCredential struct { + APIKey string + HTTPClient *http.Client +} + +func newVisualRuntime( + ctx context.Context, + vecCfg vector.Config, + mainStore *store.Store, + backend vector.Backend, + opener visual.StreamOpener, + credential visualRuntimeCredential, +) (*visualFeatures, error) { + apiKey := credential.APIKey + httpClient := credential.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } fingerprint := vecCfg.MultimodalGenerationFingerprint() var visualBackend visual.Backend switch typed := backend.(type) { @@ -773,8 +824,9 @@ func newVisualRuntime(ctx context.Context, vecCfg vector.Config, mainStore *stor providerMedia.IncludeImages = true } provider, err := visual.NewVoyageProvider(visual.VoyageConfig{ - APIKey: vecCfg.Multimodal.APIKey(), Model: vecCfg.Multimodal.Model, + APIKey: apiKey, Model: vecCfg.Multimodal.Model, Dimension: vecCfg.Multimodal.Dimension, Manifest: manifest, Media: providerMedia, + HTTPClient: providerHTTPClientWithoutRedirects(httpClient), }) if err != nil { return nil, err diff --git a/cmd/msgvault/cmd/serve_vector_visual_credentials_test.go b/cmd/msgvault/cmd/serve_vector_visual_credentials_test.go new file mode 100644 index 000000000..b9427424c --- /dev/null +++ b/cmd/msgvault/cmd/serve_vector_visual_credentials_test.go @@ -0,0 +1,119 @@ +//go:build sqlite_vec + +package cmd + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/docbank/document/media" + "go.kenn.io/docbank/document/voyage" + "go.kenn.io/docbank/document/voyage/voyagetest" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/providercredentials" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/vector/sqlitevec" + "go.kenn.io/msgvault/internal/vector/visual" +) + +type visualCredentialRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f visualCredentialRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +type unavailableVisualCredentialOpener struct{} + +func (unavailableVisualCredentialOpener) OpenStream( + context.Context, string, +) (io.ReadCloser, int64, error) { + return nil, 0, errors.New("content unavailable in credential test") +} + +func TestNewVisualRuntimeUsesStoredCredentialSnapshotAndRejectsRedirectReplay(t *testing.T) { + t.Setenv("VOYAGE_API_KEY", "") + dir := t.TempDir() + mainPath := filepath.Join(dir, "msgvault.db") + mainStore, err := store.Open(mainPath) + require.NoError(t, err) + t.Cleanup(func() { _ = mainStore.Close() }) + require.NoError(t, mainStore.InitSchema()) + backend, err := sqlitevec.Open(t.Context(), sqlitevec.Options{ + Path: filepath.Join(dir, "vectors.db"), MainPath: mainPath, + MainDB: mainStore.DB(), Dimension: 4, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = backend.Close() }) + + policy, err := voyage.NewPolicy(voyage.PolicyConfig{ + Model: voyage.DefaultModel, Dimension: 1024, + Media: media.Policy{MaxBytes: 20 << 20, MaxPixels: 16_000_000, AllowStill: true, AllowVideo: true}, + }) + require.NoError(t, err) + manifest, err := voyagetest.SyntheticManifest(policy, voyage.CapabilityQueryText) + require.NoError(t, err) + manifestPath := filepath.Join(dir, "voyage-capabilities.json") + require.NoError(t, writeVisualCapabilityManifest(manifestPath, manifest)) + + cfg := config.NewDefaultConfig() + cfg.Data.DataDir = dir + cfg.Vector.Multimodal.Enabled = true + cfg.Vector.Multimodal.CapabilitiesFile = manifestPath + empty, err := providercredentials.Read(cfg.TokensDir()) + require.NoError(t, err) + stored, err := providercredentials.Put(cfg.TokensDir(), empty.ETag, + providercredentials.VectorMultimodalID, cfg.Vector.Multimodal.Endpoint, "stored-at-startup") + require.NoError(t, err) + startup, err := providercredentials.Read(cfg.TokensDir()) + require.NoError(t, err) + apiKey, err := resolveProviderCredentialFromSnapshot(startup, + providercredentials.VectorMultimodalID, cfg.Vector.Multimodal.Endpoint, + cfg.Vector.Multimodal.APIKeyEnv) + require.NoError(t, err) + _, err = providercredentials.Put(cfg.TokensDir(), stored.ETag, + providercredentials.VectorMultimodalID, cfg.Vector.Multimodal.Endpoint, "stored-after-startup") + require.NoError(t, err) + + var authorization string + var redirectedAuthorization string + target := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + redirectedAuthorization = r.Header.Get("Authorization") + })) + t.Cleanup(target.Close) + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization = r.Header.Get("Authorization") + w.Header().Set("Location", target.URL+"/replayed") + w.WriteHeader(http.StatusTemporaryRedirect) + })) + t.Cleanup(origin.Close) + targetURL, err := url.Parse(origin.URL) + require.NoError(t, err) + transport := origin.Client().Transport + httpClient := &http.Client{Timeout: 5 * time.Second, Transport: visualCredentialRoundTripFunc( + func(request *http.Request) (*http.Response, error) { + clone := request.Clone(request.Context()) + clone.URL.Scheme = targetURL.Scheme + clone.URL.Host = targetURL.Host + return transport.RoundTrip(clone) + }, + )} + + runtime, err := newVisualRuntime( + t.Context(), cfg.Vector, mainStore, backend, unavailableVisualCredentialOpener{}, + visualRuntimeCredential{APIKey: apiKey, HTTPClient: httpClient}, + ) + require.NoError(t, err) + _, _, err = runtime.Provider.EmbedQuery(t.Context(), visual.QueryInput{Text: "private query"}) + require.Error(t, err) + assert.Equal(t, "Bearer stored-at-startup", authorization) + assert.Empty(t, redirectedAuthorization) +} diff --git a/cmd/msgvault/cmd/tui.go b/cmd/msgvault/cmd/tui.go index 7a6559f0f..a74027160 100644 --- a/cmd/msgvault/cmd/tui.go +++ b/cmd/msgvault/cmd/tui.go @@ -48,6 +48,7 @@ Navigation: Enter Drill down / view message Esc Go back m Cycle Email / Texts / Meetings / People + , Open Settings g Cycle aggregate view (Email and Texts) / Search; Tab adds active-message-only Semantic mode when enabled A Filter by account, or meeting source in Meetings mode @@ -103,6 +104,7 @@ HTTP Mode: AttachmentReader: tuiAttachmentOpener{client: backend.client}, SemanticSearch: semanticSearch, AnalyticsNotice: notice, + SettingsBackend: backend.settings, }) p := tea.NewProgram(model) noticeCtx, stopNoticeRefresh := context.WithCancel(cmd.Context()) @@ -186,10 +188,11 @@ func (o tuiAttachmentOpener) OpenAttachment(ctx context.Context, contentHash str } type tuiBackend struct { - engine *daemonclient.Engine - client *daemonclient.Client - info HTTPStoreInfo - cleanup func() + engine *daemonclient.Engine + client *daemonclient.Client + settings tui.SettingsBackend + info HTTPStoreInfo + cleanup func() } const ( @@ -270,10 +273,11 @@ func openTUIBackend(ctx context.Context) (*tuiBackend, error) { } engine := daemonclient.NewEngineAdapter(st) return &tuiBackend{ - engine: engine, - client: st, - info: info, - cleanup: func() { _ = engine.Close() }, + engine: engine, + client: st, + settings: newTUISettingsBackend(st), + info: info, + cleanup: func() { _ = engine.Close() }, }, nil } diff --git a/cmd/msgvault/cmd/tui_settings.go b/cmd/msgvault/cmd/tui_settings.go new file mode 100644 index 000000000..0f00fbcae --- /dev/null +++ b/cmd/msgvault/cmd/tui_settings.go @@ -0,0 +1,409 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "go.kenn.io/msgvault/internal/daemonclient" + "go.kenn.io/msgvault/internal/providercredentials" + "go.kenn.io/msgvault/internal/tui" +) + +const ( + tuiSettingsPath = "/api/v1/settings" + tuiSettingsCredentialPrefix = "/api/v1/settings/provider-credentials/" // #nosec G101 -- HTTP route, not a credential. + credentialETagHeader = "Credential-Etag" // #nosec G101 -- concurrency header, not a credential. +) + +type tuiDaemonSettingsBackend struct { + client *daemonclient.Client +} + +var _ tui.SettingsBackend = (*tuiDaemonSettingsBackend)(nil) + +func newTUISettingsBackend(client *daemonclient.Client) *tuiDaemonSettingsBackend { + return &tuiDaemonSettingsBackend{client: client} +} + +type tuiSettingsHTTPResponse struct { + Groups []tuiSettingsHTTPGroup `json:"groups"` + Settings []tuiSettingsHTTPField `json:"settings"` + PersonEnrichmentProviders []tuiPersonEnrichmentProvider `json:"person_enrichment_providers"` + CredentialETag string `json:"credential_etag"` + PendingRestart bool `json:"pending_restart"` +} + +type tuiSettingsHTTPGroup struct { + ID string `json:"id"` + Label string `json:"label"` +} + +type tuiSettingsHTTPField struct { + Key string `json:"key"` + CredentialID string `json:"credential_id"` + Group string `json:"group"` + Label string `json:"label"` + Description string `json:"description"` + Kind string `json:"kind"` + Value *tuiSettingsHTTPValue `json:"value"` + Secret *tuiSettingsHTTPSecret `json:"secret"` + Options []string `json:"options"` + ReadOnly bool `json:"read_only"` + RestartRequired bool `json:"restart_required"` + Validation tuiSettingsHTTPValidation `json:"validation"` +} + +type tuiSettingsHTTPValue struct { + String *string `json:"string,omitempty"` + Integer *int `json:"integer,omitempty"` + Number *float64 `json:"number,omitempty"` + Boolean *bool `json:"boolean,omitempty"` + Strings *[]string `json:"strings,omitempty"` +} + +type tuiSettingsHTTPSecret struct { + Configured bool `json:"configured"` + Source string `json:"source"` +} + +type tuiSettingsHTTPValidation struct { + Hint string `json:"hint"` + Required bool `json:"required"` + Minimum *float64 `json:"minimum"` + Maximum *float64 `json:"maximum"` +} + +type tuiPersonEnrichmentProvider struct { + Name string `json:"name"` + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + CredentialID string `json:"credential_id"` + Credential *tuiSettingsHTTPSecret `json:"credential"` +} + +func (b *tuiDaemonSettingsBackend) LoadSettings(ctx context.Context) (tui.SettingsSnapshot, error) { + if b == nil || b.client == nil { + return tui.SettingsSnapshot{}, errors.New("daemon settings client unavailable") + } + resp, err := b.client.DoGeneratedRequestWithContext(ctx, http.MethodGet, tuiSettingsPath, nil) + if err != nil { + return tui.SettingsSnapshot{}, fmt.Errorf("load settings: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return tui.SettingsSnapshot{}, daemonclient.HandleErrorResponse(resp) + } + var document tuiSettingsHTTPResponse + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(&document); err != nil { + return tui.SettingsSnapshot{}, fmt.Errorf("decode settings: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return tui.SettingsSnapshot{}, errors.New("decode settings: trailing data") + } + credentialETag := resp.Header.Get(credentialETagHeader) + if credentialETag == "" { + credentialETag = document.CredentialETag + } + return tuiSettingsSnapshot(document, resp.Header.Get("ETag"), credentialETag), nil +} + +func tuiSettingsSnapshot( + document tuiSettingsHTTPResponse, + etag string, + credentialETag string, +) tui.SettingsSnapshot { + snapshot := tui.SettingsSnapshot{ + ETag: etag, + CredentialETag: credentialETag, + PendingRestart: document.PendingRestart, + Groups: make([]tui.SettingsGroup, 0, len(document.Groups)), + Fields: make( + []tui.SettingField, + 0, + len(document.Settings)+len(document.PersonEnrichmentProviders), + ), + } + for _, group := range document.Groups { + snapshot.Groups = append(snapshot.Groups, tui.SettingsGroup{ID: group.ID, Label: group.Label}) + } + for _, field := range document.Settings { + converted := tui.SettingField{ + Key: field.Key, + CredentialID: field.CredentialID, + Group: field.Group, + Label: field.Label, + Description: field.Description, + Kind: tui.SettingKind(field.Kind), + Options: append([]string(nil), field.Options...), + ReadOnly: field.ReadOnly, + RestartRequired: field.RestartRequired, + Validation: tui.SettingValidation{ + Hint: field.Validation.Hint, Required: field.Validation.Required, + Minimum: field.Validation.Minimum, Maximum: field.Validation.Maximum, + }, + } + if field.Value != nil { + converted.Value = &tui.SettingValue{ + String: field.Value.String, Integer: field.Value.Integer, + Number: field.Value.Number, Boolean: field.Value.Boolean, + Strings: field.Value.Strings, + } + } + if field.Secret != nil { + converted.Secret = &tui.SecretSettingState{ + Configured: field.Secret.Configured, + Source: field.Secret.Source, + } + } + snapshot.Fields = append(snapshot.Fields, converted) + } + for _, provider := range document.PersonEnrichmentProviders { + if provider.Credential == nil || strings.TrimSpace(provider.CredentialID) == "" { + continue + } + status := "disabled" + if provider.Enabled { + status = "enabled" + } + snapshot.Fields = append(snapshot.Fields, tui.SettingField{ + Key: "people.enrichment.providers." + provider.Name + ".api_key", + CredentialID: provider.CredentialID, + Group: "enrichment", + Label: provider.Name + " API key", + Description: fmt.Sprintf( + "Named person-enrichment provider. Kind: %s. Status: %s. Provider policy is read-only here; edit it in Web Settings.", + provider.Kind, + status, + ), + Kind: tui.SettingKindSecret, + Secret: &tui.SecretSettingState{ + Configured: provider.Credential.Configured, + Source: provider.Credential.Source, + }, + }) + } + return snapshot +} + +type tuiSettingsRequestOptions struct { + body any + headers map[string]string +} + +func (o *tuiSettingsRequestOptions) GetPathParams() (map[string]any, error) { + return map[string]any{}, nil +} +func (o *tuiSettingsRequestOptions) GetQuery() (map[string]any, error) { + return map[string]any{}, nil +} +func (o *tuiSettingsRequestOptions) GetBody() any { return o.body } +func (o *tuiSettingsRequestOptions) GetHeader() (map[string]string, error) { return o.headers, nil } + +type tuiSettingsPatchBody struct { + Updates []tuiSettingsPatchUpdate `json:"updates"` +} + +type tuiSettingsPatchUpdate struct { + Key string `json:"key"` + Value *tuiSettingsHTTPValue `json:"value,omitempty"` + Secret *tuiSettingsPatchSecretBody `json:"secret,omitempty"` +} + +type tuiSettingsPatchSecretBody struct { + Action string `json:"action"` + Value string `json:"value,omitempty"` +} + +type tuiCredentialSetBody struct { + Value string `json:"value"` +} + +func (b *tuiDaemonSettingsBackend) SaveSettings( + ctx context.Context, + request tui.SettingsSaveRequest, +) (tui.SettingsSnapshot, error) { + if b == nil || b.client == nil { + return tui.SettingsSnapshot{}, errors.New("daemon settings client unavailable") + } + savedKeys := make([]string, 0, len(request.Updates)+len(request.ConfigSecrets)+len(request.Credentials)) + configETag := request.ConfigETag + credentialETag := request.CredentialETag + + if len(request.Updates)+len(request.ConfigSecrets) > 0 { + newETag, newCredentialETag, err := b.saveConfigSettings( + ctx, configETag, request.Updates, request.ConfigSecrets, + ) + if err != nil { + return tui.SettingsSnapshot{}, err + } + if newETag != "" { + configETag = newETag + } + if newCredentialETag != "" { + credentialETag = newCredentialETag + } + for _, update := range request.Updates { + savedKeys = append(savedKeys, update.Key) + } + for _, update := range request.ConfigSecrets { + savedKeys = append(savedKeys, update.Key) + } + } + + for _, credential := range request.Credentials { + newETag, err := b.saveProviderCredential(ctx, credentialETag, credential) + if err != nil { + if len(savedKeys) > 0 { + return tui.SettingsSnapshot{}, &tui.SettingsPartialSaveError{ + SavedKeys: append([]string(nil), savedKeys...), Err: err, + } + } + return tui.SettingsSnapshot{}, err + } + if newETag == "" { + err := errors.New("credential write returned no concurrency token") + if len(savedKeys) > 0 { + return tui.SettingsSnapshot{}, &tui.SettingsPartialSaveError{ + SavedKeys: append([]string(nil), savedKeys...), Err: err, + } + } + return tui.SettingsSnapshot{}, err + } + credentialETag = newETag + savedKeys = append(savedKeys, credential.Key) + } + + snapshot, err := b.LoadSettings(ctx) + if err != nil { + return tui.SettingsSnapshot{}, &tui.SettingsPartialSaveError{ + SavedKeys: append([]string(nil), savedKeys...), + Err: fmt.Errorf("settings saved but could not be reloaded: %w", err), + } + } + // Preserve a newly returned token when an older daemon omits one from the + // follow-up GET. Current daemons return both. + if snapshot.ETag == "" { + snapshot.ETag = configETag + } + if snapshot.CredentialETag == "" { + snapshot.CredentialETag = credentialETag + } + return snapshot, nil +} + +func (b *tuiDaemonSettingsBackend) saveConfigSettings( + ctx context.Context, + etag string, + updates []tui.SettingUpdate, + secrets []tui.ConfigSecretUpdate, +) (string, string, error) { + payload := tuiSettingsPatchBody{Updates: make([]tuiSettingsPatchUpdate, 0, len(updates)+len(secrets))} + for _, update := range updates { + payload.Updates = append(payload.Updates, tuiSettingsPatchUpdate{ + Key: update.Key, Value: tuiSettingsHTTPValueFromTUI(update.Value), + }) + } + for _, update := range secrets { + payload.Updates = append(payload.Updates, tuiSettingsPatchUpdate{ + Key: update.Key, + Secret: &tuiSettingsPatchSecretBody{ + Action: update.Action, Value: update.Value, + }, + }) + } + resp, err := b.client.DoGeneratedRequestWithContext( + ctx, + http.MethodPatch, + tuiSettingsPath, + &tuiSettingsRequestOptions{ + body: payload, headers: map[string]string{"If-Match": etag}, + }, + ) + if err != nil { + return "", "", fmt.Errorf("save settings: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusPreconditionFailed { + return "", "", &tui.SettingsConflictError{ + Scope: tui.SettingsConflictConfig, + Err: daemonclient.HandleErrorResponse(resp), + } + } + if resp.StatusCode != http.StatusOK { + return "", "", daemonclient.HandleErrorResponse(resp) + } + return resp.Header.Get("ETag"), resp.Header.Get(credentialETagHeader), nil +} + +func tuiSettingsHTTPValueFromTUI(value *tui.SettingValue) *tuiSettingsHTTPValue { + if value == nil { + return nil + } + return &tuiSettingsHTTPValue{ + String: value.String, Integer: value.Integer, Number: value.Number, + Boolean: value.Boolean, Strings: value.Strings, + } +} + +func (b *tuiDaemonSettingsBackend) saveProviderCredential( + ctx context.Context, + etag string, + update tui.CredentialUpdate, +) (string, error) { + provider, err := validateTUICredentialID(update.CredentialID) + if err != nil { + return "", err + } + method := http.MethodPut + var body any = tuiCredentialSetBody{Value: update.Value} + switch update.Action { + case "set": + if update.Value == "" { + return "", errors.New("credential value is required") + } + case "clear": + method = http.MethodDelete + body = nil + default: + return "", fmt.Errorf("unsupported credential action %q", update.Action) + } + resp, err := b.client.DoGeneratedRequestWithContext( + ctx, + method, + tuiSettingsCredentialPrefix+url.PathEscape(provider), + &tuiSettingsRequestOptions{ + body: body, headers: map[string]string{"If-Match": etag}, + }, + ) + if err != nil { + return "", fmt.Errorf("save provider credential: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusPreconditionFailed { + return "", &tui.SettingsConflictError{ + Scope: tui.SettingsConflictCredentials, + Err: daemonclient.HandleErrorResponse(resp), + } + } + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return "", daemonclient.HandleErrorResponse(resp) + } + return resp.Header.Get("ETag"), nil +} + +func validateTUICredentialID(id string) (string, error) { + if id == "" { + return "", errors.New("provider credential ID is required") + } + if err := providercredentials.ValidateID(id); err != nil { + return "", fmt.Errorf("unsupported provider credential ID %q: %w", id, err) + } + return id, nil +} diff --git a/cmd/msgvault/cmd/tui_settings_test.go b/cmd/msgvault/cmd/tui_settings_test.go new file mode 100644 index 000000000..69c0fa4ad --- /dev/null +++ b/cmd/msgvault/cmd/tui_settings_test.go @@ -0,0 +1,354 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/api" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/daemonclient" + "go.kenn.io/msgvault/internal/tui" +) + +func TestTUISettingsBackendLoadsSelfDescribingCatalog(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/settings", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", `"config-1"`) + _, _ = io.WriteString(w, `{ + "groups":[{"id":"archive","label":"Archive"}], + "settings":[{ + "key":"analytics.engine","group":"archive","label":"Analytics engine", + "description":"Select the aggregate query engine.","kind":"string", + "value":{"string":"auto"},"options":["auto","sql","duckdb"], + "restart_required":true,"read_only":false, + "validation":{"hint":"Choose a supported engine.","required":true} + }], + "credential_etag":"\"credentials-1\"", + "pending_restart":true +}`) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + snapshot, err := backend.LoadSettings(context.Background()) + requirements.NoError(err) + assertions.Equal(`"config-1"`, snapshot.ETag) + assertions.Equal(`"credentials-1"`, snapshot.CredentialETag) + assertions.True(snapshot.PendingRestart) + requirements.Len(snapshot.Groups, 1) + assertions.Equal("Archive", snapshot.Groups[0].Label) + requirements.Len(snapshot.Fields, 1) + field := snapshot.Fields[0] + assertions.Equal("analytics.engine", field.Key) + assertions.Equal("Analytics engine", field.Label) + assertions.Equal(tui.SettingKindString, field.Kind) + assertions.True(field.RestartRequired) + assertions.Equal("Choose a supported engine.", field.Validation.Hint) + requirements.NotNil(field.Value) + requirements.NotNil(field.Value.String) + assertions.Equal("auto", *field.Value.String) +} + +func TestTUISettingsBackendSeparatesConfigAndCredentialWrites(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + const ( + providerSecret = "provider-secret-only-in-credential-request" + configSecret = "legacy-config-secret-only-in-settings-patch" + ) + var patchBody, credentialBody string + var methods []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method+" "+r.URL.Path) + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/settings": + patchBody = string(body) + assert.Equal(t, `"config-old"`, r.Header.Get("If-Match")) + w.Header().Set("ETag", `"config-new"`) + w.Header().Set("Credential-Etag", `"credentials-after-patch"`) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"settings":[],"groups":[],"pending_restart":true}`) + case r.Method == http.MethodPut && r.URL.Path == "/api/v1/settings/provider-credentials/vector.embeddings": + credentialBody = string(body) + assert.Equal(t, `"credentials-after-patch"`, r.Header.Get("If-Match")) + w.Header().Set("ETag", `"credentials-new"`) + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/settings": + w.Header().Set("ETag", `"config-new"`) + w.Header().Set("Credential-Etag", `"credentials-new"`) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "groups":[{"id":"search","label":"Search"}], + "settings":[{"key":"vector.embeddings.api_key","group":"search","label":"Embedding API key","description":"Write-only credential.","kind":"secret","secret":{"configured":true,"source":"stored"},"restart_required":true}], + "pending_restart":true +}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + value := false + + snapshot, err := backend.SaveSettings(context.Background(), tui.SettingsSaveRequest{ + ConfigETag: `"config-old"`, + CredentialETag: `"credentials-old"`, + Updates: []tui.SettingUpdate{{ + Key: "analytics.auto_build_cache", Value: &tui.SettingValue{Boolean: &value}, + }}, + ConfigSecrets: []tui.ConfigSecretUpdate{{ + Key: "integrations.tasks.api_key", Action: "set", Value: configSecret, + }}, + Credentials: []tui.CredentialUpdate{{ + Key: "vector.embeddings.api_key", CredentialID: "vector.embeddings", + Action: "set", Value: providerSecret, + }}, + }) + requirements.NoError(err) + assertions.Equal([]string{ + "PATCH /api/v1/settings", + "PUT /api/v1/settings/provider-credentials/vector.embeddings", + "GET /api/v1/settings", + }, methods) + assertions.NotContains(patchBody, providerSecret) + assertions.Contains(patchBody, configSecret) + assertions.Contains(patchBody, `"secret":{"action":"set"`) + assertions.NotContains(patchBody, "provider-credentials") + assertions.NotContains(credentialBody, configSecret) + assertions.Contains(credentialBody, providerSecret) + assertions.Equal(`"config-new"`, snapshot.ETag) + assertions.Equal(`"credentials-new"`, snapshot.CredentialETag) + requirements.Len(snapshot.Fields, 1) + requirements.NotNil(snapshot.Fields[0].Secret) + assertions.Equal("stored", snapshot.Fields[0].Secret.Source) +} + +func TestTUISettingsBackendClassifiesStaleCredentialETag(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, `"credentials-stale"`, r.Header.Get("If-Match")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusPreconditionFailed) + _, _ = io.WriteString(w, `{"error":"settings_conflict","message":"The credential store changed"}`) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + _, err := backend.SaveSettings(context.Background(), tui.SettingsSaveRequest{ + ConfigETag: `"config-current"`, + CredentialETag: `"credentials-stale"`, + Credentials: []tui.CredentialUpdate{{ + Key: "vector.multimodal.api_key", CredentialID: "vector.multimodal", Action: "clear", + }}, + }) + var conflict *tui.SettingsConflictError + require.ErrorAs(t, err, &conflict) + assert.Equal(t, tui.SettingsConflictCredentials, conflict.Scope) +} + +func TestTUISettingsBackendUsesDaemonCredentialIDsForNamedProviders(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + var credentialPaths []string + credentialETag := `"credentials-start"` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", `"config-current"`) + w.Header().Set("Credential-Etag", credentialETag) + _, _ = io.WriteString(w, `{ + "groups":[{"id":"enrichment","label":"Person enrichment"}], + "settings":[], + "person_enrichment_providers":[ + {"name":"exa-primary","kind":"exa","enabled":true,"credential_id":"people.enrichment/exa-primary","credential":{"configured":false,"source":"none"}}, + {"name":"sixtyfour-primary","kind":"sixtyfour","enabled":false,"credential_id":"people.enrichment/sixtyfour-primary","credential":{"configured":false,"source":"none"}} + ], + "pending_restart":false +}`) + case http.MethodPut: + credentialPaths = append(credentialPaths, r.URL.EscapedPath()) + assert.Equal(t, credentialETag, r.Header.Get("If-Match")) + credentialETag = `"credentials-next-` + string(rune('0'+len(credentialPaths))) + `"` + w.Header().Set("ETag", credentialETag) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"configured":true,"source":"stored"}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + loaded, err := backend.LoadSettings(context.Background()) + requirements.NoError(err) + requirements.Len(loaded.Fields, 2) + assertions.Equal("exa-primary API key", loaded.Fields[0].Label) + assertions.Contains(loaded.Fields[0].Description, "Kind: exa") + assertions.Contains(loaded.Fields[0].Description, "Status: enabled") + assertions.Equal("people.enrichment/exa-primary", loaded.Fields[0].CredentialID) + assertions.Contains(loaded.Fields[1].Description, "Kind: sixtyfour") + assertions.Contains(loaded.Fields[1].Description, "Status: disabled") + assertions.Equal("people.enrichment/sixtyfour-primary", loaded.Fields[1].CredentialID) + + _, err = backend.SaveSettings(context.Background(), tui.SettingsSaveRequest{ + ConfigETag: loaded.ETag, CredentialETag: loaded.CredentialETag, + Credentials: []tui.CredentialUpdate{ + { + Key: loaded.Fields[0].Key, CredentialID: loaded.Fields[0].CredentialID, + Action: "set", Value: "exa-test-secret", + }, + { + Key: loaded.Fields[1].Key, CredentialID: loaded.Fields[1].CredentialID, + Action: "set", Value: "sixtyfour-test-secret", + }, + }, + }) + requirements.NoError(err) + assertions.Equal([]string{ + "/api/v1/settings/provider-credentials/people.enrichment%2Fexa-primary", + "/api/v1/settings/provider-credentials/people.enrichment%2Fsixtyfour-primary", + }, credentialPaths) +} + +func TestTUISettingsBackendWritesEncodedNamedProviderIDThroughRegisteredRouter(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + const secret = "registered-router-secret-must-not-render" + const providerName = "exa:primary..v1" + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + requirements.NoError(os.WriteFile(configPath, []byte(fmt.Sprintf(` +[[people.enrichment.providers]] +name = %q +kind = "exa" +enabled = false +`, providerName)), 0o600)) + cfg, err := config.Load(configPath, "") + requirements.NoError(err) + apiServer := api.NewServer(cfg, nil, nil, slog.New(slog.DiscardHandler)) + server := httptest.NewServer(apiServer.Router()) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + loaded, err := backend.LoadSettings(context.Background()) + requirements.NoError(err) + requirements.NotEmpty(loaded.CredentialETag) + var credentialField tui.SettingField + for _, field := range loaded.Fields { + if field.CredentialID == "people.enrichment/"+providerName { + credentialField = field + break + } + } + requirements.Equal("people.enrichment/"+providerName, credentialField.CredentialID) + assertions.Contains(credentialField.Description, "Kind: exa") + stored, err := backend.SaveSettings(context.Background(), tui.SettingsSaveRequest{ + ConfigETag: loaded.ETag, CredentialETag: loaded.CredentialETag, + Credentials: []tui.CredentialUpdate{{ + Key: credentialField.Key, CredentialID: credentialField.CredentialID, + Action: "set", Value: secret, + }}, + }) + requirements.NoError(err) + assertions.NotEqual(loaded.CredentialETag, stored.CredentialETag) + + cleared, err := backend.SaveSettings(context.Background(), tui.SettingsSaveRequest{ + ConfigETag: stored.ETag, CredentialETag: stored.CredentialETag, + Credentials: []tui.CredentialUpdate{{ + Key: credentialField.Key, CredentialID: credentialField.CredentialID, + Action: "clear", + }}, + }) + requirements.NoError(err) + assertions.NotEqual(stored.CredentialETag, cleared.CredentialETag) +} + +func TestTUISettingsBackendMapsValidationMetadataFromRegisteredRouter(t *testing.T) { + requirements := require.New(t) + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + requirements.NoError(os.WriteFile(configPath, nil, 0o600)) + cfg, err := config.Load(configPath, "") + requirements.NoError(err) + apiServer := api.NewServer(cfg, nil, nil, slog.New(slog.DiscardHandler)) + server := httptest.NewServer(apiServer.Router()) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + loaded, err := backend.LoadSettings(context.Background()) + requirements.NoError(err) + var activityBatch tui.SettingField + for _, field := range loaded.Fields { + if field.Key == "activity.batch_size" { + activityBatch = field + break + } + } + requirements.Equal("activity.batch_size", activityBatch.Key) + requirements.NotNil(activityBatch.Validation.Minimum) + requirements.NotNil(activityBatch.Validation.Maximum) + assert.InDelta(t, float64(1), *activityBatch.Validation.Minimum, 0) + assert.InDelta(t, float64(10_000), *activityBatch.Validation.Maximum, 0) +} + +func TestTUISettingsBackendReportsPartialSaveWithoutSecretLeak(t *testing.T) { + const secret = "partial-provider-secret-must-not-leak" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPatch: + w.Header().Set("ETag", `"config-new"`) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"settings":[],"groups":[],"pending_restart":true}`) + case http.MethodPut: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":"credential_write_failed","message":"Could not store credential"}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + value := true + + _, err := backend.SaveSettings(context.Background(), tui.SettingsSaveRequest{ + ConfigETag: `"config-old"`, + CredentialETag: `"credentials-old"`, + Updates: []tui.SettingUpdate{{ + Key: "analytics.auto_build_cache", Value: &tui.SettingValue{Boolean: &value}, + }}, + Credentials: []tui.CredentialUpdate{{ + Key: "vector.embeddings.api_key", CredentialID: "vector.embeddings", + Action: "set", Value: secret, + }}, + }) + var partial *tui.SettingsPartialSaveError + require.ErrorAs(t, err, &partial) + assert.Equal(t, []string{"analytics.auto_build_cache"}, partial.SavedKeys) + assert.NotContains(t, err.Error(), secret) +} + +func newTUISettingsDaemonClient(t *testing.T, server *httptest.Server) *daemonclient.Client { + t.Helper() + client, err := daemonclient.New(daemonclient.Config{ + URL: server.URL, AllowInsecure: true, HTTPClient: server.Client(), + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, client.Close()) }) + return client +} diff --git a/cmd/msgvault/cmd/tui_test.go b/cmd/msgvault/cmd/tui_test.go index 335766219..26d8d13be 100644 --- a/cmd/msgvault/cmd/tui_test.go +++ b/cmd/msgvault/cmd/tui_test.go @@ -52,6 +52,7 @@ func TestOpenTUIEngineUsesConfiguredRemoteHTTP(t *testing.T) { "TUI backend should expose daemon-backed text queries") assert.Implements((*peoplebrowser.Backend)(nil), daemonclient.NewPeopleBrowser(backend.engine), "TUI backend should expose the daemon-backed People wrapper") + assert.NotNil(backend.settings, "TUI backend should expose daemon-backed settings") assert.Equal("remote@example.com", accounts[0].Identifier) assert.Equal("gmail", accounts[0].SourceType) assert.Equal(int32(1), requests.Load()) diff --git a/docs/api-server.md b/docs/api-server.md index 0c37fa20d..d4942e8be 100644 --- a/docs/api-server.md +++ b/docs/api-server.md @@ -13,7 +13,7 @@ background sync scheduler to keep accounts up to date on a cron-based schedule. The complete UI is embedded in the release binary; see [Web UI](/web-ui/) for browser login, secure remote deployment, search states, and keyboard controls. -The API is registered through Huma and exposes a generated OpenAPI document at `/openapi.json`. You can also run `msgvault openapi` to print the same checked-in contract without starting a daemon or opening the archive database. The OpenAPI `info.version` is the API schema version used for client/server compatibility; the current schema is 2.14.0. The running daemon binary version is exposed separately in the generated document metadata. The API queries the same archive database and attachment store as the CLI, Web UI, and TUI. SQLite is the default archive database; PostgreSQL is supported when `[data].database_url` is a PostgreSQL DSN. Keyword search and ordinary archive reads stay local to that database. If vector search is enabled, semantic and hybrid search also call the embedding endpoint configured in `[vector.embeddings]`. The server is designed for interactive archive use, local integrations, dashboards, and automation scripts. +The API is registered through Huma and exposes a generated OpenAPI document at `/openapi.json`. You can also run `msgvault openapi` to print the same checked-in contract without starting a daemon or opening the archive database. The OpenAPI `info.version` is the API schema version used for client/server compatibility; the current schema is 2.15.0. Within the unreleased 2.x line, 2.14.0 replaces the CardDAV publication and conflict response shapes with bounded projections that omit raw vCards and resource hrefs. The running daemon binary version is exposed separately in the generated document metadata. The API queries the same archive database and attachment store as the CLI, Web UI, and TUI. SQLite is the default archive database; PostgreSQL is supported when `[data].database_url` is a PostgreSQL DSN. Keyword search and ordinary archive reads stay local to that database. If vector search is enabled, semantic and hybrid search also call the embedding endpoint configured in `[vector.embeddings]`. The server is designed for interactive archive use, local integrations, dashboards, and automation scripts. Go integrations can use the generated client in `pkg/client`. The wrapper handles msgvault-specific response details such as deletion staging dry-runs @@ -77,6 +77,41 @@ If no `api_key` is configured, authentication is not required regardless of bind ## API Endpoints +### Curated person network {#get-apiv1peopleidnetwork} + +**Endpoint:** `GET /api/v1/people/{id}/network` + +Returns a read-only, person-centred projection built only from durable typed +relationships and employments. It never derives nodes or edges from messages, +conversations, participant co-occurrence, or the analytical cache. + +`depth` defaults to `1` and accepts `1`, `2`, or `3`. `include_ended` defaults +to `false`; set it to `true` to admit ended relationships and employment +records. The deterministic breadth-first response contains at most 250 nodes +and 500 edges. `truncated: true` means the response is a bounded prefix and can +contain fewer than either maximum. The root durable person is returned even +when it has no qualifying connections. + +```json +{ + "root_person_id": 42, + "depth": 2, + "truncated": false, + "nodes": [ + {"id": "person:42", "kind": "person", "entity_id": 42, "label": "Example Person", "hop": 0}, + {"id": "organization:21", "kind": "organization", "entity_id": 21, "label": "Example Organization", "hop": 1} + ], + "edges": [ + {"id": "employment:7", "kind": "employment", "source_node_id": "person:42", "target_node_id": "organization:21", "label": "Engineer"} + ] +} +``` + +Invalid depths return `400`; an unknown durable person returns `404`. The +projection has no ETag because it is not a mutation resource. + +--- + ### Health check {#get-health} **Endpoint:** `GET /health` diff --git a/docs/changelog.md b/docs/changelog.md index e9a3afae6..01927a17c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,7 +12,9 @@ All notable changes to msgvault, grouped by release. - The HTTP API separates observed participant analytics from durable curated people, crossing the API schema 2.0 compatibility boundary at 2.1.0. The - current unreleased API schema is 2.14.0. The + current unreleased API schema is 2.15.0. Version 2.14.0 also replaces the CardDAV + publication and conflict response shapes with bounded projections that + omit raw vCards and resource hrefs. The analytical routes formerly under `/api/v1/people/*` (search, detail, summary, timeline, files) now live under `/api/v1/participants/*`, and the durable person routes formerly under `/api/v1/persons/*` now live under @@ -32,6 +34,27 @@ All notable changes to msgvault, grouped by release. **Features** +- Web Directory workspace: browse and search promoted durable people, filter + by contact state, category, organization, and last contact, and maintain a + person's profile, custom fields, employment, typed relationships, tracking, + CardDAV publication, and merge history in place. Identity-match and + imported-relationship review queues, explicit merge and split, and a + bounded person network (`GET /api/v1/people/{id}/network`) built only + from curated relationships and employments live in the same shell. + +- Settings workspace in the Web UI and a keyboard-only Settings screen in + the TUI (`,`), driven by a daemon-described catalog with restart-pending + state. Provider API keys are stored write-only in + `tokens/provider-credentials.json` and can be added, replaced, or removed + without revealing their values; named Exa and SixtyFour person-enrichment + policies, text and visual embedding configuration, and future-only + attachment download rules are editable from the browser. + +- Operation history: `GET /api/v1/operations/runs` and `/operations/status` + expose normalized sync, person-sweep, and CardDAV run history with stable + cursors, and CardDAV sync runs are recorded and recoverable after a + daemon restart. + - Starting in v0.20.0, remote deletion remains permanently opt-in. The invoking CLI can grant durable consent with `[deletion] remote_enabled = true`; `MSGVAULT_ENABLE_REMOTE_DELETE=1` diff --git a/docs/configuration.md b/docs/configuration.md index 02a2ce756..ef9558c34 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -867,6 +867,25 @@ External OpenAI-compatible embedding endpoint used to convert message text into | `max_input_chars` | `32768` | Character cap per embedding chunk. Set below your model's context window (e.g., `2000` for Ollama's default `nomic-embed-text`). | | `eta_window` | `10` | Number of recent progress samples used for ETA smoothing. | +##### Stored provider credentials + +Instead of naming an environment variable in `api_key_env`, you can store a +provider API key through Settings in the Web UI or the TUI. Stored keys live in +`tokens/provider-credentials.json` under the data directory with owner-only +file permissions. They are never written to `config.toml` and are never shown +again after saving; Settings only reports whether a key is configured and +whether it comes from the store or from the environment. + +A stored key takes precedence over the environment variable named by +`api_key_env`. Each stored key is bound to the endpoint origin (scheme, host, +and port) it was saved for. If you later change the endpoint to another origin, +the stored key is removed automatically and must be entered again, so a key +is never sent to a host it was not entered for. + +Changing a stored key for vector or multimodal (visual) embeddings requires a daemon +restart, like the other `[vector]` settings. Person enrichment and sweep keys +apply on the next run. + The index generation fingerprint includes the model, dimension, document and query prefixes, preprocessing settings, `max_input_chars`, embedding policy, and scope. Changing those settings triggers a stale-index error on the next vector/hybrid query. For an existing account-scoped generation built with CLI flags, set matching `[vector.embed.scope].accounts` and restart the daemon; otherwise run `msgvault embeddings build --full-rebuild`. #### `[vector.preprocess]` diff --git a/docs/usage/tui.md b/docs/usage/tui.md index a30c1761f..7842cb3e3 100644 --- a/docs/usage/tui.md +++ b/docs/usage/tui.md @@ -223,6 +223,7 @@ Press `Esc` to return to the message list. | `d` | Stage selected for deletion | | `D` | Stage all matching current filter | | `/` | Search | +| `,` | Open Settings (keyboard-only; unavailable while a search box or modal is open) | | `?` | Help | | `q` | Quit | diff --git a/docs/web-ui.md b/docs/web-ui.md index c22f1f38b..154a91597 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -1,4 +1,5 @@ --- +last_edited: 2026-08-28 title: Web UI description: Use and securely deploy msgvault's daemon-served analytical interface. --- @@ -150,6 +151,32 @@ that mean “me,” explicit durable profile promotion, display-name overrides, typed profile attributes are separate curated operations; see [People, Profiles, and Source Identities](/usage/people/). +Directory is the curated durable-person workspace. Its person detail keeps +Overview, Organizations, Relationships, Network, and Media & Files together. +The Network tab can request one, two, or three hops and optionally include +ended records. It visualizes at most 250 nodes and 500 connections, while an +always-present list groups the same connections by hop for keyboard and screen +reader use. Person and organization names in this view come from durable +profiles. Edges come only from curated typed relationships and employments +(including shared organizations), never messages, participant co-occurrence, +or inferred communication activity. + +Settings is a daemon-described workspace: the daemon publishes the catalog of +editable keys with their groups, kinds, and allowed values, and the browser +renders that catalog rather than a hard-coded form. Saving writes +`config.toml` on the daemon host. Keys marked restart-required show a +pending-restart state until the daemon restarts. Person enrichment uses named +provider policies for Exa and SixtyFour; the workspace can create and edit +those policies, and the TUI shows them read-only. + +Provider credentials (embedding, enrichment, and sweep API keys) are +write-only. You can add, replace, or remove a key; the value is never shown +again after saving, only whether one is configured and where it comes from. +Credentials are versioned separately from `config.toml`. When both an endpoint +or model change and a credential change are pending, save the endpoint/model +change first, then the credential, so the key is bound to the endpoint it was +entered for. + Domains provides the same activity-and-files analysis for an exact domain fact. A domain is not treated as an inferred organization identity. Selecting a grouped person or domain in Everything opens its inspector in the current @@ -226,9 +253,10 @@ integration keys. It performs targeted, comment-preserving edits to never displays secret values—only whether they are configured. Most settings are restart-required by design. After saving, the UI shows a -pending-restart state until the daemon restarts. An API-key change requires an -extra confirmation. The current process keeps the active key until restart; -after restart, old browser sessions are gone and the login screen appears. +pending-restart state until the daemon restarts. The server API key +(`server.api_key`) is read-only in the browser; it can only be changed in +`config.toml` on the daemon host. After a key change and restart, old browser +sessions are gone and the login screen appears. ## Optional integration states diff --git a/internal/api/attribute_definitions_test.go b/internal/api/attribute_definitions_test.go index 92648e5cf..8d7d95943 100644 --- a/internal/api/attribute_definitions_test.go +++ b/internal/api/attribute_definitions_test.go @@ -116,6 +116,106 @@ func TestAttributeDefinitionsHTTPCreateDerivesSlug(t *testing.T) { assert.Equal("favorite_color", created.Slug) } +func TestAttributeDefinitionsHTTPWebSafeChoicesRoundTripThroughPersonWrites(t *testing.T) { + tests := []struct { + name string + valueType string + choices []string + canonical []string + maxLength int + values []json.RawMessage + }{ + { + name: "text", valueType: "text", + choices: []string{"\u0085😀\uFEFF\u0085"}, + canonical: []string{"😀\uFEFF"}, + maxLength: 2, + values: []json.RawMessage{ + []byte(`{"type":"text","text":"😀\uFEFF"}`), + }, + }, + { + name: "integer", valueType: "integer", + choices: []string{"-9007199254740991", "9007199254740991"}, + values: []json.RawMessage{ + []byte(`{"type":"integer","integer":-9007199254740991}`), + []byte(`{"type":"integer","integer":9007199254740991}`), + }, + }, + { + name: "real", valueType: "real", + choices: []string{"-999999.5", "0", "0.0001", "999999.5"}, + values: []json.RawMessage{ + []byte(`{"type":"real","real":-999999.5}`), + []byte(`{"type":"real","real":0}`), + []byte(`{"type":"real","real":0.0001}`), + []byte(`{"type":"real","real":999999.5}`), + }, + }, + { + name: "timestamp", valueType: "timestamp", + choices: []string{ + "0000-01-01T00:00:00Z", + "2026-01-01T00:00:00.123456789Z", + "9999-12-31T23:59:59Z", + }, + values: []json.RawMessage{ + []byte(`{"type":"timestamp","timestamp":"0000-01-01T00:00:00Z"}`), + []byte(`{"type":"timestamp","timestamp":"2026-01-01T00:00:00.123456789Z"}`), + []byte(`{"type":"timestamp","timestamp":"9999-12-31T23:59:59Z"}`), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, person := newPersonAttributeFixture(t) + choices := make([]map[string]string, 0, len(test.choices)) + for _, value := range test.choices { + choices = append(choices, map[string]string{"value": value, "label": value}) + } + options := map[string]any{"choices": choices} + if test.maxLength > 0 { + options["max_length"] = test.maxLength + } + createBody, err := json.Marshal(map[string]any{ + "object_type": "person", "slug": "web_safe_" + test.name, + "label": "Web safe " + test.name, "value_type": test.valueType, + "field_type": "multiselect", "cardinality": "multi", + "options": options, + }) + require.NoError(err) + createdResponse := attributeRequest(t, srv, http.MethodPost, + "/api/v1/attribute-definitions", createBody, "") + require.Equal(http.StatusCreated, createdResponse.Code, createdResponse.Body.String()) + var created store.AttributeDefinition + require.NoError(json.Unmarshal(createdResponse.Body.Bytes(), &created)) + require.NotNil(created.Options) + wantChoices := test.canonical + if wantChoices == nil { + wantChoices = test.choices + } + require.Len(created.Options.Choices, len(wantChoices)) + for i, choice := range created.Options.Choices { + assert.Equal(wantChoices[i], choice.Value) + } + assert.Equal(test.maxLength, created.Options.MaxLength) + + for _, value := range test.values { + writeBody, marshalErr := json.Marshal(map[string]any{ + "value": value, "source": "user", + }) + require.NoError(marshalErr) + written := attributeRequest(t, srv, http.MethodPut, + personAttributesPath(person)+"/"+created.Slug, writeBody, "") + require.Equal(http.StatusOK, written.Code, written.Body.String()) + } + }) + } +} + func TestAttributeDefinitionsHTTPProtectsSeedAndRejectsUnknownFields(t *testing.T) { assert := assert.New(t) require := require.New(t) diff --git a/internal/api/carddav.go b/internal/api/carddav.go index e4fe4a384..a15eb12c7 100644 --- a/internal/api/carddav.go +++ b/internal/api/carddav.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "io" + "log/slog" "net" "net/http" "net/url" @@ -22,20 +24,21 @@ import ( ) var ( - errCardDAVValidation = errors.New("invalid CardDAV request") - errCardDAVUpstream = errors.New("CardDAV upstream failure") - errCardDAVStorage = errors.New("CardDAV storage failure") + errCardDAVValidation = errors.New("invalid CardDAV request") + errCardDAVUpstream = errors.New("CardDAV upstream failure") + errCardDAVStorage = errors.New("CardDAV storage failure") + errCardDAVUnavailable = errors.New("CardDAV status unavailable") ) type CardDAVOperations interface { Sync(ctx context.Context, options carddav.SyncOptions) (carddav.SyncResult, error) ListBooks(ctx context.Context) ([]store.CardDAVAddressBook, error) SetBookRoles(ctx context.Context, bookID int64, roles carddav.BookRoles) error - Publication(ctx context.Context, personID int64) (*store.CardDAVPublication, error) + PublicationView(ctx context.Context, personID int64) (*carddav.PublicationView, error) PublishPerson(ctx context.Context, personID int64) error UnpublishPerson(ctx context.Context, personID int64) error - ListConflicts(ctx context.Context) ([]store.CardDAVConflict, error) - GetConflict(ctx context.Context, conflictID int64) (*store.CardDAVConflict, error) + ListConflictViews(ctx context.Context) ([]carddav.ConflictListItem, error) + GetConflictView(ctx context.Context, conflictID int64) (*carddav.ConflictDetail, error) ResolveConflict(ctx context.Context, conflictID int64, choice carddav.ResolutionChoice) error } @@ -50,20 +53,18 @@ type cardDAVServiceFactory func(*store.Store, string, string, string) (cardDAVCa // CardDAVController owns the currently configured shared service and the // discovery-first account setup transaction. type CardDAVController struct { - mu sync.RWMutex - saveMu sync.Mutex - cfg *config.Config - store *store.Store - service CardDAVOperations - factory cardDAVServiceFactory - persistDiscovery func(context.Context, cardDAVCandidate, string, string, carddav.Discovery, bool) error - saveConfig func(*config.CardDAVConfig, config.CardDAVConfig) (config.CardDAVConfig, error) - saveCredential func(string, carddav.Credential) error - loadCredential func(string) (carddav.Credential, error) - saveLegacyPassword func(string, string) error - loadLegacyPassword func(string) (string, error) - removeCredential func(string) error - reconcileSchedule func(config.CardDAVConfig, CardDAVOperations) error + mu sync.RWMutex + saveMu sync.Mutex + cfg *config.Config + store *store.Store + service CardDAVOperations + factory cardDAVServiceFactory + persistDiscovery func(context.Context, cardDAVCandidate, string, string, carddav.Discovery, bool) error + saveConfig func(*config.CardDAVConfig, config.CardDAVConfig) (config.CardDAVConfig, error) + saveCredential func(string, carddav.Credential) error + loadCredential func(string) (carddav.Credential, error) + removeCredential func(string) error + reconcileSchedule func(config.CardDAVConfig, CardDAVOperations) error } // SetScheduleReconciler wires the daemon's live scheduler into successful @@ -74,12 +75,16 @@ func (c *CardDAVController) SetScheduleReconciler(reconcile func(config.CardDAVC c.reconcileSchedule = reconcile } -func NewCardDAVController(cfg *config.Config, st *store.Store) (*CardDAVController, error) { +// NewCardDAVController loads the saved account and credential. A credential +// that cannot be read leaves CardDAV unavailable for repair rather than +// failing daemon startup; the cause is logged so the operator can see why. +func NewCardDAVController(cfg *config.Config, st *store.Store, logger *slog.Logger) (*CardDAVController, error) { + if logger == nil { + return nil, errors.New("CardDAV controller requires a logger") + } c := &CardDAVController{cfg: cfg, store: st, factory: newCardDAVService} c.saveCredential = carddav.SaveCredential c.loadCredential = carddav.LoadCredential - c.saveLegacyPassword = carddav.SavePassword - c.loadLegacyPassword = carddav.LoadLegacyPassword c.removeCredential = carddav.RemoveCredential c.persistDiscovery = func(ctx context.Context, service cardDAVCandidate, baseURL, username string, discovery carddav.Discovery, credentialsChanged bool) error { return service.PersistDiscovery(ctx, baseURL, username, discovery, credentialsChanged) @@ -103,7 +108,9 @@ func NewCardDAVController(cfg *config.Config, st *store.Store) (*CardDAVControll return c, nil } if legacyErr != nil { - return nil, legacyErr + logger.Warn("CardDAV legacy credential is unreadable; CardDAV stays unavailable until the account is repaired", + "error", legacyErr) + return c, nil } if account == nil || account.ConnectionGeneration <= 0 || configured.BaseURL != account.BaseURL || configured.Username != account.Username { @@ -119,7 +126,9 @@ func NewCardDAVController(cfg *config.Config, st *store.Store) (*CardDAVControll } else if errors.Is(err, os.ErrNotExist) { return c, nil } else if err != nil { - return nil, err + logger.Warn("CardDAV credential is unreadable; CardDAV stays unavailable until the account is repaired", + "error", err) + return c, nil } if account == nil || credential.BaseURL != configured.BaseURL || credential.Username != configured.Username || credential.BaseURL != account.BaseURL || credential.Username != account.Username || @@ -187,12 +196,6 @@ func (c *CardDAVController) ensureDependencies() { if c.loadCredential == nil { c.loadCredential = carddav.LoadCredential } - if c.saveLegacyPassword == nil { - c.saveLegacyPassword = carddav.SavePassword - } - if c.loadLegacyPassword == nil { - c.loadLegacyPassword = carddav.LoadLegacyPassword - } if c.removeCredential == nil { c.removeCredential = carddav.RemoveCredential } @@ -259,6 +262,15 @@ func (c *CardDAVController) Save(ctx context.Context, req CardDAVAccountRequest) if err := validateCardDAVAccountRequest(req); err != nil { return CardDAVAccountResponse{}, err } + next := config.CardDAVConfig{ + BaseURL: req.BaseURL, Username: req.Username, Enabled: *req.Enabled, Schedule: req.Schedule, + } + current := c.cardDAVConfigSnapshot() + identityChanged := current.BaseURL != next.BaseURL || current.Username != next.Username + enabling := !current.Enabled && next.Enabled + if req.Password == "" && !identityChanged && !enabling { + return c.saveCardDAVConfigOnly(ctx, next) + } password, err := c.passwordForRequest(ctx, req) if err != nil { return CardDAVAccountResponse{}, err @@ -270,14 +282,14 @@ func (c *CardDAVController) Save(ctx context.Context, req CardDAVAccountRequest) tokenDir := c.cfg.TokensDir() previousCredential, previousCredentialErr := c.loadCredential(tokenDir) hadPreviousCredential := previousCredentialErr == nil - previousLegacyPassword := "" - hadPreviousLegacyCredential := false - if req.Password != "" && errors.Is(previousCredentialErr, carddav.ErrCredentialNotBound) { - previousLegacyPassword, err = c.loadLegacyPassword(tokenDir) + var previousCredentialFile carddav.CredentialFileSnapshot + hadPreviousCredentialFile := false + if req.Password != "" && previousCredentialErr != nil { + previousCredentialFile, err = carddav.CaptureCredentialFile(tokenDir) if err != nil { return CardDAVAccountResponse{}, errors.Join(errCardDAVStorage, err) } - hadPreviousLegacyCredential = true + hadPreviousCredentialFile = true } else if previousCredentialErr != nil && !errors.Is(previousCredentialErr, os.ErrNotExist) { return CardDAVAccountResponse{}, errors.Join(errCardDAVStorage, previousCredentialErr) } @@ -288,43 +300,6 @@ func (c *CardDAVController) Save(ctx context.Context, req CardDAVAccountRequest) ); err != nil { return CardDAVAccountResponse{}, errors.Join(errCardDAVStorage, err) } - next := config.CardDAVConfig{ - BaseURL: req.BaseURL, Username: req.Username, Enabled: *req.Enabled, Schedule: req.Schedule, - } - connectionUnchanged := account != nil && account.BaseURL == req.BaseURL && - account.Username == req.Username && !credentialsChanged && req.Password == "" - if connectionUnchanged { - books, err := c.store.ListCardDAVAddressBooksContext(ctx) - if err != nil { - return CardDAVAccountResponse{}, errors.Join(errCardDAVStorage, err) - } - c.mu.RLock() - service := c.service - reconcileSchedule := c.reconcileSchedule - c.mu.RUnlock() - if service == nil { - return CardDAVAccountResponse{}, errors.Join(errCardDAVStorage, - errors.New("CardDAV service is unavailable for saved account")) - } - previous, err := c.saveConfig(nil, next) - if err != nil { - var rollbackConfigErr error - if errors.Is(err, config.ErrConfigChanged) { - _, rollbackConfigErr = c.saveConfig(&next, previous) - } - return CardDAVAccountResponse{}, errors.Join(errCardDAVStorage, err, rollbackConfigErr) - } - if reconcileSchedule != nil { - if err := reconcileSchedule(next, service); err != nil { - return CardDAVAccountResponse{}, errors.Join(errCardDAVStorage, - fmt.Errorf("reconcile CardDAV schedule: %w", err)) - } - } - return CardDAVAccountResponse{ - BaseURL: req.BaseURL, Username: req.Username, Enabled: *req.Enabled, - Schedule: req.Schedule, Books: len(books), - }, nil - } service, err := c.factory(c.store, req.BaseURL, req.Username, password) if err != nil { return CardDAVAccountResponse{}, errors.Join(errCardDAVValidation, err) @@ -344,8 +319,8 @@ func (c *CardDAVController) Save(ctx context.Context, req CardDAVAccountRequest) if hadPreviousCredential { return c.saveCredential(tokenDir, previousCredential) } - if hadPreviousLegacyCredential { - return c.saveLegacyPassword(tokenDir, previousLegacyPassword) + if hadPreviousCredentialFile { + return previousCredentialFile.Restore(tokenDir) } return c.removeCredential(tokenDir) } @@ -378,6 +353,37 @@ func (c *CardDAVController) Save(ctx context.Context, req CardDAVAccountRequest) return CardDAVAccountResponse{BaseURL: req.BaseURL, Username: req.Username, Enabled: *req.Enabled, Schedule: req.Schedule, Books: len(discovery.Books)}, nil } +func (c *CardDAVController) saveCardDAVConfigOnly( + ctx context.Context, next config.CardDAVConfig, +) (CardDAVAccountResponse, error) { + books, err := c.store.ListCardDAVAddressBooksContext(ctx) + if err != nil { + return CardDAVAccountResponse{}, errors.Join(errCardDAVStorage, err) + } + c.mu.RLock() + service := c.service + reconcileSchedule := c.reconcileSchedule + c.mu.RUnlock() + previous, err := c.saveConfig(nil, next) + if err != nil { + var rollbackConfigErr error + if errors.Is(err, config.ErrConfigChanged) { + _, rollbackConfigErr = c.saveConfig(&next, previous) + } + return CardDAVAccountResponse{}, errors.Join(errCardDAVStorage, err, rollbackConfigErr) + } + if reconcileSchedule != nil { + if err := reconcileSchedule(next, service); err != nil { + return CardDAVAccountResponse{}, errors.Join(errCardDAVStorage, + fmt.Errorf("reconcile CardDAV schedule: %w", err)) + } + } + return CardDAVAccountResponse{ + BaseURL: next.BaseURL, Username: next.Username, Enabled: next.Enabled, + Schedule: next.Schedule, Books: len(books), + }, nil +} + func (c *CardDAVController) passwordForRequest(ctx context.Context, req CardDAVAccountRequest) (string, error) { if req.Password != "" { return req.Password, nil @@ -481,35 +487,53 @@ type CardDAVBookRolesRequest struct { LookupSource *bool `json:"lookup_source" nullable:"false"` } type CardDAVPublicationResponse struct { - PersonID int64 `json:"person_id"` - Desired bool `json:"desired"` - PendingOperation string `json:"pending_operation,omitempty"` - Href string `json:"href,omitempty"` + PersonID int64 `json:"person_id" minimum:"1"` + State carddav.PublicationState `json:"state" enum:"unpublished,published,pending,conflict"` + Desired bool `json:"desired"` + PendingOperation store.CardDAVMutationOperation `json:"pending_operation,omitempty" enum:"create,update,delete"` + AddressBook *CardDAVAddressBookIdentityResponse `json:"address_book,omitempty"` + ConflictID *int64 `json:"conflict_id,omitempty" minimum:"1"` +} +type CardDAVAddressBookIdentityResponse struct { + ID int64 `json:"id" minimum:"1"` + Name string `json:"name"` +} +type CardDAVContactSummaryResponse struct { + State carddav.ConflictSideState `json:"state" enum:"present,deleted,unavailable"` + DisplayName string `json:"display_name,omitempty"` + Emails []string `json:"emails" nullable:"false"` + Phones []string `json:"phones" nullable:"false"` + Truncated bool `json:"truncated,omitempty"` } type CardDAVConflictResponse struct { - ID int64 `json:"id"` - AddressBookID int64 `json:"address_book_id"` - Href string `json:"href"` - LocalTombstone bool `json:"local_tombstone"` - RemoteTombstone bool `json:"remote_tombstone"` - Status string `json:"status"` + ID int64 `json:"id" minimum:"1"` + AddressBook CardDAVAddressBookIdentityResponse `json:"address_book"` + Status store.CardDAVConflictStatus `json:"status" enum:"unresolved,resolved"` + LocalState carddav.ConflictSideState `json:"local_state" enum:"present,deleted,unavailable"` + RemoteState carddav.ConflictSideState `json:"remote_state" enum:"present,deleted,unavailable"` + AllowedResolutions []carddav.ResolutionChoice `json:"allowed_resolutions" enum:"keep_local,keep_remote" nullable:"false"` + UpdatedAt time.Time `json:"updated_at"` } type CardDAVConflictDetailResponse struct { - ID int64 `json:"id"` - AddressBookID int64 `json:"address_book_id"` - Href string `json:"href"` - LocalVCard string `json:"local_vcard,omitempty"` - RemoteVCard string `json:"remote_vcard,omitempty"` - LocalTombstone bool `json:"local_tombstone"` - RemoteTombstone bool `json:"remote_tombstone"` - Status string `json:"status"` + ID int64 `json:"id" minimum:"1"` + AddressBook CardDAVAddressBookIdentityResponse `json:"address_book"` + Status store.CardDAVConflictStatus `json:"status" enum:"unresolved,resolved"` + Resolution store.CardDAVConflictResolution `json:"resolution,omitempty" enum:"keep_local,keep_remote"` + Base CardDAVContactSummaryResponse `json:"base"` + Local CardDAVContactSummaryResponse `json:"local"` + Remote CardDAVContactSummaryResponse `json:"remote"` + AllowedResolutions []carddav.ResolutionChoice `json:"allowed_resolutions" enum:"keep_local,keep_remote" nullable:"false"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` } type CardDAVConflictResolutionResponse struct { - ID int64 `json:"id"` - Status string `json:"status"` + ID int64 `json:"id" minimum:"1"` + Status store.CardDAVConflictStatus `json:"status" enum:"resolved"` + Resolution carddav.ResolutionChoice `json:"resolution" enum:"keep_local,keep_remote"` } type CardDAVConflictsResponse struct { - Conflicts []CardDAVConflictResponse `json:"conflicts"` + Conflicts []CardDAVConflictResponse `json:"conflicts" nullable:"false"` } type CardDAVResolveRequest struct { Choice carddav.ResolutionChoice `json:"choice" enum:"keep_local,keep_remote"` @@ -518,7 +542,185 @@ type CardDAVSyncRequest struct { Full bool `json:"full,omitempty"` } +type CardDAVRunResponse struct { + ID int64 `json:"id"` + Trigger string `json:"trigger" enum:"manual,scheduled"` + Full bool `json:"full"` + State string `json:"state" enum:"running,succeeded,failed,cancelled,partial"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + Books int64 `json:"books"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` + Removed int64 `json:"removed"` + ErrorCode string `json:"error_code,omitempty" enum:"cancelled,retry_after,authentication_failed,upstream_failed,safety_limit,sync_failed,unsafe_error_redacted,daemon_restarted"` + ErrorMessage string `json:"error_message,omitempty"` +} + +type CardDAVStatusAccount struct { + BaseURL string `json:"base_url"` + Username string `json:"username"` +} + +type CardDAVStatusResponse struct { + Configured bool `json:"configured"` + Available bool `json:"available"` + CredentialConfigured bool `json:"credential_configured"` + Enabled bool `json:"enabled"` + Scheduled bool `json:"scheduled"` + Schedule string `json:"schedule"` + NextScheduledAt *time.Time `json:"next_scheduled_at,omitempty"` + RepairReason string `json:"repair_reason,omitempty" enum:"account_missing,credential_missing,credential_mismatch,credential_unavailable,runtime_unavailable"` + Account *CardDAVStatusAccount `json:"account,omitempty"` + Active *CardDAVRunResponse `json:"active,omitempty"` + Latest *CardDAVRunResponse `json:"latest,omitempty"` + LatestSuccessful *CardDAVRunResponse `json:"latest_successful,omitempty"` +} + +type CardDAVRunsResponse struct { + Runs []CardDAVRunResponse `json:"runs" nullable:"false"` + NextBeforeID *int64 `json:"next_before_id,omitempty"` +} + +func cardDAVRunResponse(run *store.CardDAVSyncRun) *CardDAVRunResponse { + if run == nil { + return nil + } + errorCode, errorMessage := cardDAVRunPublicFailure(run.ErrorCode) + return &CardDAVRunResponse{ + ID: run.ID, Trigger: string(run.Trigger), Full: run.Full, State: string(run.State), + StartedAt: run.StartedAt, FinishedAt: run.FinishedAt, + Books: run.Books, Created: run.Created, Updated: run.Updated, Removed: run.Removed, + ErrorCode: errorCode, ErrorMessage: errorMessage, + } +} + +func cardDAVRunPublicFailure(code string) (string, string) { + switch code { + case "": + return "", "" + case "cancelled": + return code, "CardDAV sync was cancelled." + case "retry_after": + return code, "CardDAV sync is temporarily paused." + case "authentication_failed": + return code, "CardDAV authentication failed." + case "upstream_failed": + return code, "CardDAV server request failed." + case "safety_limit": + return code, "CardDAV sync exceeded its safety limits." + case "sync_failed": + return code, "CardDAV sync failed." + case "unsafe_error_redacted": + return code, "CardDAV sync failed; sensitive details were removed." + case "daemon_restarted": + return code, "CardDAV sync stopped because the daemon restarted." + default: + return "sync_failed", "CardDAV sync failed." + } +} + +func (c *CardDAVController) Status(ctx context.Context) (CardDAVStatusResponse, error) { + if c == nil || c.store == nil || c.cfg == nil { + return CardDAVStatusResponse{}, errCardDAVUnavailable + } + c.mu.RLock() + cfg := c.cfg.CardDAV + service := c.service + loadCredential := c.loadCredential + c.mu.RUnlock() + status := CardDAVStatusResponse{Schedule: cfg.Schedule} + status.Enabled = cfg.Enabled + status.Available = service != nil + status.Configured = strings.TrimSpace(cfg.BaseURL) != "" && strings.TrimSpace(cfg.Username) != "" + if status.Configured { + status.Account = &CardDAVStatusAccount{BaseURL: cardDAVStatusBaseURL(cfg.BaseURL), Username: cfg.Username} + } + runs, err := c.store.CardDAVSyncStatusContext(ctx) + if err != nil { + return CardDAVStatusResponse{}, errors.Join(errCardDAVStorage, err) + } + status.Active = cardDAVRunResponse(runs.Active) + status.Latest = cardDAVRunResponse(runs.Latest) + status.LatestSuccessful = cardDAVRunResponse(runs.LatestSuccessful) + if !status.Configured { + return status, nil + } + account, err := c.store.GetCardDAVAccountContext(ctx) + if err != nil { + return CardDAVStatusResponse{}, errors.Join(errCardDAVStorage, err) + } + if account == nil { + status.RepairReason = "account_missing" + return status, nil + } + if loadCredential == nil { + loadCredential = carddav.LoadCredential + } + credential, err := loadCredential(c.cfg.TokensDir()) + if errors.Is(err, os.ErrNotExist) || errors.Is(err, carddav.ErrCredentialNotBound) { + status.RepairReason = "credential_missing" + return status, nil + } + if err != nil { + status.RepairReason = "credential_unavailable" + return status, nil //nolint:nilerr // Status reports the recoverable credential condition. + } + status.CredentialConfigured = credential.BaseURL == cfg.BaseURL && credential.Username == cfg.Username && + credential.BaseURL == account.BaseURL && credential.Username == account.Username && + credential.ConnectionGeneration == account.ConnectionGeneration + if !status.CredentialConfigured { + status.RepairReason = "credential_mismatch" + } else if !status.Available { + status.RepairReason = "runtime_unavailable" + } + return status, nil +} + +func cardDAVStatusBaseURL(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + parsed.User = nil + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + parsed.RawFragment = "" + return parsed.String() +} + +func (c *CardDAVController) Runs(ctx context.Context, limit int, beforeID *int64) (CardDAVRunsResponse, error) { + if c == nil || c.store == nil { + return CardDAVRunsResponse{}, errCardDAVUnavailable + } + runs, err := c.store.ListCardDAVSyncRunsContext(ctx, limit, beforeID) + if err != nil { + return CardDAVRunsResponse{}, errors.Join(errCardDAVStorage, err) + } + result := CardDAVRunsResponse{Runs: make([]CardDAVRunResponse, 0, len(runs))} + for i := range runs { + result.Runs = append(result.Runs, *cardDAVRunResponse(&runs[i])) + } + if len(runs) == limit && len(runs) > 0 { + next := runs[len(runs)-1].ID + result.NextBeforeID = &next + } + return result, nil +} + func (s *Server) registerCardDAVRoutes(api huma.API) { + registerCardDAVJSONRoute[CardDAVStatusResponse](api, "getCardDAVStatus", http.MethodGet, "/carddav/status", "Get CardDAV synchronization status", s.handleCardDAVStatus, http.StatusInternalServerError, http.StatusServiceUnavailable) + runs := rawAPIV1Operation("listCardDAVRuns", http.MethodGet, "/carddav/runs", "List CardDAV synchronization runs") + limit := queryIntegerParam("limit", "Maximum runs to return (default 25, max 100)") + minimum, maximum := float64(1), float64(100) + limit.Schema.Minimum, limit.Schema.Maximum = &minimum, &maximum + before := queryIntegerParam("before_id", "Return runs with IDs lower than this cursor") + before.Schema.Minimum = &minimum + runs.Parameters = append(runs.Parameters, limit, before) + runs.Responses = jsonResponsesFor[CardDAVRunsResponse](api) + addErrorResponses(api, runs.Responses, http.StatusBadRequest, http.StatusInternalServerError, http.StatusServiceUnavailable) + registerRawHumaRoute(api, runs, s.handleCardDAVRuns) registerCardDAVJSONRouteWithRequest[CardDAVAccountRequest, CardDAVAccountResponse](api, "testCardDAVAccount", http.MethodPost, "/carddav/account/test", "Test a CardDAV account", s.handleCardDAVAccountTest, http.StatusBadRequest, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable) registerCardDAVJSONRouteWithRequest[CardDAVAccountRequest, CardDAVAccountResponse](api, "saveCardDAVAccount", http.MethodPut, "/carddav/account", "Discover and save a CardDAV account", s.handleCardDAVAccountSave, http.StatusBadRequest, http.StatusConflict, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable) registerCardDAVJSONRoute[CardDAVBooksResponse](api, "listCardDAVBooks", http.MethodGet, "/carddav/books", "List CardDAV address books", s.handleCardDAVBooks, http.StatusInternalServerError, http.StatusServiceUnavailable) @@ -532,6 +734,75 @@ func (s *Server) registerCardDAVRoutes(api huma.API) { registerCardDAVJSONRouteWithRequest[CardDAVSyncRequest, carddav.SyncResult](api, "syncCardDAV", http.MethodPost, "/carddav/sync", "Trigger CardDAV synchronization", s.handleCardDAVSync, http.StatusBadRequest, http.StatusConflict, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable) } +func (s *Server) handleCardDAVStatus(w http.ResponseWriter, r *http.Request) { + if s.cardDAV == nil { + writeError(w, http.StatusServiceUnavailable, "carddav_unavailable", "CardDAV status is unavailable") + return + } + status, err := s.cardDAV.Status(r.Context()) + if err != nil { + if errors.Is(err, errCardDAVUnavailable) { + writeError(w, http.StatusServiceUnavailable, "carddav_unavailable", "CardDAV status is unavailable") + } else { + writeError(w, http.StatusInternalServerError, "carddav_storage_failed", "CardDAV status lookup failed") + } + return + } + if s.scheduler != nil && s.scheduler.IsRunning() { + for _, job := range s.scheduler.JobStatus() { + if job.Name != CardDAVJobName { + continue + } + status.Scheduled = true + if !job.NextRun.IsZero() { + next := job.NextRun + status.NextScheduledAt = &next + } + break + } + } + writeJSON(w, http.StatusOK, status) +} + +func (s *Server) handleCardDAVRuns(w http.ResponseWriter, r *http.Request) { + if s.cardDAV == nil { + writeError(w, http.StatusServiceUnavailable, "carddav_unavailable", "CardDAV run history is unavailable") + return + } + limit := 25 + if parsed, present, err := queryInt(r, "limit"); err != nil { + s.rejectBadParam(w, err) + return + } else if present { + if parsed < 1 || parsed > 100 { + s.rejectBadParam(w, newParamError("limit", "query parameter \"limit\" must be between 1 and 100")) + return + } + limit = parsed + } + var beforeID *int64 + if parsed, present, err := queryInt64(r, "before_id"); err != nil { + s.rejectBadParam(w, err) + return + } else if present { + if parsed <= 0 { + s.rejectBadParam(w, newParamError("before_id", "query parameter \"before_id\" must be positive")) + return + } + beforeID = &parsed + } + result, err := s.cardDAV.Runs(r.Context(), limit, beforeID) + if err != nil { + if errors.Is(err, errCardDAVUnavailable) { + writeError(w, http.StatusServiceUnavailable, "carddav_unavailable", "CardDAV run history is unavailable") + } else { + writeError(w, http.StatusInternalServerError, "carddav_storage_failed", "CardDAV run history lookup failed") + } + return + } + writeJSON(w, http.StatusOK, result) +} + func registerCardDAVJSONRoute[Resp any](api huma.API, operationID, method, path, summary string, handler http.HandlerFunc, errorStatuses ...int) { op := rawAPIV1Operation(operationID, method, path, summary) op.Responses = jsonResponsesFor[Resp](api) @@ -598,6 +869,10 @@ func decodeCardDAV(w http.ResponseWriter, r *http.Request, dst any) bool { writeError(w, 400, "bad_request", "Invalid JSON request") return false } + if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeError(w, 400, "bad_request", "Invalid JSON request") + return false + } return true } func (s *Server) cardDAVService(w http.ResponseWriter) CardDAVOperations { @@ -678,16 +953,20 @@ func (s *Server) writeCardDAVOperationError( errors.Is(err, store.ErrCardDAVConflictNotFound), errors.Is(err, store.ErrPersonNotFound): writeError(w, http.StatusNotFound, "not_found", message) + case errors.Is(err, store.ErrCardDAVConflictStale): + writeError(w, http.StatusConflict, "carddav_conflict_stale", "CardDAV conflict changed; refresh before trying again") + case errors.Is(err, carddav.ErrCardDAVConflictPending): + writeError(w, http.StatusConflict, "carddav_conflict_pending", "Resolve the existing CardDAV conflict before trying again") + case errors.Is(err, store.ErrCardDAVPublicationPending): + writeError(w, http.StatusConflict, "carddav_publication_pending", "CardDAV publication is pending; refresh before trying again") case errors.Is(err, store.ErrCardDAVStalePlan), - errors.Is(err, store.ErrCardDAVConflictStale), + errors.Is(err, store.ErrCardDAVSyncActive), errors.Is(err, store.ErrCardDAVWriteTargetSubscribed), errors.Is(err, store.ErrCardDAVReadOnlyAddressBook), errors.Is(err, store.ErrCardDAVRoleChangePending), - errors.Is(err, store.ErrCardDAVPublicationPending), errors.Is(err, store.ErrCardDAVPublicationMismatch), errors.Is(err, store.ErrCardDAVResourceAmbiguous), - errors.Is(err, store.ErrCardDAVNoWriteTarget), - errors.Is(err, carddav.ErrCardDAVConflictPending): + errors.Is(err, store.ErrCardDAVNoWriteTarget): writeError(w, http.StatusConflict, "conflict", message) case errors.Is(err, store.ErrCardDAVRetryAfter): s.setCardDAVRetryAfterHeader(ctx, w, 0) @@ -774,11 +1053,19 @@ func (s *Server) handleCardDAVBookRoles(w http.ResponseWriter, r *http.Request) } writeError(w, 404, "not_found", "CardDAV book not found") } -func publicationResponse(p *store.CardDAVPublication, id int64) CardDAVPublicationResponse { - if p == nil { - return CardDAVPublicationResponse{PersonID: id} +func addressBookIdentityResponse(book carddav.AddressBookIdentity) CardDAVAddressBookIdentityResponse { + return CardDAVAddressBookIdentityResponse{ID: book.ID, Name: book.Name} +} +func publicationResponse(view *carddav.PublicationView) CardDAVPublicationResponse { + response := CardDAVPublicationResponse{ + PersonID: view.PersonID, State: view.State, Desired: view.Desired, + PendingOperation: view.PendingOperation, ConflictID: view.ConflictID, + } + if view.AddressBook != nil { + book := addressBookIdentityResponse(*view.AddressBook) + response.AddressBook = &book } - return CardDAVPublicationResponse{PersonID: id, Desired: p.Desired, PendingOperation: string(p.PendingOperation), Href: p.Href} + return response } func (s *Server) handleCardDAVPublication(w http.ResponseWriter, r *http.Request) { svc := s.cardDAVService(w) @@ -790,16 +1077,12 @@ func (s *Server) handleCardDAVPublication(w http.ResponseWriter, r *http.Request writeError(w, 400, "bad_request", err.Error()) return } - p, err := svc.Publication(r.Context(), id) - if errors.Is(err, store.ErrCardDAVPublicationNotFound) { - writeError(w, http.StatusNotFound, "not_found", "CardDAV publication not found") - return - } + p, err := svc.PublicationView(r.Context(), id) if err != nil { s.writeCardDAVOperationError(r.Context(), w, err, "CardDAV publication lookup failed") return } - writeJSON(w, 200, publicationResponse(p, id)) + writeJSON(w, 200, publicationResponse(p)) } func (s *Server) mutatePublication(w http.ResponseWriter, r *http.Request, publish bool) { svc := s.cardDAVService(w) @@ -820,16 +1103,12 @@ func (s *Server) mutatePublication(w http.ResponseWriter, r *http.Request, publi s.writeCardDAVOperationError(r.Context(), w, err, "CardDAV publication failed") return } - p, err := svc.Publication(r.Context(), id) - if !publish && errors.Is(err, store.ErrCardDAVPublicationNotFound) { - writeJSON(w, http.StatusOK, CardDAVPublicationResponse{PersonID: id, Desired: false}) - return - } + p, err := svc.PublicationView(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "carddav_failed", "CardDAV publication lookup failed") return } - writeJSON(w, 200, publicationResponse(p, id)) + writeJSON(w, 200, publicationResponse(p)) } func (s *Server) handleCardDAVPublish(w http.ResponseWriter, r *http.Request) { s.mutatePublication(w, r, true) @@ -837,15 +1116,26 @@ func (s *Server) handleCardDAVPublish(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCardDAVUnpublish(w http.ResponseWriter, r *http.Request) { s.mutatePublication(w, r, false) } -func conflictResponse(c store.CardDAVConflict) CardDAVConflictResponse { - return CardDAVConflictResponse{ID: c.ID, AddressBookID: c.AddressBookID, Href: c.Href, LocalTombstone: c.LocalTombstone, RemoteTombstone: c.RemoteTombstone, Status: string(c.Status)} +func conflictResponse(c carddav.ConflictListItem) CardDAVConflictResponse { + return CardDAVConflictResponse{ + ID: c.ID, AddressBook: addressBookIdentityResponse(c.AddressBook), Status: c.Status, + LocalState: c.LocalState, RemoteState: c.RemoteState, + AllowedResolutions: c.AllowedResolutions, UpdatedAt: c.UpdatedAt, + } +} +func contactSummaryResponse(summary carddav.ContactSummary) CardDAVContactSummaryResponse { + return CardDAVContactSummaryResponse{ + State: summary.State, DisplayName: summary.DisplayName, Emails: summary.Emails, + Phones: summary.Phones, Truncated: summary.Truncated, + } } -func conflictDetailResponse(c store.CardDAVConflict) CardDAVConflictDetailResponse { +func conflictDetailResponse(c carddav.ConflictDetail) CardDAVConflictDetailResponse { return CardDAVConflictDetailResponse{ - ID: c.ID, AddressBookID: c.AddressBookID, Href: c.Href, - LocalVCard: string(c.LocalBody), RemoteVCard: string(c.RemoteBody), - LocalTombstone: c.LocalTombstone, RemoteTombstone: c.RemoteTombstone, - Status: string(c.Status), + ID: c.ID, AddressBook: addressBookIdentityResponse(c.AddressBook), Status: c.Status, + Resolution: c.Resolution, Base: contactSummaryResponse(c.Base), + Local: contactSummaryResponse(c.Local), Remote: contactSummaryResponse(c.Remote), + AllowedResolutions: c.AllowedResolutions, CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, ResolvedAt: c.ResolvedAt, } } func (s *Server) handleCardDAVConflicts(w http.ResponseWriter, r *http.Request) { @@ -853,7 +1143,7 @@ func (s *Server) handleCardDAVConflicts(w http.ResponseWriter, r *http.Request) if svc == nil { return } - items, err := svc.ListConflicts(r.Context()) + items, err := svc.ListConflictViews(r.Context()) if err != nil { writeError(w, 500, "carddav_failed", "CardDAV operation failed") return @@ -874,7 +1164,7 @@ func (s *Server) handleCardDAVConflict(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } - conflict, err := svc.GetConflict(r.Context(), id) + conflict, err := svc.GetConflictView(r.Context(), id) if err != nil { s.writeCardDAVOperationError(r.Context(), w, err, "CardDAV conflict lookup failed") return @@ -899,7 +1189,7 @@ func (s *Server) handleCardDAVResolve(w http.ResponseWriter, r *http.Request) { s.writeCardDAVOperationError(r.Context(), w, err, "CardDAV conflict resolution failed") return } - writeJSON(w, 200, CardDAVConflictResolutionResponse{ID: id, Status: string(store.CardDAVConflictResolved)}) + writeJSON(w, 200, CardDAVConflictResolutionResponse{ID: id, Status: store.CardDAVConflictResolved, Resolution: req.Choice}) } func (s *Server) handleCardDAVSync(w http.ResponseWriter, r *http.Request) { svc := s.cardDAVService(w) @@ -910,7 +1200,9 @@ func (s *Server) handleCardDAVSync(w http.ResponseWriter, r *http.Request) { if !decodeCardDAV(w, r, &req) { return } - result, err := svc.Sync(r.Context(), carddav.SyncOptions{Full: req.Full}) + result, err := svc.Sync(r.Context(), carddav.SyncOptions{ + Full: req.Full, Trigger: store.CardDAVSyncTriggerManual, + }) if err != nil { s.writeCardDAVOperationError(r.Context(), w, err, "CardDAV synchronization failed") return diff --git a/internal/api/carddav_status_test.go b/internal/api/carddav_status_test.go new file mode 100644 index 000000000..823f32181 --- /dev/null +++ b/internal/api/carddav_status_test.go @@ -0,0 +1,404 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/carddav" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +func TestCardDAVStatusUnconfiguredRemainsReadable(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + cfg := config.NewDefaultConfig() + cfg.HomeDir = t.TempDir() + cfg.Data.DataDir = cfg.HomeDir + controller := &CardDAVController{cfg: cfg, store: testutil.NewTestStore(t)} + srv := NewServerWithOptions(ServerOptions{ + Config: cfg, Store: &mockStore{}, CardDAV: controller, Logger: testLogger(), + }) + + resp := httptest.NewRecorder() + srv.Router().ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/api/v1/carddav/status", nil)) + require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + var status CardDAVStatusResponse + require.NoError(json.NewDecoder(resp.Body).Decode(&status)) + assert.False(status.Configured) + assert.False(status.Available) + assert.False(status.CredentialConfigured) + assert.False(status.Enabled) + assert.False(status.Scheduled) + assert.Nil(status.Account) + assert.Empty(status.RepairReason) + assert.Nil(status.Active) + assert.Nil(status.Latest) + assert.Nil(status.LatestSuccessful) + assert.NotContains(resp.Body.String(), "password") +} + +func TestCardDAVStatusPreservesIncompleteSavedEnablementAndRuntimeAvailability(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + cfg := config.NewDefaultConfig() + cfg.HomeDir = t.TempDir() + cfg.Data.DataDir = cfg.HomeDir + cfg.CardDAV = config.CardDAVConfig{BaseURL: "https://contacts.example/dav", Enabled: true, Schedule: "0 3 * * *"} + controller := &CardDAVController{ + cfg: cfg, store: testutil.NewTestStore(t), service: cardDAVListFixture{}, + } + + resp := getCardDAVRead(t, cardDAVReadServer(t, cfg, controller, nil), "/api/v1/carddav/status") + require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + var status CardDAVStatusResponse + require.NoError(json.NewDecoder(resp.Body).Decode(&status)) + assert.False(status.Configured) + assert.True(status.Enabled) + assert.True(status.Available) + assert.Equal("0 3 * * *", status.Schedule) + assert.Nil(status.Account) + assert.Empty(status.RepairReason) +} + +func cardDAVReadServer(t *testing.T, cfg *config.Config, controller *CardDAVController, sched SyncScheduler) *Server { + t.Helper() + return NewServerWithOptions(ServerOptions{ + Config: cfg, Store: &mockStore{}, CardDAV: controller, Scheduler: sched, Logger: testLogger(), + }) +} + +func getCardDAVRead(t *testing.T, srv *Server, path string) *httptest.ResponseRecorder { + t.Helper() + resp := httptest.NewRecorder() + srv.Router().ServeHTTP(resp, httptest.NewRequest(http.MethodGet, path, nil)) + return resp +} + +func TestCardDAVStatusReportsStableCredentialRepairReasons(t *testing.T) { + tests := []struct { + name string + seedAccount bool + load func(string) (carddav.Credential, error) + want string + }{ + {name: "account missing", load: func(string) (carddav.Credential, error) { + return carddav.Credential{}, errors.New("must not inspect credential before account") + }, want: "account_missing"}, + {name: "credential missing", seedAccount: true, load: func(string) (carddav.Credential, error) { + return carddav.Credential{}, os.ErrNotExist + }, want: "credential_missing"}, + {name: "credential mismatch", seedAccount: true, load: func(string) (carddav.Credential, error) { + return carddav.Credential{BaseURL: "https://other.example/dav", Username: "alice", ConnectionGeneration: 1}, nil + }, want: "credential_mismatch"}, + {name: "credential unavailable", seedAccount: true, load: func(string) (carddav.Credential, error) { + return carddav.Credential{}, errors.New("permission denied Authorization: synthetic-secret") + }, want: "credential_unavailable"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + cfg := config.NewDefaultConfig() + cfg.HomeDir = t.TempDir() + cfg.Data.DataDir = cfg.HomeDir + cfg.CardDAV = config.CardDAVConfig{BaseURL: "https://contacts.example/dav", Username: "alice"} + st := testutil.NewTestStore(t) + if tt.seedAccount { + _, _, err := st.ReplaceCardDAVDiscoveryContext(t.Context(), store.CardDAVDiscoveryInput{ + BaseURL: cfg.CardDAV.BaseURL, Username: cfg.CardDAV.Username, + PrincipalURL: "https://contacts.example/principal/", HomeURL: "https://contacts.example/books/", + }) + require.NoError(err) + } + controller := &CardDAVController{cfg: cfg, store: st, loadCredential: tt.load} + resp := getCardDAVRead(t, cardDAVReadServer(t, cfg, controller, nil), "/api/v1/carddav/status") + require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + var status CardDAVStatusResponse + require.NoError(json.NewDecoder(resp.Body).Decode(&status)) + assert.Equal(tt.want, status.RepairReason) + assert.NotContains(resp.Body.String(), "synthetic-secret") + assert.NotContains(resp.Body.String(), "permission denied") + }) + } +} + +func TestCardDAVStatusRedactsSavedAccountURLSecrets(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + cfg := config.NewDefaultConfig() + cfg.HomeDir = t.TempDir() + cfg.Data.DataDir = cfg.HomeDir + cfg.CardDAV = config.CardDAVConfig{ + BaseURL: "https://alice:synthetic-password@contacts.example/dav?access_token=synthetic-query#private-fragment", + Username: "alice", + } + controller := &CardDAVController{cfg: cfg, store: testutil.NewTestStore(t)} + + resp := getCardDAVRead(t, cardDAVReadServer(t, cfg, controller, nil), "/api/v1/carddav/status") + require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + var status CardDAVStatusResponse + require.NoError(json.NewDecoder(resp.Body).Decode(&status)) + require.NotNil(status.Account) + assert.Equal("https://contacts.example/dav", status.Account.BaseURL) + for _, private := range []string{"synthetic-password", "synthetic-query", "private-fragment", "access_token"} { + assert.NotContains(resp.Body.String(), private) + } +} + +func TestNewCardDAVControllerKeepsUnreadableCredentialAvailableForRepairStatus(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + cfg, st, _ := savedCardDAVFixture(t) + credentialPath := filepath.Join(cfg.TokensDir(), "carddav.json") + require.NoError(os.Remove(credentialPath)) + require.NoError(os.Mkdir(credentialPath, 0o700)) + + var logs bytes.Buffer + controller, err := NewCardDAVController(cfg, st, slog.New(slog.NewTextHandler(&logs, nil))) + require.NoError(err) + assert.Nil(controller.Current()) + assert.Contains(logs.String(), "level=WARN") + assert.Contains(logs.String(), "CardDAV credential is unreadable") + status, err := controller.Status(t.Context()) + require.NoError(err) + assert.Equal("credential_unavailable", status.RepairReason) + assert.False(status.CredentialConfigured) +} + +func TestCardDAVStatusSeparatesRuntimeEnablementAndMatchingSchedule(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + cfg, st, service := savedCardDAVFixture(t) + cfg.CardDAV.Enabled = false + next := time.Date(2026, 8, 29, 1, 0, 0, 0, time.UTC) + controller := &CardDAVController{ + cfg: cfg, store: st, service: service, loadCredential: carddav.LoadCredential, + } + sched := newMockScheduler() + sched.jobStatuses = []JobStatus{ + {Name: "unrelated", Schedule: cfg.CardDAV.Schedule, NextRun: next.Add(-time.Hour)}, + {Name: CardDAVJobName, Schedule: "stale-runtime-copy", NextRun: next}, + } + + resp := getCardDAVRead(t, cardDAVReadServer(t, cfg, controller, sched), "/api/v1/carddav/status") + require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + var status CardDAVStatusResponse + require.NoError(json.NewDecoder(resp.Body).Decode(&status)) + assert.True(status.Configured) + assert.True(status.Available) + assert.True(status.CredentialConfigured) + assert.False(status.Enabled) + assert.True(status.Scheduled) + assert.Equal(cfg.CardDAV.Schedule, status.Schedule) + require.NotNil(status.NextScheduledAt) + assert.Equal(next, *status.NextScheduledAt) + assert.Empty(status.RepairReason) + + controller.service = nil + resp = getCardDAVRead(t, cardDAVReadServer(t, cfg, controller, nil), "/api/v1/carddav/status") + require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + require.NoError(json.NewDecoder(resp.Body).Decode(&status)) + assert.False(status.Available) + assert.True(status.CredentialConfigured) + assert.Equal("runtime_unavailable", status.RepairReason) +} + +func TestCardDAVStatusIgnoresUnavailableAndUnrelatedSchedulers(t *testing.T) { + cfg, st, service := savedCardDAVFixture(t) + controller := &CardDAVController{cfg: cfg, store: st, service: service, loadCredential: carddav.LoadCredential} + next := time.Date(2026, 8, 29, 1, 0, 0, 0, time.UTC) + stopped := newMockScheduler() + stopped.running = false + stopped.jobStatuses = []JobStatus{{Name: CardDAVJobName, NextRun: next}} + unrelated := newMockScheduler() + unrelated.jobStatuses = []JobStatus{{Name: "unrelated", NextRun: next}} + for name, sched := range map[string]SyncScheduler{"nil": nil, "stopped": stopped, "unrelated": unrelated} { + t.Run(name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + resp := getCardDAVRead(t, cardDAVReadServer(t, cfg, controller, sched), "/api/v1/carddav/status") + require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + var status CardDAVStatusResponse + require.NoError(json.NewDecoder(resp.Body).Decode(&status)) + assert.False(status.Scheduled) + assert.Nil(status.NextScheduledAt) + }) + } +} + +func TestCardDAVStatusProjectsLatestFailureAndSuccessfulRun(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + cfg := config.NewDefaultConfig() + cfg.HomeDir = t.TempDir() + cfg.Data.DataDir = cfg.HomeDir + st := testutil.NewTestStore(t) + success, err := st.StartCardDAVSyncRunContext(t.Context(), store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual, Full: true}) + require.NoError(err) + _, err = st.FinishCardDAVSyncRunContext(t.Context(), success.ID, store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunSucceeded, Books: 2, Created: 3, Updated: 4, Removed: 5, + }) + require.NoError(err) + failed, err := st.StartCardDAVSyncRunContext(t.Context(), store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerScheduled}) + require.NoError(err) + _, err = st.FinishCardDAVSyncRunContext(t.Context(), failed.ID, store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunFailed, Books: 1, ErrorCode: "upstream_failed", ErrorMessage: "CardDAV server request failed.", + }) + require.NoError(err) + controller := &CardDAVController{cfg: cfg, store: st} + + resp := getCardDAVRead(t, cardDAVReadServer(t, cfg, controller, nil), "/api/v1/carddav/status") + require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + var status CardDAVStatusResponse + require.NoError(json.NewDecoder(resp.Body).Decode(&status)) + require.NotNil(status.Latest) + assert.Equal(failed.ID, status.Latest.ID) + assert.Equal("failed", status.Latest.State) + assert.Equal("upstream_failed", status.Latest.ErrorCode) + require.NotNil(status.LatestSuccessful) + assert.Equal(success.ID, status.LatestSuccessful.ID) + assert.Equal(int64(2), status.LatestSuccessful.Books) + assert.Equal(int64(3), status.LatestSuccessful.Created) + assert.NotContains(resp.Body.String(), "connection_generation") +} + +func TestCardDAVStatusAndRunsCollapseUnknownStoredFailureProjection(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + cfg := config.NewDefaultConfig() + cfg.HomeDir = t.TempDir() + cfg.Data.DataDir = cfg.HomeDir + st := testutil.NewTestStore(t) + run, err := st.StartCardDAVSyncRunContext(t.Context(), store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + require.NoError(err) + _, err = st.FinishCardDAVSyncRunContext(t.Context(), run.ID, store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunFailed, ErrorCode: "future_provider_failure", + ErrorMessage: "tenant-internal-marker must not cross the API", + }) + require.NoError(err) + controller := &CardDAVController{cfg: cfg, store: st} + srv := cardDAVReadServer(t, cfg, controller, nil) + + for _, path := range []string{"/api/v1/carddav/status", "/api/v1/carddav/runs"} { + resp := getCardDAVRead(t, srv, path) + require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + assert.Contains(resp.Body.String(), `"error_code":"sync_failed"`) + assert.Contains(resp.Body.String(), `"error_message":"CardDAV sync failed."`) + assert.NotContains(resp.Body.String(), "future_provider_failure") + assert.NotContains(resp.Body.String(), "tenant-internal-marker") + } +} + +func TestCardDAVStatusProjectsActiveRunExactly(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + cfg := config.NewDefaultConfig() + cfg.HomeDir = t.TempDir() + cfg.Data.DataDir = cfg.HomeDir + st := testutil.NewTestStore(t) + active, err := st.StartCardDAVSyncRunContext(t.Context(), store.CardDAVSyncRunStart{ + Trigger: store.CardDAVSyncTriggerScheduled, Full: true, + }) + require.NoError(err) + controller := &CardDAVController{cfg: cfg, store: st} + + resp := getCardDAVRead(t, cardDAVReadServer(t, cfg, controller, nil), "/api/v1/carddav/status") + require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + var status CardDAVStatusResponse + require.NoError(json.NewDecoder(resp.Body).Decode(&status)) + require.NotNil(status.Active) + assert.Equal(active.ID, status.Active.ID) + assert.Equal("scheduled", status.Active.Trigger) + assert.True(status.Active.Full) + assert.Equal("running", status.Active.State) + assert.Nil(status.Active.FinishedAt) + require.NotNil(status.Latest) + assert.Equal(active.ID, status.Latest.ID) + assert.Nil(status.LatestSuccessful) +} + +func TestCardDAVRunHistoryPagesNewestFirstWithoutRuntimeService(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + cfg := config.NewDefaultConfig() + cfg.HomeDir = t.TempDir() + cfg.Data.DataDir = cfg.HomeDir + st := testutil.NewTestStore(t) + var ids []int64 + for i := range 3 { + run, err := st.StartCardDAVSyncRunContext(t.Context(), store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual, Full: i == 0}) + require.NoError(err) + ids = append(ids, run.ID) + _, err = st.FinishCardDAVSyncRunContext(t.Context(), run.ID, store.CardDAVSyncRunFinish{State: store.CardDAVSyncRunSucceeded, Books: int64(i + 1)}) + require.NoError(err) + } + controller := &CardDAVController{cfg: cfg, store: st} + srv := cardDAVReadServer(t, cfg, controller, nil) + + first := getCardDAVRead(t, srv, "/api/v1/carddav/runs?limit=2") + require.Equal(http.StatusOK, first.Code, first.Body.String()) + var firstPage CardDAVRunsResponse + require.NoError(json.NewDecoder(first.Body).Decode(&firstPage)) + require.Len(firstPage.Runs, 2) + assert.Equal([]int64{ids[2], ids[1]}, []int64{firstPage.Runs[0].ID, firstPage.Runs[1].ID}) + require.NotNil(firstPage.NextBeforeID) + assert.Equal(ids[1], *firstPage.NextBeforeID) + + second := getCardDAVRead(t, srv, "/api/v1/carddav/runs?limit=2&before_id="+strconv.FormatInt(*firstPage.NextBeforeID, 10)) + require.Equal(http.StatusOK, second.Code, second.Body.String()) + var secondPage CardDAVRunsResponse + require.NoError(json.NewDecoder(second.Body).Decode(&secondPage)) + require.Len(secondPage.Runs, 1) + assert.Equal(ids[0], secondPage.Runs[0].ID) + assert.Nil(secondPage.NextBeforeID) + assert.NotContains(first.Body.String(), "connection_generation") +} + +func TestCardDAVRunHistoryRejectsInvalidPagination(t *testing.T) { + cfg := config.NewDefaultConfig() + cfg.HomeDir = t.TempDir() + cfg.Data.DataDir = cfg.HomeDir + controller := &CardDAVController{cfg: cfg, store: testutil.NewTestStore(t)} + srv := cardDAVReadServer(t, cfg, controller, nil) + for _, query := range []string{"limit=0", "limit=101", "limit=bad", "before_id=0", "before_id=bad"} { + resp := getCardDAVRead(t, srv, "/api/v1/carddav/runs?"+query) + assert.Equal(t, http.StatusBadRequest, resp.Code, query+": "+resp.Body.String()) + } +} + +func TestCardDAVStatusAndRunsMapMissingDependenciesAndStorageFailureSafely(t *testing.T) { + assert := assert.New(t) + cfg := config.NewDefaultConfig() + cfg.HomeDir = t.TempDir() + cfg.Data.DataDir = cfg.HomeDir + for _, path := range []string{"/api/v1/carddav/status", "/api/v1/carddav/runs"} { + missing := getCardDAVRead(t, cardDAVReadServer(t, cfg, nil, nil), path) + assert.Equal(http.StatusServiceUnavailable, missing.Code, path+": "+missing.Body.String()) + missingStore := getCardDAVRead(t, cardDAVReadServer(t, cfg, &CardDAVController{cfg: cfg}, nil), path) + assert.Equal(http.StatusServiceUnavailable, missingStore.Code, path+": "+missingStore.Body.String()) + } + + st := testutil.NewTestStore(t) + _, err := st.DB().Exec(`DROP TABLE carddav_sync_runs`) + require.NoError(t, err) + controller := &CardDAVController{cfg: cfg, store: st} + for _, path := range []string{"/api/v1/carddav/status", "/api/v1/carddav/runs"} { + failed := getCardDAVRead(t, cardDAVReadServer(t, cfg, controller, nil), path) + assert.Equal(http.StatusInternalServerError, failed.Code, path+": "+failed.Body.String()) + assert.NotContains(failed.Body.String(), "no such table") + assert.NotContains(failed.Body.String(), "carddav_sync_runs") + } +} diff --git a/internal/api/carddav_test.go b/internal/api/carddav_test.go index 6840d34e8..cdb4887bb 100644 --- a/internal/api/carddav_test.go +++ b/internal/api/carddav_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net" "net/http" "net/http/httptest" @@ -105,7 +106,7 @@ func TestNewCardDAVControllerLoadsCredentialFromConfiguredDataDir(t *testing.T) Username: cfg.CardDAV.Username, ConnectionGeneration: 1, })) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) assert.NotNil(controller.Current()) assert.FileExists(filepath.Join(cfg.TokensDir(), "carddav.json")) @@ -195,7 +196,7 @@ func TestCardDAVAccountSaveRollsBackPublishedFilesWhenDiscoveryStoreFails(t *tes cfg, st, oldService := savedCardDAVFixture(t) newService := &controlledCardDAVCandidate{discovery: oldService.discovery} - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { return newService, nil } controller.persistDiscovery = func(context.Context, cardDAVCandidate, string, string, carddav.Discovery, bool) error { @@ -231,7 +232,7 @@ func TestCardDAVAccountSavePreservesOldStateWhenConfigPublicationFails(t *testin require := require.New(t) cfg, st, candidate := savedCardDAVFixture(t) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { return candidate, nil } persisted := false @@ -258,7 +259,7 @@ func TestCardDAVAccountSaveRepublishesPreviousConfigAfterPublishThenError(t *tes assert := assert.New(t) require := require.New(t) cfg, st, candidate := savedCardDAVFixture(t) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { return candidate, nil } persisted := false @@ -301,7 +302,7 @@ func TestCardDAVAccountSavePreservesConcurrentNonCardDAVConfigEdits(t *testing.T require := require.New(t) cfg, st, candidate := savedCardDAVFixture(t) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { return candidate, nil } controller.persistDiscovery = func(context.Context, cardDAVCandidate, string, string, carddav.Discovery, bool) error { return nil } @@ -331,7 +332,7 @@ func TestCardDAVAccountSavePreservesOldStateWhenCredentialPublicationFails(t *te require := require.New(t) cfg, st, candidate := savedCardDAVFixture(t) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { return candidate, nil } persisted := false @@ -366,7 +367,7 @@ func TestCardDAVControllerKeepsSetupAvailableForCredentialMismatch(t *testing.T) Username: cfg.CardDAV.Username, ConnectionGeneration: 1, })) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) assert.NotNil(controller) assert.Nil(controller.Current()) @@ -382,7 +383,7 @@ func TestCardDAVControllerMigratesLegacyPasswordWhenDurableIdentityMatches(t *te before, err := os.Stat(tokenPath) require.NoError(err) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) assert.NotNil(controller.Current()) @@ -409,7 +410,7 @@ func TestCardDAVControllerLeavesLegacyPasswordUnboundWhenIdentityDoesNotMatch(t cfg.CardDAV.Username = "different-user" require.NoError(cfg.Save()) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) assert.NotNil(controller) @@ -429,7 +430,7 @@ func TestCardDAVAccountSaveRepairsUnboundLegacyCredentialWithExplicitPassword(t require.NoError(carddav.SavePassword(cfg.TokensDir(), "legacy-password")) cfg.CardDAV.Username = "replacement-user" require.NoError(cfg.Save()) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) require.Nil(controller.Current()) controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { @@ -456,7 +457,7 @@ func TestCardDAVAccountSaveRestoresUnboundLegacyCredentialOnRollback(t *testing. require.NoError(carddav.SavePassword(cfg.TokensDir(), "legacy-password")) cfg.CardDAV.Username = "replacement-user" require.NoError(cfg.Save()) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { return candidate, nil @@ -482,7 +483,7 @@ func TestCardDAVControllerConstructsManualServiceWhenSchedulingDisabled(t *testi cfg.CardDAV.Enabled = false require.NoError(t, cfg.Save()) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(t, err) assert.NotNil(t, controller.Current()) } @@ -492,7 +493,7 @@ func TestCardDAVAccountSaveUpdatesSchedulingWithoutDiscovery(t *testing.T) { require := require.New(t) cfg, st, candidate := savedCardDAVFixture(t) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) candidate.discover = errors.New("injected upstream outage") controller.service = candidate @@ -526,12 +527,50 @@ func TestCardDAVAccountSaveUpdatesSchedulingWithoutDiscovery(t *testing.T) { assert.Equal("old-password", credential.Password) } +func TestCardDAVAccountSaveDisablesUnavailableCredentialWithoutDiscovery(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + cfg, st, candidate := savedCardDAVFixture(t) + credentialPath := filepath.Join(cfg.TokensDir(), "carddav.json") + corruptCredential := []byte("{corrupt credential") + requirements.NoError(os.WriteFile(credentialPath, corruptCredential, 0o600)) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) + requirements.NoError(err) + requirements.Nil(controller.Current()) + controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { + return nil, errors.New("credential-free update must not build a service") + } + var scheduledConfig config.CardDAVConfig + var scheduledService CardDAVOperations + controller.SetScheduleReconciler(func(cfg config.CardDAVConfig, service CardDAVOperations) error { + scheduledConfig, scheduledService = cfg, service + return nil + }) + + response, err := controller.Save(t.Context(), CardDAVAccountRequest{ + BaseURL: cfg.CardDAV.BaseURL, Username: cfg.CardDAV.Username, + Enabled: new(false), Schedule: "0 3 * * *", + }) + requirements.NoError(err) + assertions.False(response.Enabled) + assertions.Equal("0 3 * * *", response.Schedule) + assertions.Equal(config.CardDAVConfig{ + BaseURL: cfg.CardDAV.BaseURL, Username: cfg.CardDAV.Username, + Enabled: false, Schedule: "0 3 * * *", + }, scheduledConfig) + assertions.Nil(scheduledService) + assertions.Zero(candidate.discoverCalls.Load()) + retained, err := os.ReadFile(credentialPath) + requirements.NoError(err) + assertions.Equal(corruptCredential, retained) +} + func TestCardDAVAccountSaveExplicitPasswordRefreshesDiscovery(t *testing.T) { assert := assert.New(t) require := require.New(t) cfg, st, candidate := savedCardDAVFixture(t) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { return candidate, nil @@ -553,7 +592,7 @@ func TestCardDAVAccountSaveRepairsMissingCredentialAfterStartup(t *testing.T) { cfg, st, candidate := savedCardDAVFixture(t) require.NoError(carddav.RemoveCredential(cfg.TokensDir())) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) assert.Nil(controller.Current()) controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { @@ -588,6 +627,42 @@ func TestCardDAVAccountSaveRepairsMissingCredentialAfterStartup(t *testing.T) { assert.Equal(account.ConnectionGeneration, credential.ConnectionGeneration) } +func TestCardDAVAccountSaveRepairsCorruptCredentialWithoutLosingItOnRollback(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + cfg, st, candidate := savedCardDAVFixture(t) + credentialPath := filepath.Join(cfg.TokensDir(), "carddav.json") + corruptCredential := []byte("{corrupt credential") + requirements.NoError(os.WriteFile(credentialPath, corruptCredential, 0o600)) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) + requirements.NoError(err) + requirements.Nil(controller.Current()) + controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { + return candidate, nil + } + persistDiscovery := controller.persistDiscovery + controller.persistDiscovery = func(context.Context, cardDAVCandidate, string, string, carddav.Discovery, bool) error { + return errors.New("injected discovery persistence failure") + } + + request := CardDAVAccountRequest{ + BaseURL: cfg.CardDAV.BaseURL, Username: cfg.CardDAV.Username, + Password: "replacement-password", Enabled: new(true), Schedule: cfg.CardDAV.Schedule, + } + _, err = controller.Save(t.Context(), request) + requirements.ErrorContains(err, "injected discovery persistence failure") + retained, err := os.ReadFile(credentialPath) + requirements.NoError(err) + assertions.Equal(corruptCredential, retained) + + controller.persistDiscovery = persistDiscovery + _, err = controller.Save(t.Context(), request) + requirements.NoError(err) + repaired, err := carddav.LoadCredential(cfg.TokensDir()) + requirements.NoError(err) + assertions.Equal("replacement-password", repaired.Password) +} + func TestCardDAVAccountSavePasswordChangeAdvancesConnectionGeneration(t *testing.T) { assert := assert.New(t) require := require.New(t) @@ -596,7 +671,7 @@ func TestCardDAVAccountSavePasswordChangeAdvancesConnectionGeneration(t *testing before, err := st.GetCardDAVAccountContext(t.Context()) require.NoError(err) require.NotNil(before) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { return candidate, nil } controller.persistDiscovery = func(ctx context.Context, _ cardDAVCandidate, baseURL, username string, discovery carddav.Discovery, credentialsChanged bool) error { @@ -675,7 +750,7 @@ func TestCardDAVAccountSaveRejectsCredentialRotationBeforeDiscoveryWhenIntentIsP require.NoError(err) require.Len(books, 1) tc.seed(t, st, books[0]) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) oldService := controller.Current() controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { @@ -742,7 +817,7 @@ func TestCardDAVAccountSaveRejectsIdentityChangeBeforeDiscoveryWhenRemoteStateIs require.NoError(err) require.Len(books, 1) tc.seed(t, st, books[0]) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) oldService := controller.Current() controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { @@ -804,7 +879,7 @@ func TestCardDAVAccountSaveSerializesCompleteCredentialTransition(t *testing.T) require := require.New(t) cfg, st, fixture := savedCardDAVFixture(t) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(err) firstStarted := make(chan struct{}, 1) firstRelease := make(chan struct{}) @@ -857,7 +932,7 @@ func TestCardDAVAccountSaveSerializesCompleteCredentialTransition(t *testing.T) func TestCardDAVAccountRequiresPasswordWhenConnectionIdentityChanges(t *testing.T) { cfg, st, _ := savedCardDAVFixture(t) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(t, err) _, err = controller.Test(t.Context(), CardDAVAccountRequest{ @@ -867,6 +942,25 @@ func TestCardDAVAccountRequiresPasswordWhenConnectionIdentityChanges(t *testing. assert.ErrorContains(t, err, "password") } +func TestCardDAVAccountTestDoesNotCreateSyncRun(t *testing.T) { + require := require.New(t) + cfg, st, candidate := savedCardDAVFixture(t) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) + require.NoError(err) + controller.factory = func(*store.Store, string, string, string) (cardDAVCandidate, error) { + return candidate, nil + } + + _, err = controller.Test(t.Context(), CardDAVAccountRequest{ + BaseURL: cfg.CardDAV.BaseURL, Username: cfg.CardDAV.Username, + Enabled: new(true), + }) + require.NoError(err) + runs, err := st.ListCardDAVSyncRunsContext(t.Context(), 10, nil) + require.NoError(err) + assert.Empty(t, runs) +} + func writeCardDAVMultiStatus(w http.ResponseWriter, body string) { _, _ = w.Write([]byte(`` + body + ``)) } @@ -943,8 +1037,8 @@ func (f cardDAVListFixture) ListBooks(context.Context) ([]store.CardDAVAddressBo return nil, nil } func (f cardDAVListFixture) SetBookRoles(context.Context, int64, carddav.BookRoles) error { return nil } -func (f cardDAVListFixture) Publication(context.Context, int64) (*store.CardDAVPublication, error) { - return nil, store.ErrCardDAVPublicationNotFound +func (f cardDAVListFixture) PublicationView(context.Context, int64) (*carddav.PublicationView, error) { + return &carddav.PublicationView{PersonID: 7, State: carddav.PublicationUnpublished}, nil } func (f cardDAVListFixture) PublishPerson(context.Context, int64) error { return nil } func (f cardDAVListFixture) UnpublishPerson(context.Context, int64) error { return nil } @@ -955,6 +1049,27 @@ func (f cardDAVListFixture) GetConflict(context.Context, int64) (*store.CardDAVC conflict := f.conflict return &conflict, nil } +func (f cardDAVListFixture) ListConflictViews(context.Context) ([]carddav.ConflictListItem, error) { + state := carddav.ConflictSidePresent + if f.conflict.LocalTombstone { + state = carddav.ConflictSideDeleted + } + return []carddav.ConflictListItem{{ + ID: f.conflict.ID, AddressBook: carddav.AddressBookIdentity{ID: f.conflict.AddressBookID, Name: "Personal"}, + Status: f.conflict.Status, LocalState: state, RemoteState: carddav.ConflictSidePresent, + AllowedResolutions: []carddav.ResolutionChoice{carddav.ResolutionKeepLocal, carddav.ResolutionKeepRemote}, + }}, nil +} +func (f cardDAVListFixture) GetConflictView(context.Context, int64) (*carddav.ConflictDetail, error) { + return &carddav.ConflictDetail{ + ID: f.conflict.ID, AddressBook: carddav.AddressBookIdentity{ID: f.conflict.AddressBookID, Name: "Personal"}, + Status: f.conflict.Status, + Base: carddav.ContactSummary{State: carddav.ConflictSideUnavailable, Emails: []string{}, Phones: []string{}}, + Local: carddav.ContactSummary{State: carddav.ConflictSidePresent, DisplayName: "Alice Local", Emails: []string{}, Phones: []string{}}, + Remote: carddav.ContactSummary{State: carddav.ConflictSidePresent, DisplayName: "Alice Remote", Emails: []string{}, Phones: []string{}}, + AllowedResolutions: []carddav.ResolutionChoice{carddav.ResolutionKeepLocal, carddav.ResolutionKeepRemote}, + }, nil +} func (f cardDAVListFixture) ResolveConflict(context.Context, int64, carddav.ResolutionChoice) error { return errors.New("unused") } @@ -988,14 +1103,70 @@ func TestCardDAVConflictDetailExposesOnlyRequestedSnapshots(t *testing.T) { require.Equal(http.StatusOK, resp.Code, resp.Body.String()) assert.Contains(resp.Body.String(), "Alice Local") assert.Contains(resp.Body.String(), "Alice Remote") - assert.Contains(resp.Body.String(), `"href":"/books/personal/alice.vcf"`) + assert.NotContains(resp.Body.String(), "href") + assert.Contains(resp.Body.String(), `"address_book":{"id":2,"name":"Personal"}`) } func TestCardDAVConflictResolutionReturnsOnlyResolvedIdentity(t *testing.T) { resp := cardDAVRouteResponse(t, cardDAVErrorFixture{}, http.MethodPost, "/api/v1/carddav/conflicts/7/resolve", `{"choice":"keep_remote"}`) require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) - assert.JSONEq(t, `{"id":7,"status":"resolved"}`, resp.Body.String()) + assert.JSONEq(t, `{"id":7,"status":"resolved","resolution":"keep_remote"}`, resp.Body.String()) +} + +type cardDAVResolveCountFixture struct { + cardDAVErrorFixture + + calls int + choice carddav.ResolutionChoice + err error +} + +func (f *cardDAVResolveCountFixture) ResolveConflict(_ context.Context, _ int64, choice carddav.ResolutionChoice) error { + f.calls++ + f.choice = choice + return f.err +} + +func TestCardDAVConflictResolutionIsOneStrictMutationWithTypedStaleResponse(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + service := &cardDAVResolveCountFixture{err: store.ErrCardDAVConflictStale} + resp := cardDAVRouteResponse(t, service, http.MethodPost, + "/api/v1/carddav/conflicts/7/resolve", `{"choice":"keep_local"}`) + require.Equal(http.StatusConflict, resp.Code, resp.Body.String()) + assert.JSONEq(`{"error":"carddav_conflict_stale","message":"CardDAV conflict changed; refresh before trying again"}`, resp.Body.String()) + assert.Equal(1, service.calls) + assert.Equal(carddav.ResolutionKeepLocal, service.choice) + + resp = cardDAVRouteResponse(t, service, http.MethodPost, + "/api/v1/carddav/conflicts/7/resolve", `{"choice":"keep_local","retry":true}`) + require.Equal(http.StatusBadRequest, resp.Code, resp.Body.String()) + assert.Equal(1, service.calls, "unknown request fields must be rejected before the service") + + resp = cardDAVRouteResponse(t, service, http.MethodPost, + "/api/v1/carddav/conflicts/7/resolve", `{"choice":"keep_local"}{"choice":"keep_remote"}`) + require.Equal(http.StatusBadRequest, resp.Code, resp.Body.String()) + assert.Equal(1, service.calls, "trailing JSON must be rejected before the service") +} + +func TestCardDAVMutationPendingErrorsHaveStable409Codes(t *testing.T) { + tests := []struct { + name string + err error + code string + }{ + {name: "conflict pending", err: carddav.ErrCardDAVConflictPending, code: "carddav_conflict_pending"}, + {name: "publication pending", err: store.ErrCardDAVPublicationPending, code: "carddav_publication_pending"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := cardDAVRouteResponse(t, cardDAVErrorFixture{mutateErr: tt.err}, http.MethodPost, + "/api/v1/carddav/publications/7", "") + require.Equal(t, http.StatusConflict, resp.Code, resp.Body.String()) + assert.Contains(t, resp.Body.String(), `"error":"`+tt.code+`"`) + }) + } } type cardDAVErrorFixture struct { @@ -1004,7 +1175,7 @@ type cardDAVErrorFixture struct { books []store.CardDAVAddressBook booksErr error rolesErr error - publication *store.CardDAVPublication + publication *carddav.PublicationView pubErr error mutateErr error conflictErr error @@ -1023,17 +1194,16 @@ func (f cardDAVErrorFixture) ListBooks(context.Context) ([]store.CardDAVAddressB func (f cardDAVErrorFixture) SetBookRoles(context.Context, int64, carddav.BookRoles) error { return f.rolesErr } -func (f cardDAVErrorFixture) Publication(context.Context, int64) (*store.CardDAVPublication, error) { +func (f cardDAVErrorFixture) PublicationView(context.Context, int64) (*carddav.PublicationView, error) { return f.publication, f.pubErr } func (f cardDAVErrorFixture) PublishPerson(context.Context, int64) error { return f.mutateErr } func (f cardDAVErrorFixture) UnpublishPerson(context.Context, int64) error { return f.mutateErr } -func (f cardDAVErrorFixture) GetConflict(context.Context, int64) (*store.CardDAVConflict, error) { +func (f cardDAVErrorFixture) GetConflictView(context.Context, int64) (*carddav.ConflictDetail, error) { if f.conflictErr != nil { return nil, f.conflictErr } - conflict := f.conflict - return &conflict, nil + return f.cardDAVListFixture.GetConflictView(context.Background(), 0) } func (f cardDAVErrorFixture) ResolveConflict(context.Context, int64, carddav.ResolutionChoice) error { return f.resolveErr @@ -1064,7 +1234,7 @@ func TestCardDAVRoutesMapValidationMissingConflictAndStorageStatuses(t *testing. {name: "role conflict", method: http.MethodPatch, path: "/api/v1/carddav/books/7", body: roles, service: cardDAVErrorFixture{rolesErr: store.ErrCardDAVReadOnlyAddressBook}, want: http.StatusConflict}, {name: "role pending mutation", method: http.MethodPatch, path: "/api/v1/carddav/books/7", body: roles, service: cardDAVErrorFixture{rolesErr: store.ErrCardDAVRoleChangePending}, want: http.StatusConflict}, {name: "role follow-up storage", method: http.MethodPatch, path: "/api/v1/carddav/books/7", body: roles, service: cardDAVErrorFixture{booksErr: errors.New("database unavailable")}, want: http.StatusInternalServerError}, - {name: "publication missing", method: http.MethodGet, path: "/api/v1/carddav/publications/7", service: cardDAVErrorFixture{pubErr: store.ErrCardDAVPublicationNotFound}, want: http.StatusNotFound}, + {name: "publication person missing", method: http.MethodGet, path: "/api/v1/carddav/publications/7", service: cardDAVErrorFixture{pubErr: store.ErrPersonNotFound}, want: http.StatusNotFound}, {name: "person missing", method: http.MethodPost, path: "/api/v1/carddav/publications/7", service: cardDAVErrorFixture{mutateErr: store.ErrPersonNotFound}, want: http.StatusNotFound}, {name: "publication follow-up missing", method: http.MethodPost, path: "/api/v1/carddav/publications/7", service: cardDAVErrorFixture{pubErr: store.ErrCardDAVPublicationNotFound}, want: http.StatusInternalServerError}, {name: "conflict detail missing", method: http.MethodGet, path: "/api/v1/carddav/conflicts/7", service: cardDAVErrorFixture{conflictErr: store.ErrCardDAVConflictNotFound}, want: http.StatusNotFound}, @@ -1073,6 +1243,7 @@ func TestCardDAVRoutesMapValidationMissingConflictAndStorageStatuses(t *testing. {name: "conflict stale", method: http.MethodPost, path: "/api/v1/carddav/conflicts/7/resolve", body: `{"choice":"keep_remote"}`, service: cardDAVErrorFixture{resolveErr: store.ErrCardDAVConflictStale}, want: http.StatusConflict}, {name: "conflict storage", method: http.MethodPost, path: "/api/v1/carddav/conflicts/7/resolve", body: `{"choice":"keep_remote"}`, service: cardDAVErrorFixture{resolveErr: errors.New("database unavailable")}, want: http.StatusInternalServerError}, {name: "sync stale", method: http.MethodPost, path: "/api/v1/carddav/sync", body: `{}`, service: cardDAVErrorFixture{syncErr: store.ErrCardDAVStalePlan}, want: http.StatusConflict}, + {name: "sync already active", method: http.MethodPost, path: "/api/v1/carddav/sync", body: `{}`, service: cardDAVErrorFixture{syncErr: store.ErrCardDAVSyncActive}, want: http.StatusConflict}, {name: "sync retry gate", method: http.MethodPost, path: "/api/v1/carddav/sync", body: `{}`, service: cardDAVErrorFixture{syncErr: store.ErrCardDAVRetryAfter}, want: http.StatusServiceUnavailable}, {name: "sync storage", method: http.MethodPost, path: "/api/v1/carddav/sync", body: `{}`, service: cardDAVErrorFixture{syncErr: errors.New("database unavailable")}, want: http.StatusInternalServerError}, {name: "sync upstream", method: http.MethodPost, path: "/api/v1/carddav/sync", body: `{}`, service: cardDAVErrorFixture{syncErr: &carddav.StatusError{StatusCode: http.StatusBadGateway}}, want: http.StatusBadGateway}, @@ -1173,7 +1344,7 @@ func TestCardDAVAccountChangeOwnershipErrorsMapConflict(t *testing.T) { func TestCardDAVAccountTestMapsCredentialStorageFailure(t *testing.T) { cfg, st, _ := savedCardDAVFixture(t) - controller, err := NewCardDAVController(cfg, st) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) require.NoError(t, err) controller.loadCredential = func(string) (carddav.Credential, error) { return carddav.Credential{}, errors.New("database-backed credential lookup unavailable") @@ -1198,29 +1369,52 @@ func TestCardDAVSyncResponseUsesLowercaseJSONFields(t *testing.T) { assert.JSONEq(t, `{"books":1,"created":2,"updated":3,"removed":4}`, resp.Body.String()) } +type cardDAVSyncOptionsFixture struct { + cardDAVListFixture + + options carddav.SyncOptions +} + +func (f *cardDAVSyncOptionsFixture) Sync(_ context.Context, options carddav.SyncOptions) (carddav.SyncResult, error) { + f.options = options + return carddav.SyncResult{}, nil +} + +func TestCardDAVSyncRouteMarksRunManual(t *testing.T) { + service := &cardDAVSyncOptionsFixture{} + resp := cardDAVRouteResponse(t, service, http.MethodPost, "/api/v1/carddav/sync", `{"full":true}`) + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + assert.True(t, service.options.Full) + assert.Equal(t, store.CardDAVSyncTriggerManual, service.options.Trigger) +} + func TestCardDAVPublicationStateRouteRequiresAuthAndReturnsState(t *testing.T) { - controller := &CardDAVController{service: cardDAVErrorFixture{publication: &store.CardDAVPublication{ - PersonID: 11, Desired: true, PendingOperation: store.CardDAVMutationCreate, Href: "/books/personal/11.vcf", + assert := assert.New(t) + controller := &CardDAVController{service: cardDAVErrorFixture{publication: &carddav.PublicationView{ + PersonID: 11, State: carddav.PublicationPending, Desired: true, + PendingOperation: store.CardDAVMutationCreate, + AddressBook: &carddav.AddressBookIdentity{ID: 2, Name: "Personal"}, }}} cfg := &config.Config{Server: config.ServerConfig{APIKey: "synthetic-api-key"}} srv := NewServerWithOptions(ServerOptions{Config: cfg, Store: &mockStore{}, Logger: testLogger(), CardDAV: controller}) unauthorized := httptest.NewRecorder() srv.Router().ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/v1/carddav/publications/11", nil)) - assert.Equal(t, http.StatusUnauthorized, unauthorized.Code) + assert.Equal(http.StatusUnauthorized, unauthorized.Code) req := httptest.NewRequest(http.MethodGet, "/api/v1/carddav/publications/11", nil) req.Header.Set("X-Api-Key", "synthetic-api-key") authorized := httptest.NewRecorder() srv.Router().ServeHTTP(authorized, req) require.Equal(t, http.StatusOK, authorized.Code, authorized.Body.String()) - assert.JSONEq(t, `{"person_id":11,"desired":true,"pending_operation":"create","href":"/books/personal/11.vcf"}`, authorized.Body.String()) + assert.JSONEq(`{"person_id":11,"state":"pending","desired":true,"pending_operation":"create","address_book":{"id":2,"name":"Personal"}}`, authorized.Body.String()) + assert.NotContains(authorized.Body.String(), "href") } func TestCardDAVUnpublishReturnsDesiredFalseWhenPublicationIsGone(t *testing.T) { resp := cardDAVRouteResponse(t, cardDAVErrorFixture{ - pubErr: store.ErrCardDAVPublicationNotFound, + publication: &carddav.PublicationView{PersonID: 11, State: carddav.PublicationUnpublished}, }, http.MethodDelete, "/api/v1/carddav/publications/11", "") require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) - assert.JSONEq(t, `{"person_id":11,"desired":false}`, resp.Body.String()) + assert.JSONEq(t, `{"person_id":11,"state":"unpublished","desired":false}`, resp.Body.String()) } diff --git a/internal/api/openapi.go b/internal/api/openapi.go index d895bb595..42e1d2823 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -241,12 +241,22 @@ import ( // Omission preserves the active-only default. Additive (minor bump): existing // clients continue to receive the same result population. // 2.13.0 makes deduplicate planning use an explicit, version-gated backfill -// confirmation protocol. +// confirmation protocol. It also adds GET /api/v1/people/directory: a paginated, +// lexical, non-sensitive Directory view of promoted durable people. The legacy +// unpaginated GET /api/v1/people response remains unchanged. // 2.14.0 adds exact case-insensitive List-ID filtering to analytics, deletion, // and vector/hybrid search MessageFilter routes, and includes nullable list_id // values in the message change feed. Remote clients must require this version // before sending list_id because older compatible daemons ignore unknown query -// parameters and could widen a scoped request. +// parameters and could widen a scoped request. It also adds the person network, +// operation history, CardDAV status and run-history reads, the self-describing +// Settings catalog, stable-name person enrichment provider updates, and +// write-only provider credential endpoints. It replaces the CardDAV publication +// and conflict response shapes with bounded projections that omit raw vCards and +// resource hrefs so those responses cannot expose private contact data or +// infrastructure identifiers. That shape change lands inside the unreleased 2.x +// line: nothing after the released 1.36.0 contract has shipped yet, so it does +// not open a new major version. // 2.15.0 adds POST /api/v1/cli/repair-message with a dedicated request and // streaming event contract for Gmail snapshot repair and audit operations. // Additive (minor bump): existing CLI routes and clients remain unchanged. @@ -504,7 +514,10 @@ func hardenSettingsSchemas(doc *huma.OpenAPI) { } } if setting := schemas["Setting"]; setting != nil { - setting.Properties["group"].Enum = []any{"browser", "server", "archive", "search", "sources", "integrations"} + setting.Properties["group"].Enum = []any{ + "browser", "server", "archive", "sync", "logging", "search", "sources", "attachments", + "activity", "backup", "enrichment", "integrations", + } setting.Properties["kind"].Enum = []any{"string", "integer", "number", "boolean", "string_array", "secret"} } if request := schemas["SettingsPatchRequest"]; request != nil { @@ -512,6 +525,7 @@ func hardenSettingsSchemas(doc *huma.OpenAPI) { } if response := schemas["SettingsResponse"]; response != nil { response.Properties["settings"].Nullable = false + response.Properties["groups"].Nullable = false } } @@ -742,6 +756,26 @@ func applyClientCodegenExtensions(doc *huma.OpenAPI) { "CreateCommunicationServiceRequestScopePolicyRequired", }, }, + "CardDAVConflictDetailResponse": { + "resolution": {"CardDAVConflictDetailResponseResolutionKeepLocal", "CardDAVConflictDetailResponseResolutionKeepRemote"}, + "status": {"CardDAVConflictDetailResponseStatusUnresolved", "CardDAVConflictDetailResponseStatusResolved"}, + }, + "CardDAVConflictResolutionResponse": { + "resolution": {"CardDAVConflictResolutionResponseResolutionKeepLocal", "CardDAVConflictResolutionResponseResolutionKeepRemote"}, + "status": {"CardDAVConflictResolutionResponseStatusResolved"}, + }, + "CardDAVConflictResponse": { + "local_state": {"CardDAVConflictResponseLocalStatePresent", "CardDAVConflictResponseLocalStateDeleted", "CardDAVConflictResponseLocalStateUnavailable"}, + "remote_state": {"CardDAVConflictResponseRemoteStatePresent", "CardDAVConflictResponseRemoteStateDeleted", "CardDAVConflictResponseRemoteStateUnavailable"}, + "status": {"CardDAVConflictResponseStatusUnresolved", "CardDAVConflictResponseStatusResolved"}, + }, + "CardDAVContactSummaryResponse": { + "state": {"CardDAVContactSummaryResponseStatePresent", "CardDAVContactSummaryResponseStateDeleted", "CardDAVContactSummaryResponseStateUnavailable"}, + }, + "CardDAVPublicationResponse": { + "pending_operation": {"CardDAVPublicationResponsePendingOperationCreate", "CardDAVPublicationResponsePendingOperationUpdate", "CardDAVPublicationResponsePendingOperationDelete"}, + "state": {"CardDAVPublicationResponseStateUnpublished", "CardDAVPublicationResponseStatePublished", "CardDAVPublicationResponseStatePending", "CardDAVPublicationResponseStateConflict"}, + }, "ExploreCacheUnavailableResponse": { "readiness": {"ExploreCacheUnavailableResponseReadinessAbsent", "ExploreCacheUnavailableResponseReadinessBuilding", "ExploreCacheUnavailableResponseReadinessInterrupted", "ExploreCacheUnavailableResponseReadinessStaleSchema", "ExploreCacheUnavailableResponseReadinessDrifted"}, }, @@ -780,6 +814,16 @@ func applyClientCodegenExtensions(doc *huma.OpenAPI) { setEnumNames(schema.Properties[propertyName], enumNames) } } + for _, schemaName := range []string{"CardDAVConflictDetailResponse", "CardDAVConflictResponse"} { + schema := schemas[schemaName] + if schema == nil || schema.Properties["allowed_resolutions"] == nil { + continue + } + setEnumNames(schema.Properties["allowed_resolutions"].Items, []any{ + schemaName + "AllowedResolutionsKeepLocal", + schemaName + "AllowedResolutionsKeepRemote", + }) + } meeting := schemas["Meeting"] if meeting == nil || meeting.Properties == nil { return diff --git a/internal/api/openapi_test.go b/internal/api/openapi_test.go index 4ca7741c7..24db0f37e 100644 --- a/internal/api/openapi_test.go +++ b/internal/api/openapi_test.go @@ -728,8 +728,9 @@ func TestOpenAPIMeetingImportContract(t *testing.T) { // 2.5.0. Person search in 2.6.0, structured filters in 2.7.0, CardDAV routes // in 2.8.0, person merge/split operations in 2.9.0, and relationship // calendars in 2.10.0, person fact diagnostics in 2.11.0, lexical deletion - // scope in 2.12.0, deduplicate planning in 2.13.0, List-ID filtering in - // 2.14.0, and Gmail repair in 2.15.0 did not touch it. + // scope in 2.12.0, Directory people and deduplicate planning in 2.13.0, + // CardDAV status and run history plus List-ID filtering in 2.14.0, and Gmail + // repair in 2.15.0 did not touch it. assert.Equal("2.15.0", APISchemaVersion, "meeting import is an additive schema release") doc := OpenAPIDocument() @@ -944,6 +945,8 @@ func TestOpenAPIDocumentsAllExplorationOperations(t *testing.T) { "ExploreFilterDimensionDeletion", "ExploreFilterDimensionIdentity", }, clientFilter.Properties["dimension"].Extensions["x-enum-names"]) for schemaName, properties := range map[string][]string{ + "DirectoryPeopleResponse": {"people"}, + "DirectoryPersonSummary": {"categories", "organizations"}, "ExploreFilter": {"values"}, "ExploreHTTPResponse": {"rows"}, "EntryRow": {"matched_sender_identities", "matched_recipient_identities"}, @@ -956,11 +959,62 @@ func TestOpenAPIDocumentsAllExplorationOperations(t *testing.T) { requirements.NotNil(schema, schemaName) for _, property := range properties { requirements.NotNil(schema.Properties[property], "%s.%s", schemaName, property) + assertions.Contains(schema.Required, property, "%s.%s must be required", schemaName, property) assertions.False(schema.Properties[property].Nullable, "%s.%s must not be nullable", schemaName, property) } } } +func TestOpenAPIDirectoryLastContactParametersAreTyped(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + doc := OpenAPIDocument() + operation := doc.Paths["/api/v1/people/directory"].Get + require.NotNil(operation) + parameters := make(map[string]*huma.Param, len(operation.Parameters)) + for _, parameter := range operation.Parameters { + parameters[parameter.Name] = parameter + } + require.Contains(parameters, "last_contact_after") + require.Contains(parameters, "last_contact_before") + require.Contains(parameters, "sort") + assert.Equal("date-time", parameters["last_contact_after"].Schema.Format) + assert.Equal("date-time", parameters["last_contact_before"].Schema.Format) + assert.ElementsMatch([]any{"name", "last_contact_desc", "last_contact_asc"}, parameters["sort"].Schema.Enum) +} + +func TestOpenAPICardDAVConflictArraysAreRequiredAndNonNull(t *testing.T) { + tests := []struct { + schema string + property string + }{ + {schema: "CardDAVContactSummaryResponse", property: "emails"}, + {schema: "CardDAVContactSummaryResponse", property: "phones"}, + {schema: "CardDAVConflictResponse", property: "allowed_resolutions"}, + {schema: "CardDAVConflictDetailResponse", property: "allowed_resolutions"}, + {schema: "CardDAVConflictsResponse", property: "conflicts"}, + } + for documentName, document := range map[string]*huma.OpenAPI{ + "server": OpenAPIDocument(), + "client": openAPIClientDocument(), + } { + t.Run(documentName, func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.schema+"/"+tt.property, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + schema := document.Components.Schemas.Map()[tt.schema] + require.NotNil(schema) + property := schema.Properties[tt.property] + require.NotNil(property) + assert.Contains(schema.Required, tt.property) + assert.False(property.Nullable) + }) + } + }) + } +} + func TestOpenAPIClientServiceEnumsPreserveExistingGoNames(t *testing.T) { requirements := require.New(t) assertions := assert.New(t) @@ -1004,6 +1058,34 @@ func TestOpenAPIClientAppendNoteSourceEnumNamesAvoidExistingConstants(t *testing }, schema.Properties["source"].Extensions["x-enum-names"]) } +func TestOpenAPIClientCardDAVEnumsDoNotRenameExistingConstants(t *testing.T) { + schemas := openAPIClientDocument().Components.Schemas.Map() + tests := []struct { + schema, property string + want []any + }{ + {schema: "CardDAVPublicationResponse", property: "state", want: []any{ + "CardDAVPublicationResponseStateUnpublished", "CardDAVPublicationResponseStatePublished", + "CardDAVPublicationResponseStatePending", "CardDAVPublicationResponseStateConflict", + }}, + {schema: "CardDAVPublicationResponse", property: "pending_operation", want: []any{ + "CardDAVPublicationResponsePendingOperationCreate", "CardDAVPublicationResponsePendingOperationUpdate", + "CardDAVPublicationResponsePendingOperationDelete", + }}, + {schema: "CardDAVContactSummaryResponse", property: "state", want: []any{ + "CardDAVContactSummaryResponseStatePresent", "CardDAVContactSummaryResponseStateDeleted", + "CardDAVContactSummaryResponseStateUnavailable", + }}, + } + for _, tt := range tests { + schema := schemas[tt.schema] + require.NotNil(t, schema, tt.schema) + property := schema.Properties[tt.property] + require.NotNil(t, property, tt.schema+"."+tt.property) + assert.Equal(t, tt.want, property.Extensions["x-enum-names"], tt.schema+"."+tt.property) + } +} + func TestOpenAPIExplorationUsesStructuredUnavailableUnion(t *testing.T) { requirements := require.New(t) assertions := assert.New(t) @@ -1145,6 +1227,43 @@ func TestOpenAPIArtifactUpToDate(t *testing.T) { assert.Equal(t, normalizeGeneratedArtifact(want), normalizeGeneratedArtifact(got), "api/openapi.yaml is stale; run `make api-generate`") } +func TestOpenAPIDirectoryArraysAreRequiredAndNonNullInRenderedDocuments(t *testing.T) { + for _, version := range []string{"3.1", "3.0"} { + t.Run(version, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + raw, err := OpenAPIJSONVersion(version) + require.NoError(err) + var document struct { + Components struct { + Schemas map[string]struct { + Required []string `json:"required"` + Properties map[string]struct { + Type any `json:"type"` + Nullable bool `json:"nullable"` + } `json:"properties"` + } `json:"schemas"` + } `json:"components"` + } + require.NoError(json.Unmarshal(raw, &document)) + for schemaName, properties := range map[string][]string{ + "DirectoryPeopleResponse": {"people"}, + "DirectoryPersonSummary": {"categories", "organizations"}, + } { + schema, ok := document.Components.Schemas[schemaName] + require.True(ok, schemaName) + for _, propertyName := range properties { + property, ok := schema.Properties[propertyName] + require.True(ok, "%s.%s", schemaName, propertyName) + assert.Contains(schema.Required, propertyName) + assert.Equal("array", property.Type, "%s.%s", schemaName, propertyName) + assert.False(property.Nullable, "%s.%s", schemaName, propertyName) + } + } + }) + } +} + func TestCardDAVOpenAPIDocumentsPositiveIDsAndOperationalErrors(t *testing.T) { assert := assert.New(t) require := require.New(t) @@ -1187,6 +1306,222 @@ func TestCardDAVOpenAPIDocumentsPositiveIDsAndOperationalErrors(t *testing.T) { } } +func TestCardDAVStatusAndRunHistoryOpenAPIContract(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + doc := OpenAPIDocument() + status := doc.Paths["/api/v1/carddav/status"] + require.NotNil(status) + require.NotNil(status.Get) + assert.Equal("getCardDAVStatus", status.Get.OperationID) + runs := doc.Paths["/api/v1/carddav/runs"] + require.NotNil(runs) + require.NotNil(runs.Get) + assert.Equal("listCardDAVRuns", runs.Get.OperationID) + require.Len(runs.Get.Parameters, 2) + assert.Equal("limit", runs.Get.Parameters[0].Name) + require.NotNil(runs.Get.Parameters[0].Schema.Minimum) + require.NotNil(runs.Get.Parameters[0].Schema.Maximum) + assert.InDelta(1, *runs.Get.Parameters[0].Schema.Minimum, 0) + assert.InDelta(100, *runs.Get.Parameters[0].Schema.Maximum, 0) + assert.Equal("before_id", runs.Get.Parameters[1].Name) + require.NotNil(runs.Get.Parameters[1].Schema.Minimum) + assert.InDelta(1, *runs.Get.Parameters[1].Schema.Minimum, 0) + + run := doc.Components.Schemas.Map()["CardDAVRunResponse"] + require.NotNil(run) + assert.Equal([]any{"manual", "scheduled"}, run.Properties["trigger"].Enum) + assert.Equal([]any{"running", "succeeded", "failed", "cancelled", "partial"}, run.Properties["state"].Enum) + assert.Equal([]any{"cancelled", "retry_after", "authentication_failed", "upstream_failed", "safety_limit", "sync_failed", "unsafe_error_redacted", "daemon_restarted"}, run.Properties["error_code"].Enum) + page := doc.Components.Schemas.Map()["CardDAVRunsResponse"] + require.NotNil(page) + assert.Contains(page.Required, "runs") + assert.False(page.Properties["runs"].Nullable) + statusSchema := doc.Components.Schemas.Map()["CardDAVStatusResponse"] + require.NotNil(statusSchema) + assert.NotContains(statusSchema.Required, "repair_reason") + assert.NotContains(statusSchema.Required, "next_scheduled_at") + assert.NotContains(statusSchema.Required, "active") + assert.Equal([]any{"account_missing", "credential_missing", "credential_mismatch", "credential_unavailable", "runtime_unavailable"}, statusSchema.Properties["repair_reason"].Enum) +} + +func TestOpenAPIOperationRoutesParametersAndFailures(t *testing.T) { + for documentName, document := range map[string]*huma.OpenAPI{ + "server": OpenAPIDocument(), + "client": openAPIClientDocument(), + } { + t.Run(documentName, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + list := document.Paths["/api/v1/operations/runs"] + require.NotNil(list) + require.NotNil(list.Get) + assert.Equal("listOperationRuns", list.Get.OperationID) + for _, status := range []string{"200", "400", "500", "503", "default"} { + assert.Contains(list.Get.Responses, status) + } + require.Len(list.Get.Parameters, 5) + parameters := make(map[string]*huma.Param, len(list.Get.Parameters)) + for _, parameter := range list.Get.Parameters { + parameters[parameter.Name] = parameter + } + assert.ElementsMatch(operationKindValues(), anyToStrings(t, parameters["kind"].Schema.Enum)) + assert.ElementsMatch(operationLaneValues(), anyToStrings(t, parameters["lane"].Schema.Enum)) + assert.ElementsMatch(operationStateValues(), anyToStrings(t, parameters["state"].Schema.Enum)) + require.NotNil(parameters["limit"].Schema.Minimum) + require.NotNil(parameters["limit"].Schema.Maximum) + assert.InDelta(1, *parameters["limit"].Schema.Minimum, 0) + assert.InDelta(100, *parameters["limit"].Schema.Maximum, 0) + assert.Contains(parameters["limit"].Description, "default 25") + assert.Contains(parameters["cursor"].Description, "Opaque") + assert.Contains(parameters["cursor"].Description, "archive") + assert.Contains(parameters["cursor"].Description, "exact kind, lane, and state filters") + + detail := document.Paths["/api/v1/operations/runs/{id}"] + require.NotNil(detail) + require.NotNil(detail.Get) + assert.Equal("getOperationRun", detail.Get.OperationID) + for _, status := range []string{"200", "400", "404", "500", "503", "default"} { + assert.Contains(detail.Get.Responses, status) + } + require.Len(detail.Get.Parameters, 1) + assert.Equal("id", detail.Get.Parameters[0].Name) + assert.Contains(detail.Get.Parameters[0].Description, "Opaque") + assert.Contains(detail.Get.Parameters[0].Description, "archive-bound") + + status := document.Paths["/api/v1/operations/status"] + require.NotNil(status) + require.NotNil(status.Get) + assert.Equal("getOperationStatus", status.Get.OperationID) + assert.Contains(status.Get.Responses, "200") + assert.Contains(status.Get.Responses, "default") + }) + } +} + +func TestOpenAPIOperationEnumsAndNonNullCollections(t *testing.T) { + enums := map[string]map[string][]string{ + "OperationPublicCounter": { + "name": {"processed", "added", "updated", "item_errors", "attempted", "succeeded", "failed", "projected_writes", "books", "created", "removed"}, + "unit": {"messages", "people", "writes", "books", "contacts"}, + }, + "OperationPublicError": { + "code": { + "source_sync_failed", "person_sweep_failed", "policy", "budget", "lease_lost", + "rate_limited", "timeout", "provider_http", "invalid_output", "archive_gap", + "internal", "cancelled", "retry_after", "authentication_failed", "upstream_failed", + "safety_limit", "sync_failed", "unsafe_error_redacted", "daemon_restarted", "carddav_sync_failed", + }, + }, + "OperationRunSummary": { + "kind": operationKindValues(), + "lane": operationLaneValues(), + "state": operationStateValues(), + "trigger": {"manual", "scheduled"}, + }, + "OperationUnavailableKind": { + "kind": operationKindValues(), + "lane": operationLaneValues(), + }, + "OperationLaneStatus": { + "kind": operationKindValues(), + "lane": operationLaneValues(), + "history_availability": {"available", "unavailable"}, + "related_status": { + "listSourceStatus", "getDocumentIndexStatus", "getDocumentVectorStatus", + "getVisualAttachmentStatus", "getCardDAVStatus", + }, + "supported_actions": {"carddav_sync", "visual_build", "visual_resume"}, + }, + } + collections := map[string][]string{ + "OperationRunSummary": {"counters"}, + "OperationRunDetail": {"counters"}, + "OperationRunsResponse": {"runs", "unavailable_kinds"}, + "OperationLaneStatus": {"supported_actions"}, + "OperationStatusResponse": {"lanes"}, + } + for documentName, document := range map[string]*huma.OpenAPI{ + "server": OpenAPIDocument(), + "client": openAPIClientDocument(), + } { + t.Run(documentName, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + schemas := document.Components.Schemas.Map() + for schemaName, properties := range enums { + schema := schemas[schemaName] + require.NotNil(schema, schemaName) + for propertyName, want := range properties { + property := schema.Properties[propertyName] + require.NotNil(property, schemaName+"."+propertyName) + enumSchema := property + if len(enumSchema.Enum) == 0 && property.Items != nil { + enumSchema = property.Items + } + assert.ElementsMatch(want, anyToStrings(t, enumSchema.Enum), schemaName+"."+propertyName) + } + } + for schemaName, properties := range collections { + schema := schemas[schemaName] + require.NotNil(schema, schemaName) + for _, propertyName := range properties { + property := schema.Properties[propertyName] + require.NotNil(property, schemaName+"."+propertyName) + assert.Contains(schema.Required, propertyName, schemaName+"."+propertyName) + assert.False(property.Nullable, schemaName+"."+propertyName) + } + } + }) + } +} + +func TestOpenAPIOperationServerAndClientSchemasMatch(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server := OpenAPIDocument().Components.Schemas.Map() + client := openAPIClientDocument().Components.Schemas.Map() + wantProperties := map[string][]string{ + "OperationPublicCounter": {"name", "unit", "value"}, + "OperationPublicError": {"code", "message"}, + "OperationRunSummary": {"id", "kind", "lane", "state", "trigger", "started_at", "finished_at", "counters", "error"}, + "OperationRunDetail": {"id", "kind", "lane", "state", "trigger", "started_at", "finished_at", "counters", "error"}, + "OperationUnavailableKind": {"kind", "lane", "unavailable_code"}, + "OperationRunsResponse": {"runs", "next_cursor", "unavailable_kinds"}, + "OperationLaneStatus": { + "kind", "lane", "configured", "history_availability", "unavailable_code", + "active", "latest", "latest_successful", "related_status", "supported_actions", + }, + "OperationStatusResponse": {"lanes"}, + } + for schemaName, want := range wantProperties { + require.NotNil(server[schemaName], schemaName) + require.NotNil(client[schemaName], schemaName) + assert.ElementsMatch(want, operationSchemaPropertyNames(server[schemaName].Properties), "server "+schemaName) + assert.ElementsMatch(want, operationSchemaPropertyNames(client[schemaName].Properties), "client "+schemaName) + assert.ElementsMatch(server[schemaName].Required, client[schemaName].Required, schemaName) + } +} + +func anyToStrings(t *testing.T, values []any) []string { + t.Helper() + result := make([]string, 0, len(values)) + for _, value := range values { + text, ok := value.(string) + require.True(t, ok, "OpenAPI enum value must be a string") + result = append(result, text) + } + return result +} + +func operationSchemaPropertyNames(values map[string]*huma.Schema) []string { + result := make([]string, 0, len(values)) + for key := range values { + result = append(result, key) + } + return result +} + func TestCardDAVServiceUnavailableResponsesDocumentRetryAfter(t *testing.T) { assert := assert.New(t) require := require.New(t) diff --git a/internal/api/operations.go b/internal/api/operations.go new file mode 100644 index 000000000..2ecf8766f --- /dev/null +++ b/internal/api/operations.go @@ -0,0 +1,993 @@ +package api + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" + "unicode/utf8" + + "go.kenn.io/msgvault/internal/operations" + "go.kenn.io/msgvault/internal/store" +) + +const ( + operationTokenVersion = "1" + operationRunsDefaultLimit = 25 + maxOperationTokenPayloadBytes = 16 * 1024 + maxOperationArchiveUIDBytes = 1024 +) + +var ( + errInvalidOperationRunReference = errors.New("invalid operation run reference") + errInvalidOperationCursor = errors.New("invalid operation history cursor") +) + +type OperationPublicCounter struct { + Name operations.CounterName `json:"name" enum:"processed,added,updated,item_errors,attempted,succeeded,failed,projected_writes,books,created,removed"` + Unit operations.CounterUnit `json:"unit" enum:"messages,people,writes,books,contacts"` + Value int64 `json:"value"` +} + +type OperationPublicError struct { + Code operations.PublicErrorCode `json:"code" enum:"source_sync_failed,person_sweep_failed,policy,budget,lease_lost,rate_limited,timeout,provider_http,invalid_output,archive_gap,internal,cancelled,retry_after,authentication_failed,upstream_failed,safety_limit,sync_failed,unsafe_error_redacted,daemon_restarted,carddav_sync_failed"` + Message string `json:"message"` +} + +type OperationRunSummary struct { + ID string `json:"id"` + Kind operations.Kind `json:"kind" enum:"carddav_sync,document_embedding,document_extraction,message_embedding,person_embedding,person_enrichment,person_sweep,source_sync,visual_embedding"` + Lane operations.Lane `json:"lane" enum:"contacts,documents,messages,person_facts,visual_attachments"` + State operations.State `json:"state" enum:"cancelled,failed,partial,queued,running,succeeded"` + Trigger *operations.Trigger `json:"trigger,omitempty" enum:"manual,scheduled"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + Counters []OperationPublicCounter `json:"counters" nullable:"false"` + Error *OperationPublicError `json:"error,omitempty"` +} + +type OperationRunDetail struct { + OperationRunSummary +} + +type OperationUnavailableKind struct { + Kind operations.Kind `json:"kind" enum:"carddav_sync,document_embedding,document_extraction,message_embedding,person_embedding,person_enrichment,person_sweep,source_sync,visual_embedding"` + Lane operations.Lane `json:"lane" enum:"contacts,documents,messages,person_facts,visual_attachments"` + UnavailableCode string `json:"unavailable_code"` +} + +type OperationRunsResponse struct { + Runs []OperationRunSummary `json:"runs" nullable:"false"` + NextCursor string `json:"next_cursor,omitempty"` + UnavailableKinds []OperationUnavailableKind `json:"unavailable_kinds" nullable:"false"` +} + +type OperationLaneStatus struct { + Kind operations.Kind `json:"kind" enum:"carddav_sync,document_embedding,document_extraction,message_embedding,person_embedding,person_enrichment,person_sweep,source_sync,visual_embedding"` + Lane operations.Lane `json:"lane" enum:"contacts,documents,messages,person_facts,visual_attachments"` + Configured bool `json:"configured"` + HistoryAvailability operations.HistoryAvailability `json:"history_availability" enum:"available,unavailable"` + UnavailableCode string `json:"unavailable_code,omitempty"` + Active *OperationRunSummary `json:"active,omitempty"` + Latest *OperationRunSummary `json:"latest,omitempty"` + LatestSuccessful *OperationRunSummary `json:"latest_successful,omitempty"` + RelatedStatus *operations.RelatedStatusID `json:"related_status,omitempty" enum:"listSourceStatus,getDocumentIndexStatus,getDocumentVectorStatus,getVisualAttachmentStatus,getCardDAVStatus"` + SupportedActions []operations.ActionID `json:"supported_actions" enum:"carddav_sync,visual_build,visual_resume" nullable:"false"` +} + +type OperationStatusResponse struct { + Lanes []OperationLaneStatus `json:"lanes" nullable:"false"` +} + +type operationRunReferencePayload struct { + Kind operations.Kind `json:"kind"` + IDType operations.StableIDType `json:"id_type"` + IntID *int64 `json:"int_id,omitempty"` + StringID *string `json:"string_id,omitempty"` + ArchiveUID string `json:"archive_uid"` +} + +type operationCursorPayload struct { + Timestamp string `json:"t"` + Kind operations.Kind `json:"k"` + IDType operations.StableIDType `json:"it"` + IntID *int64 `json:"i,omitempty"` + StringID *string `json:"s,omitempty"` + FilterHash string `json:"f"` + ArchiveUID string `json:"a"` +} + +// operationHistoryFilter retains the three HTTP-owned semantic filters. The +// normalized store query expands a lane to kinds, but the cursor must still +// bind the exact public filter so a client cannot silently change its walk. +type operationHistoryFilter struct { + Kind operations.Kind + Lane operations.Lane + State operations.State +} + +type operationRunsQuery struct { + Query operations.Query + filter operationHistoryFilter +} + +func encodeOperationRunReference(id operations.StableID, archiveUID string) (string, error) { + if err := id.Validate(); err != nil { + return "", fmt.Errorf("encode operation run reference: %w", err) + } + if err := validateOperationArchiveUID(archiveUID); err != nil { + return "", fmt.Errorf("encode operation run reference: %w", err) + } + payload := operationRunReferencePayload{ + Kind: id.Kind(), IDType: id.Type(), ArchiveUID: archiveUID, + } + switch id.Type() { + case operations.StableIDInt64: + value, ok := id.Int64() + if !ok { + return "", errors.New("encode operation run reference: numeric ID is unavailable") + } + payload.IntID = &value + case operations.StableIDText: + value, ok := id.Text() + if !ok { + return "", errors.New("encode operation run reference: text ID is unavailable") + } + payload.StringID = &value + default: + return "", errors.New("encode operation run reference: unsupported ID type") + } + return encodeOperationToken(payload) +} + +func decodeOperationRunReference(raw string, archiveUID string) (operations.StableID, error) { + if err := validateOperationArchiveUID(archiveUID); err != nil { + return operations.StableID{}, invalidOperationRunReference(err) + } + decoded, err := decodeOperationToken(raw) + if err != nil { + return operations.StableID{}, invalidOperationRunReference(err) + } + var payload operationRunReferencePayload + fields, err := decodeStrictOperationObject(decoded, &payload, operationRunReferenceFieldAllowed) + if err != nil { + return operations.StableID{}, invalidOperationRunReference(err) + } + if err := validateOperationArchiveUID(payload.ArchiveUID); err != nil { + return operations.StableID{}, invalidOperationRunReference(err) + } + if payload.ArchiveUID != archiveUID { + return operations.StableID{}, invalidOperationRunReference(errors.New("archive binding does not match")) + } + id, err := operationStableID(payload.Kind, payload.IDType, + payload.IntID, operationFieldPresent(fields, "int_id"), + payload.StringID, operationFieldPresent(fields, "string_id")) + if err != nil { + return operations.StableID{}, invalidOperationRunReference(err) + } + return id, nil +} + +func encodeOperationCursor( + position operations.Position, filter operationHistoryFilter, archiveUID string, +) (string, error) { + if err := position.Validate(); err != nil { + return "", fmt.Errorf("encode operation cursor: %w", err) + } + if err := filter.validate(); err != nil { + return "", fmt.Errorf("encode operation cursor: %w", err) + } + if !filter.allows(position.ID.Kind()) { + return "", errors.New("encode operation cursor: position kind is outside the filter") + } + if err := validateOperationArchiveUID(archiveUID); err != nil { + return "", fmt.Errorf("encode operation cursor: %w", err) + } + payload := operationCursorPayload{ + Timestamp: position.StartedAt.Format(time.RFC3339Nano), + Kind: position.ID.Kind(), + IDType: position.ID.Type(), + FilterHash: operationFilterFingerprint(filter), + ArchiveUID: archiveUID, + } + switch position.ID.Type() { + case operations.StableIDInt64: + value, _ := position.ID.Int64() + payload.IntID = &value + case operations.StableIDText: + value, _ := position.ID.Text() + payload.StringID = &value + default: + return "", errors.New("encode operation cursor: unsupported ID type") + } + return encodeOperationToken(payload) +} + +func decodeOperationCursor( + raw string, filter operationHistoryFilter, archiveUID string, +) (operations.Position, error) { + if err := filter.validate(); err != nil { + return operations.Position{}, invalidOperationCursor(err) + } + if err := validateOperationArchiveUID(archiveUID); err != nil { + return operations.Position{}, invalidOperationCursor(err) + } + decoded, err := decodeOperationToken(raw) + if err != nil { + return operations.Position{}, invalidOperationCursor(err) + } + var payload operationCursorPayload + fields, err := decodeStrictOperationObject(decoded, &payload, operationCursorFieldAllowed) + if err != nil { + return operations.Position{}, invalidOperationCursor(err) + } + if payload.ArchiveUID != archiveUID || !validOperationFilterHash(payload.FilterHash) || + payload.FilterHash != operationFilterFingerprint(filter) { + return operations.Position{}, invalidOperationCursor(errors.New("archive or filter binding does not match")) + } + if err := validateOperationArchiveUID(payload.ArchiveUID); err != nil { + return operations.Position{}, invalidOperationCursor(err) + } + startedAt, err := time.Parse(time.RFC3339Nano, payload.Timestamp) + if err != nil || startedAt.Location() != time.UTC { + return operations.Position{}, invalidOperationCursor(errors.New("timestamp must be RFC3339 UTC")) + } + id, err := operationStableID(payload.Kind, payload.IDType, + payload.IntID, operationFieldPresent(fields, "i"), + payload.StringID, operationFieldPresent(fields, "s")) + if err != nil { + return operations.Position{}, invalidOperationCursor(err) + } + if !filter.allows(id.Kind()) { + return operations.Position{}, invalidOperationCursor(errors.New("position kind is outside the filter")) + } + position := operations.Position{StartedAt: startedAt, ID: id} + if err := position.Validate(); err != nil { + return operations.Position{}, invalidOperationCursor(err) + } + return position, nil +} + +func parseOperationRunsQuery(r *http.Request, archiveUID string) (operationRunsQuery, error) { + values, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + return operationRunsQuery{}, newParamError("query", "operation history query is malformed") + } + allowed := map[string]struct{}{ + "kind": {}, "lane": {}, "state": {}, "limit": {}, "cursor": {}, + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + if _, ok := allowed[key]; !ok { + return operationRunsQuery{}, newParamError("query", + fmt.Sprintf("unknown operation history query parameter %q", key)) + } + if len(values[key]) != 1 { + return operationRunsQuery{}, newParamError(key, + fmt.Sprintf("query parameter %q must appear exactly once", key)) + } + } + + filter := operationHistoryFilter{} + if raw, present := operationSingleQueryValue(values, "kind"); present { + filter.Kind = operations.Kind(raw) + if err := filter.Kind.Validate(); err != nil { + return operationRunsQuery{}, enumParamError("kind", raw, operationKindValues()) + } + } + if raw, present := operationSingleQueryValue(values, "lane"); present { + filter.Lane = operations.Lane(raw) + if err := filter.Lane.Validate(); err != nil { + return operationRunsQuery{}, enumParamError("lane", raw, operationLaneValues()) + } + } + if raw, present := operationSingleQueryValue(values, "state"); present { + filter.State = operations.State(raw) + if err := filter.State.Validate(); err != nil { + return operationRunsQuery{}, enumParamError("state", raw, operationStateValues()) + } + } + if err := filter.validate(); err != nil { + return operationRunsQuery{}, newParamError("lane", err.Error()) + } + + limit := operationRunsDefaultLimit + if raw, present := operationSingleQueryValue(values, "limit"); present { + limit, err = strconv.Atoi(raw) + if err != nil || limit < 1 || limit > 100 { + return operationRunsQuery{}, newParamError("limit", + "query parameter \"limit\" must be an integer between 1 and 100") + } + } + + query := operations.Query{ + Kinds: operationFilterKinds(filter), + States: operationFilterStates(filter), + Limit: limit, + } + if raw, present := operationSingleQueryValue(values, "cursor"); present { + if raw == "" { + return operationRunsQuery{}, invalidOperationCursor(errors.New("cursor is empty")) + } + position, decodeErr := decodeOperationCursor(raw, filter, archiveUID) + if decodeErr != nil { + return operationRunsQuery{}, decodeErr + } + query.Position = &position + } + if err := query.Validate(); err != nil { + if query.Position != nil { + return operationRunsQuery{}, invalidOperationCursor(err) + } + return operationRunsQuery{}, newParamError("query", err.Error()) + } + return operationRunsQuery{Query: query, filter: filter}, nil +} + +func operationSingleQueryValue(values url.Values, name string) (string, bool) { + raw, present := values[name] + if !present { + return "", false + } + return raw[0], true +} + +func (filter operationHistoryFilter) validate() error { + if filter.Kind != "" { + if err := filter.Kind.Validate(); err != nil { + return err + } + } + if filter.Lane != "" { + if err := filter.Lane.Validate(); err != nil { + return err + } + } + if filter.State != "" { + if err := filter.State.Validate(); err != nil { + return err + } + } + if filter.Kind != "" && filter.Lane != "" && !operationKindUsesLane(filter.Kind, filter.Lane) { + return fmt.Errorf("operation kind %q does not belong to lane %q", filter.Kind, filter.Lane) + } + return nil +} + +func (filter operationHistoryFilter) allows(kind operations.Kind) bool { + if filter.Kind != "" && filter.Kind != kind { + return false + } + return filter.Lane == "" || operationKindUsesLane(kind, filter.Lane) +} + +func operationFilterFingerprint(filter operationHistoryFilter) string { + canonical := fmt.Sprintf("kind=%s\nlane=%s\nstate=%s\n", filter.Kind, filter.Lane, filter.State) + digest := sha256.Sum256([]byte(canonical)) + return hex.EncodeToString(digest[:]) +} + +func operationFilterKinds(filter operationHistoryFilter) []operations.Kind { + if filter.Kind != "" { + return []operations.Kind{filter.Kind} + } + if filter.Lane == "" { + return nil + } + kinds := make([]operations.Kind, 0) + for _, definition := range operations.LaneRegistry() { + if definition.Lane == filter.Lane && + definition.HistoryAvailability == operations.HistoryAvailable { + kinds = append(kinds, definition.Kind) + } + } + slices.Sort(kinds) + return kinds +} + +func operationFilterStates(filter operationHistoryFilter) []operations.State { + if filter.State == "" { + return nil + } + return []operations.State{filter.State} +} + +func operationKindUsesLane(kind operations.Kind, lane operations.Lane) bool { + for _, definition := range operations.LaneRegistry() { + if definition.Kind == kind { + return definition.Lane == lane + } + } + return false +} + +func operationKindValues() []string { + definitions := operations.LaneRegistry() + values := make([]string, 0, len(definitions)) + for _, definition := range definitions { + values = append(values, string(definition.Kind)) + } + return values +} + +func operationLaneValues() []string { + values := []string{ + string(operations.LaneMessages), + string(operations.LanePersonFacts), + string(operations.LaneContacts), + string(operations.LaneDocuments), + string(operations.LaneVisualAttachments), + } + slices.Sort(values) + return values +} + +func operationStateValues() []string { + values := []string{ + string(operations.StateQueued), + string(operations.StateRunning), + string(operations.StateSucceeded), + string(operations.StatePartial), + string(operations.StateFailed), + string(operations.StateCancelled), + } + slices.Sort(values) + return values +} + +func (s *Server) handleOperationRuns(w http.ResponseWriter, r *http.Request) { + if s.operationHistoryReader == nil { + writeOperationHistoryUnavailable(w) + return + } + archiveUID, ok := s.operationHistoryArchiveUID(w, r) + if !ok { + return + } + parsed, err := parseOperationRunsQuery(r, archiveUID) + if err != nil { + if errors.Is(err, errInvalidOperationCursor) { + writeError(w, http.StatusBadRequest, "invalid_cursor", "Operation history cursor is invalid") + return + } + s.rejectBadParam(w, err) + return + } + if parsed.filter.Kind != "" && operationKindUnavailable(parsed.filter.Kind) { + writeOperationHistoryUnavailable(w) + return + } + if parsed.filter.Lane != "" && len(parsed.Query.Kinds) == 0 { + writeOperationHistoryUnavailable(w) + return + } + + runs, err := s.operationHistoryReader.ListRuns(r.Context(), parsed.Query) + if err != nil { + s.writeOperationHistoryReaderError(w, err) + return + } + if len(runs) > parsed.Query.Limit+1 { + writeError(w, http.StatusInternalServerError, "operation_history_failed", + "Operation history could not be read") + return + } + hasMore := len(runs) > parsed.Query.Limit + if hasMore { + runs = runs[:parsed.Query.Limit] + } + response := OperationRunsResponse{ + Runs: make([]OperationRunSummary, 0, len(runs)), + UnavailableKinds: unavailableOperationKinds(parsed.filter), + } + for _, run := range runs { + summary, projectErr := operationRunSummary(run, archiveUID) + if projectErr != nil { + writeError(w, http.StatusInternalServerError, "operation_history_failed", + "Operation history could not be read") + return + } + response.Runs = append(response.Runs, summary) + } + if hasMore && len(runs) > 0 { + last := runs[len(runs)-1] + response.NextCursor, err = encodeOperationCursor( + operations.Position{StartedAt: last.StartedAt, ID: last.ID}, parsed.filter, archiveUID) + if err != nil { + writeError(w, http.StatusInternalServerError, "operation_history_failed", + "Operation history could not be read") + return + } + } + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) handleOperationStatus(w http.ResponseWriter, r *http.Request) { + visualConfigured, visualActions := s.operationVisualAdvertisement(r.Context()) + response := OperationStatusResponse{ + Lanes: make([]OperationLaneStatus, 0, len(operations.LaneRegistry())), + } + for _, definition := range operations.LaneRegistry() { + lane := OperationLaneStatus{ + Kind: definition.Kind, Lane: definition.Lane, + Configured: s.operationLaneConfigured(r.Context(), definition.Kind, visualConfigured), + HistoryAvailability: definition.HistoryAvailability, + UnavailableCode: definition.UnavailableCode, + RelatedStatus: operationRelatedStatus(definition.Kind), + SupportedActions: make([]operations.ActionID, 0), + } + switch definition.Kind { + case operations.KindCardDAVSync: + if lane.Configured { + lane.SupportedActions = append(lane.SupportedActions, operations.ActionCardDAVSync) + } + case operations.KindVisualEmbedding: + lane.SupportedActions = append(lane.SupportedActions, visualActions...) + default: + // The remaining registered lanes do not expose direct actions here. + } + if definition.HistoryAvailability == operations.HistoryAvailable { + s.projectOperationLaneHistory(r.Context(), &lane) + } + response.Lanes = append(response.Lanes, lane) + } + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) projectOperationLaneHistory(ctx context.Context, lane *OperationLaneStatus) { + if s.operationHistoryReader == nil { + degradeOperationLaneHistory(lane) + return + } + status, err := s.operationHistoryReader.LaneStatus(ctx, lane.Kind) + if err != nil || status.Validate() != nil { + degradeOperationLaneHistory(lane) + return + } + lane.HistoryAvailability = status.HistoryAvailability + lane.UnavailableCode = status.UnavailableCode + if status.Active == nil && status.Latest == nil && status.LatestSuccessful == nil { + return + } + identifier, ok := s.store.(ArchiveIdentifier) + if !ok { + degradeOperationLaneHistory(lane) + return + } + archiveUID, err := identifier.ArchiveUIDContext(ctx) + if err != nil || validateOperationArchiveUID(archiveUID) != nil { + degradeOperationLaneHistory(lane) + return + } + lane.Active, err = operationRunSummaryPointer(status.Active, archiveUID) + if err == nil { + lane.Latest, err = operationRunSummaryPointer(status.Latest, archiveUID) + } + if err == nil { + lane.LatestSuccessful, err = operationRunSummaryPointer(status.LatestSuccessful, archiveUID) + } + if err != nil { + degradeOperationLaneHistory(lane) + } +} + +func operationRunSummaryPointer(run *operations.Run, archiveUID string) (*OperationRunSummary, error) { + if run == nil { + return nil, nil //nolint:nilnil // An absent lane run is a valid optional projection. + } + summary, err := operationRunSummary(*run, archiveUID) + if err != nil { + return nil, err + } + return &summary, nil +} + +func degradeOperationLaneHistory(lane *OperationLaneStatus) { + lane.HistoryAvailability = operations.HistoryUnavailable + lane.UnavailableCode = string(lane.Kind) + "_history_unavailable" + lane.Active = nil + lane.Latest = nil + lane.LatestSuccessful = nil +} + +type operationSourceLister interface { + ListSourcesContext(ctx context.Context, account string) ([]*store.Source, error) +} + +func (s *Server) operationLaneConfigured( + ctx context.Context, kind operations.Kind, visualConfigured bool, +) bool { + if s.cfg == nil { + return false + } + switch kind { + case operations.KindCardDAVSync: + if s.cardDAV == nil { + return false + } + status, err := s.cardDAV.Status(ctx) + return err == nil && status.Configured && status.Available && status.CredentialConfigured + case operations.KindDocumentEmbedding: + return s.cfg.Vector.Enabled && s.cfg.Attachments.Documents.Index.Embeddings.Enabled + case operations.KindDocumentExtraction: + return s.cfg.Attachments.Documents.Enabled + case operations.KindMessageEmbedding: + return s.cfg.Vector.Enabled + case operations.KindPersonEmbedding: + return s.cfg.Vector.Enabled && s.cfg.Vector.People.Enabled + case operations.KindPersonEnrichment: + return false + case operations.KindPersonSweep: + return s.cfg.People.Sweep.Enabled + case operations.KindSourceSync: + sources, ok := s.store.(operationSourceLister) + if !ok { + return false + } + rows, err := sources.ListSourcesContext(ctx, "") + return err == nil && len(rows) > 0 + case operations.KindVisualEmbedding: + return visualConfigured + default: + return false + } +} + +func (s *Server) operationVisualAdvertisement(ctx context.Context) (bool, []operations.ActionID) { + s.vectorMu.RLock() + build, run, statusFn := s.visualBuild, s.visualRun, s.visualStatus + s.vectorMu.RUnlock() + if statusFn == nil { + return false, []operations.ActionID{} + } + status, err := statusFn(ctx, false) + if err != nil { + return true, []operations.ActionID{} + } + switch status.Generation.State { + case store.VisualGenerationBuilding: + if status.Generation.Consented && run != nil { + return true, []operations.ActionID{operations.ActionVisualResume} + } + if !status.Generation.Consented && build != nil { + return true, []operations.ActionID{operations.ActionVisualBuild} + } + case store.VisualGenerationActive: + complete := status.ReconciliationComplete && status.JournalLag == 0 && status.Stale == 0 && + status.Converged == status.ConvergenceTotal + if !complete && run != nil { + return true, []operations.ActionID{operations.ActionVisualResume} + } + case store.VisualGenerationRetired: + // Retired generations advertise no action until a new build is configured. + } + return true, []operations.ActionID{} +} + +func operationRelatedStatus(kind operations.Kind) *operations.RelatedStatusID { + var related operations.RelatedStatusID + switch kind { + case operations.KindCardDAVSync: + related = operations.RelatedStatusCardDAV + case operations.KindDocumentEmbedding: + related = operations.RelatedStatusDocumentVector + case operations.KindDocumentExtraction: + related = operations.RelatedStatusDocumentIndex + case operations.KindSourceSync: + related = operations.RelatedStatusSource + case operations.KindVisualEmbedding: + related = operations.RelatedStatusVisual + default: + return nil + } + return &related +} + +func (s *Server) handleOperationRunDetail(w http.ResponseWriter, r *http.Request) { + if s.operationHistoryReader == nil { + writeOperationHistoryUnavailable(w) + return + } + archiveUID, ok := s.operationHistoryArchiveUID(w, r) + if !ok { + return + } + id, err := decodeOperationRunReference(r.PathValue("id"), archiveUID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_operation_run_id", + "Operation run ID is invalid") + return + } + run, err := s.operationHistoryReader.GetRun(r.Context(), id) + if err != nil { + switch { + case errors.Is(err, store.ErrOperationRunNotFound): + writeError(w, http.StatusNotFound, "operation_run_not_found", "Operation run was not found") + case errors.Is(err, store.ErrOperationHistoryUnavailable): + writeOperationHistoryUnavailable(w) + default: + s.writeOperationHistoryReaderError(w, err) + } + return + } + summary, err := operationRunSummary(run, archiveUID) + if err != nil { + writeError(w, http.StatusInternalServerError, "operation_history_failed", + "Operation history could not be read") + return + } + writeJSON(w, http.StatusOK, OperationRunDetail{OperationRunSummary: summary}) +} + +func (s *Server) operationHistoryArchiveUID(w http.ResponseWriter, r *http.Request) (string, bool) { + identifier, ok := s.store.(ArchiveIdentifier) + if !ok { + writeOperationHistoryUnavailable(w) + return "", false + } + archiveUID, err := identifier.ArchiveUIDContext(r.Context()) + if err != nil || validateOperationArchiveUID(archiveUID) != nil { + writeOperationHistoryUnavailable(w) + return "", false + } + return archiveUID, true +} + +func (s *Server) writeOperationHistoryReaderError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, store.ErrOperationHistoryUnavailable): + writeOperationHistoryUnavailable(w) + default: + writeError(w, http.StatusInternalServerError, "operation_history_failed", + "Operation history could not be read") + } +} + +func writeOperationHistoryUnavailable(w http.ResponseWriter) { + writeError(w, http.StatusServiceUnavailable, "operation_history_unavailable", + "Operation history is unavailable") +} + +func operationRunSummary(run operations.Run, archiveUID string) (OperationRunSummary, error) { + if err := run.Validate(); err != nil { + return OperationRunSummary{}, fmt.Errorf("project operation run: %w", err) + } + ref, err := encodeOperationRunReference(run.ID, archiveUID) + if err != nil { + return OperationRunSummary{}, err + } + summary := OperationRunSummary{ + ID: ref, Kind: run.ID.Kind(), Lane: run.Lane, State: run.State, + Trigger: run.Trigger, StartedAt: run.StartedAt, FinishedAt: run.FinishedAt, + Counters: make([]OperationPublicCounter, 0, len(run.Counters)), + } + for _, counter := range run.Counters { + summary.Counters = append(summary.Counters, OperationPublicCounter{ + Name: counter.Name, Unit: counter.Unit, Value: counter.Value, + }) + } + if run.Error != nil { + summary.Error = &OperationPublicError{Code: run.Error.Code, Message: run.Error.Message} + } + return summary, nil +} + +func operationKindUnavailable(kind operations.Kind) bool { + for _, definition := range operations.LaneRegistry() { + if definition.Kind == kind { + return definition.HistoryAvailability == operations.HistoryUnavailable + } + } + return true +} + +func unavailableOperationKinds(filter operationHistoryFilter) []OperationUnavailableKind { + result := make([]OperationUnavailableKind, 0) + if filter.Kind != "" { + return result + } + for _, definition := range operations.LaneRegistry() { + if definition.HistoryAvailability != operations.HistoryUnavailable { + continue + } + if filter.Lane != "" && definition.Lane != filter.Lane { + continue + } + result = append(result, OperationUnavailableKind{ + Kind: definition.Kind, Lane: definition.Lane, UnavailableCode: definition.UnavailableCode, + }) + } + return result +} + +func operationStableID( + kind operations.Kind, idType operations.StableIDType, + intID *int64, intPresent bool, stringID *string, stringPresent bool, +) (operations.StableID, error) { + if err := kind.Validate(); err != nil { + return operations.StableID{}, err + } + if err := idType.Validate(); err != nil { + return operations.StableID{}, err + } + if intPresent == stringPresent { + return operations.StableID{}, errors.New("operation token must carry exactly one stable ID") + } + switch idType { + case operations.StableIDInt64: + if !intPresent || intID == nil || stringPresent { + return operations.StableID{}, errors.New("operation numeric token has the wrong ID field") + } + return operations.NewInt64ID(kind, *intID) + case operations.StableIDText: + if !stringPresent || stringID == nil || intPresent { + return operations.StableID{}, errors.New("operation text token has the wrong ID field") + } + return operations.NewTextID(kind, *stringID) + default: + return operations.StableID{}, errors.New("operation token has an unsupported ID type") + } +} + +func encodeOperationToken(payload any) (string, error) { + encoded, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("encode operation token: %w", err) + } + if len(encoded) > maxOperationTokenPayloadBytes { + return "", errors.New("encode operation token: payload is too large") + } + return operationTokenVersion + "." + base64.RawURLEncoding.EncodeToString(encoded), nil +} + +func decodeOperationToken(raw string) ([]byte, error) { + prefix, encoded, found := strings.Cut(raw, ".") + if !found || prefix != operationTokenVersion || encoded == "" || + len(encoded) > base64.RawURLEncoding.EncodedLen(maxOperationTokenPayloadBytes) { + return nil, errors.New("operation token has an invalid envelope") + } + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil || len(decoded) == 0 || len(decoded) > maxOperationTokenPayloadBytes { + return nil, errors.New("operation token payload is invalid") + } + if base64.RawURLEncoding.EncodeToString(decoded) != encoded { + return nil, errors.New("operation token payload encoding is noncanonical") + } + return decoded, nil +} + +// decodeStrictOperationObject rejects duplicate and unknown fields and +// requires exactly one top-level JSON object. json.Decoder alone permits +// duplicate object names, so the first pass checks names before the typed pass. +func decodeStrictOperationObject( + encoded []byte, target any, allowed func(string) bool, +) (map[string]struct{}, error) { + if !utf8.Valid(encoded) { + return nil, errors.New("operation token payload must be valid UTF-8") + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + token, err := decoder.Token() + if err != nil { + return nil, err + } + delimiter, ok := token.(json.Delim) + if !ok || delimiter != '{' { + return nil, errors.New("operation token payload must be an object") + } + seen := make(map[string]struct{}) + for decoder.More() { + nameToken, tokenErr := decoder.Token() + if tokenErr != nil { + return nil, tokenErr + } + name, ok := nameToken.(string) + if !ok { + return nil, errors.New("operation token object key is invalid") + } + if _, duplicate := seen[name]; duplicate { + return nil, fmt.Errorf("operation token field %q is duplicated", name) + } + if !allowed(name) { + return nil, fmt.Errorf("operation token field %q is not allowed", name) + } + seen[name] = struct{}{} + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, err + } + } + if _, err := decoder.Token(); err != nil { + return nil, err + } + if err := requireOperationJSONEOF(decoder); err != nil { + return nil, err + } + + typed := json.NewDecoder(bytes.NewReader(encoded)) + typed.DisallowUnknownFields() + if err := typed.Decode(target); err != nil { + return nil, err + } + if err := requireOperationJSONEOF(typed); err != nil { + return nil, err + } + return seen, nil +} + +func operationRunReferenceFieldAllowed(name string) bool { + switch name { + case "kind", "id_type", "int_id", "string_id", "archive_uid": + return true + default: + return false + } +} + +func operationCursorFieldAllowed(name string) bool { + switch name { + case "t", "k", "it", "i", "s", "f", "a": + return true + default: + return false + } +} + +func operationFieldPresent(fields map[string]struct{}, name string) bool { + _, present := fields[name] + return present +} + +func requireOperationJSONEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("operation token payload has trailing JSON") + } + return err + } + return nil +} + +func validateOperationArchiveUID(value string) error { + if value == "" || strings.TrimSpace(value) != value || !utf8.ValidString(value) || + len(value) > maxOperationArchiveUIDBytes { + return errors.New("operation cursor archive UID must be nonempty, canonical, and bounded") + } + return nil +} + +func validOperationFilterHash(value string) bool { + if len(value) != sha256.Size*2 { + return false + } + for _, character := range value { + if (character < '0' || character > '9') && (character < 'a' || character > 'f') { + return false + } + } + return true +} + +func invalidOperationRunReference(cause error) error { + return fmt.Errorf("%w: %w", errInvalidOperationRunReference, cause) +} + +func invalidOperationCursor(cause error) error { + return errors.Join(errInvalidOperationCursor, + newParamError("cursor", fmt.Sprintf("operation history cursor is invalid: %v", cause))) +} diff --git a/internal/api/operations_cursor_test.go b/internal/api/operations_cursor_test.go new file mode 100644 index 000000000..420c91926 --- /dev/null +++ b/internal/api/operations_cursor_test.go @@ -0,0 +1,486 @@ +package api + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/operations" +) + +const ( + operationTestArchive = "archive-fixture-01" + operationTestHash = "09400aa4c775f0beee51af164d411202bd86e7e50263e7b8109317a77c1e6c99" +) + +func TestOperationRunReferenceRoundTripsTypedIDs(t *testing.T) { + tests := []struct { + name string + id operations.StableID + wantPayload string + }{ + { + name: "source numeric", + id: mustOperationIntID(t, operations.KindSourceSync, 42), + wantPayload: `{"kind":"source_sync","id_type":"int64","int_id":42,"archive_uid":"archive-fixture-01"}`, + }, + { + name: "CardDAV numeric", + id: mustOperationIntID(t, operations.KindCardDAVSync, 9), + wantPayload: `{"kind":"carddav_sync","id_type":"int64","int_id":9,"archive_uid":"archive-fixture-01"}`, + }, + { + name: "person sweep text", + id: mustOperationTextID(t, operations.KindPersonSweep, "sweep-fixture-01"), + wantPayload: `{"kind":"person_sweep","id_type":"text","string_id":"sweep-fixture-01","archive_uid":"archive-fixture-01"}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + token, err := encodeOperationRunReference(test.id, operationTestArchive) + require.NoError(err) + assert.Equal(test.wantPayload, operationTokenPayload(t, token)) + + decoded, err := decodeOperationRunReference(token, operationTestArchive) + require.NoError(err) + assert.Equal(test.id, decoded) + }) + } +} + +func TestOperationRunReferenceRejectsMalformedOrNoncanonicalTokens(t *testing.T) { + oversized := strings.Repeat("x", operations.MaxTextStableIDBytes+1) + valid := `{"kind":"source_sync","id_type":"int64","int_id":1,"archive_uid":"archive-fixture-01"}` + tests := []struct { + name string + raw string + }{ + {"missing version", operationRawToken(valid)}, + {"malformed version", "x." + operationRawToken(`{}`)}, + {"future version", "2." + operationRawToken(`{}`)}, + {"missing payload", "1."}, + {"invalid raw base64", "1.%%%"}, + {"padded base64", "1.e30="}, + {"invalid JSON", operationVersionedToken(`{"kind":`)}, + {"mixed case kind alias", operationVersionedToken(strings.Replace(valid, `"kind"`, `"Kind"`, 1))}, + {"mixed case ID type alias", operationVersionedToken(strings.Replace(valid, `"id_type"`, `"ID_TYPE"`, 1))}, + {"semantic duplicate field", operationVersionedToken(strings.Replace(valid, `"kind":"source_sync"`, `"kind":"carddav_sync","Kind":"source_sync"`, 1))}, + {"invalid UTF-8 string", operationInvalidUTF8RunToken()}, + {"unknown field", operationVersionedToken(strings.TrimSuffix(valid, "}") + `,"provider":"private"}`)}, + {"duplicate field", operationVersionedToken(strings.Replace(valid, `"kind":"source_sync"`, `"kind":"source_sync","kind":"carddav_sync"`, 1))}, + {"trailing JSON", operationVersionedToken(valid + `{}`)}, + {"null object", operationVersionedToken(`null`)}, + {"null required field", operationVersionedToken(strings.Replace(valid, `"kind":"source_sync"`, `"kind":null`, 1))}, + {"missing kind", operationVersionedToken(strings.Replace(valid, `"kind":"source_sync",`, "", 1))}, + {"unknown kind", operationVersionedToken(strings.Replace(valid, "source_sync", "provider_sync", 1))}, + {"unknown ID type", operationVersionedToken(strings.Replace(valid, `"id_type":"int64"`, `"id_type":"number"`, 1))}, + {"wrong numeric kind pair", operationVersionedToken(strings.Replace(valid, "source_sync", "person_sweep", 1))}, + {"wrong text kind pair", operationVersionedToken(`{"kind":"source_sync","id_type":"text","string_id":"run-1","archive_uid":"archive-fixture-01"}`)}, + {"both ID variants", operationVersionedToken(strings.Replace(valid, `"int_id":1`, `"int_id":1,"string_id":"run-1"`, 1))}, + {"null alternate ID variant", operationVersionedToken(strings.Replace(valid, `"int_id":1`, `"int_id":1,"string_id":null`, 1))}, + {"neither ID variant", operationVersionedToken(strings.Replace(valid, `,"int_id":1`, "", 1))}, + {"null numeric ID", operationVersionedToken(strings.Replace(valid, `"int_id":1`, `"int_id":null`, 1))}, + {"zero numeric ID", operationVersionedToken(strings.Replace(valid, `"int_id":1`, `"int_id":0`, 1))}, + {"negative numeric ID", operationVersionedToken(strings.Replace(valid, `"int_id":1`, `"int_id":-1`, 1))}, + {"empty text ID", operationVersionedToken(`{"kind":"person_sweep","id_type":"text","string_id":"","archive_uid":"archive-fixture-01"}`)}, + {"oversized text ID", operationVersionedToken(`{"kind":"person_sweep","id_type":"text","string_id":"` + oversized + `","archive_uid":"archive-fixture-01"}`)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := decodeOperationRunReference(test.raw, operationTestArchive) + assert.ErrorIs(t, err, errInvalidOperationRunReference) + }) + } +} + +func TestOperationRunReferenceBindsArchiveAndValidatesArchiveUID(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + id := mustOperationIntID(t, operations.KindSourceSync, 42) + token, err := encodeOperationRunReference(id, operationTestArchive) + require.NoError(err) + + decoded, err := decodeOperationRunReference(token, operationTestArchive) + require.NoError(err) + assert.Equal(id, decoded) + + _, err = decodeOperationRunReference(token, "another-archive") + require.ErrorIs(err, errInvalidOperationRunReference) + _, err = encodeOperationRunReference(id, "") + require.Error(err) + _, err = encodeOperationRunReference(id, strings.Repeat("a", maxOperationArchiveUIDBytes+1)) + require.Error(err) + + for _, payload := range []string{ + `{"kind":"source_sync","id_type":"int64","int_id":42,"archive_uid":null}`, + `{"kind":"source_sync","id_type":"int64","int_id":42,"archive_uid":""}`, + `{"kind":"source_sync","id_type":"int64","int_id":42,"archive_uid":"` + strings.Repeat("a", maxOperationArchiveUIDBytes+1) + `"}`, + } { + _, err = decodeOperationRunReference(operationVersionedToken(payload), operationTestArchive) + require.ErrorIs(err, errInvalidOperationRunReference) + } +} + +func TestOperationCursorRoundTripsAndBindsArchiveAndFilter(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + filter := operationHistoryFilter{ + Kind: operations.KindSourceSync, Lane: operations.LaneMessages, State: operations.StateFailed, + } + position := operations.Position{ + StartedAt: time.Date(2026, 8, 28, 12, 34, 56, 123456000, time.UTC), + ID: mustOperationIntID(t, operations.KindSourceSync, 42), + } + token, err := encodeOperationCursor(position, filter, operationTestArchive) + require.NoError(err) + assert.JSONEq( + `{"t":"2026-08-28T12:34:56.123456Z","k":"source_sync","it":"int64","i":42,"f":"`+ + operationTestHash+`","a":"archive-fixture-01"}`, + operationTokenPayload(t, token)) + + decoded, err := decodeOperationCursor(token, filter, operationTestArchive) + require.NoError(err) + assert.Equal(position, decoded) + + _, err = decodeOperationCursor(token, filter, "another-archive") + require.ErrorIs(err, errInvalidOperationCursor) + for _, changed := range []operationHistoryFilter{ + {Lane: operations.LaneMessages, State: operations.StateFailed}, + {Kind: operations.KindSourceSync, State: operations.StateFailed}, + {Kind: operations.KindSourceSync, Lane: operations.LaneMessages}, + } { + _, err = decodeOperationCursor(token, changed, operationTestArchive) + require.ErrorIs(err, errInvalidOperationCursor) + } +} + +func TestOperationCursorRejectsMalformedOrNoncanonicalTokens(t *testing.T) { + filter := operationHistoryFilter{ + Kind: operations.KindSourceSync, Lane: operations.LaneMessages, State: operations.StateFailed, + } + valid := `{"t":"2026-08-28T12:34:56.123456Z","k":"source_sync","it":"int64","i":42,"f":"` + + operationTestHash + `","a":"archive-fixture-01"}` + tests := []struct { + name string + raw string + }{ + {"missing version", operationRawToken(valid)}, + {"malformed version", "v1." + operationRawToken(valid)}, + {"future version", "2." + operationRawToken(valid)}, + {"missing payload", "1."}, + {"invalid raw base64", "1.%%%"}, + {"invalid JSON", operationVersionedToken(`{"t":`)}, + {"mixed case kind alias", operationVersionedToken(strings.Replace(valid, `"k":"source_sync"`, `"K":"source_sync"`, 1))}, + {"semantic duplicate field", operationVersionedToken(strings.Replace(valid, `"k":"source_sync"`, `"k":"carddav_sync","K":"source_sync"`, 1))}, + {"unknown field", operationVersionedToken(strings.TrimSuffix(valid, "}") + `,"provider_cursor":"private"}`)}, + {"duplicate field", operationVersionedToken(strings.Replace(valid, `"k":"source_sync"`, `"k":"source_sync","k":"carddav_sync"`, 1))}, + {"trailing JSON", operationVersionedToken(valid + `{}`)}, + {"null object", operationVersionedToken(`null`)}, + {"null required field", operationVersionedToken(strings.Replace(valid, `"t":"2026-08-28T12:34:56.123456Z"`, `"t":null`, 1))}, + {"missing timestamp", operationVersionedToken(strings.Replace(valid, `"t":"2026-08-28T12:34:56.123456Z",`, "", 1))}, + {"malformed timestamp", operationVersionedToken(strings.Replace(valid, "2026-08-28T12:34:56.123456Z", "yesterday", 1))}, + {"non UTC timestamp", operationVersionedToken(strings.Replace(valid, "2026-08-28T12:34:56.123456Z", "2026-08-28T14:34:56.123456+02:00", 1))}, + {"unknown kind", operationVersionedToken(strings.Replace(valid, "source_sync", "provider_sync", 1))}, + {"unknown ID type", operationVersionedToken(strings.Replace(valid, `"it":"int64"`, `"it":"number"`, 1))}, + {"wrong kind ID pair", operationVersionedToken(strings.Replace(valid, "source_sync", "person_sweep", 1))}, + {"both ID variants", operationVersionedToken(strings.Replace(valid, `"i":42`, `"i":42,"s":"run-1"`, 1))}, + {"null alternate ID variant", operationVersionedToken(strings.Replace(valid, `"i":42`, `"i":42,"s":null`, 1))}, + {"neither ID variant", operationVersionedToken(strings.Replace(valid, `,"i":42`, "", 1))}, + {"nonpositive ID", operationVersionedToken(strings.Replace(valid, `"i":42`, `"i":0`, 1))}, + {"invalid filter hash", operationVersionedToken(strings.Replace(valid, operationTestHash, "ABC", 1))}, + {"missing filter hash", operationVersionedToken(strings.Replace(valid, `,"f":"`+operationTestHash+`"`, "", 1))}, + {"empty archive", operationVersionedToken(strings.Replace(valid, operationTestArchive, "", 1))}, + {"missing archive", operationVersionedToken(strings.Replace(valid, `,"a":"archive-fixture-01"`, "", 1))}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := decodeOperationCursor(test.raw, filter, operationTestArchive) + assert.ErrorIs(t, err, errInvalidOperationCursor) + }) + } +} + +func TestOperationCursorSupportsTextStableID(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + position := operations.Position{ + StartedAt: time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC), + ID: mustOperationTextID(t, operations.KindPersonSweep, "sweep-fixture-02"), + } + filter := operationHistoryFilter{Kind: operations.KindPersonSweep} + token, err := encodeOperationCursor(position, filter, operationTestArchive) + require.NoError(err) + assert.Contains(operationTokenPayload(t, token), `"it":"text","s":"sweep-fixture-02"`) + decoded, err := decodeOperationCursor(token, filter, operationTestArchive) + require.NoError(err) + assert.Equal(position, decoded) +} + +func TestOperationTokenEnvelopeRequiresCanonicalRawBase64(t *testing.T) { + newAssertions := assert.New + require := require.New(t) + assert := assert.New(t) + id := mustOperationIntID(t, operations.KindSourceSync, 42) + canonical, err := encodeOperationRunReference(id, operationTestArchive) + require.NoError(err) + prefix, encoded, found := strings.Cut(canonical, ".") + require.True(found) + alternateToken, err := encodeOperationRunReference(id, operationTestArchive+"x") + require.NoError(err) + _, alternateEncoded, found := strings.Cut(alternateToken, ".") + require.True(found) + + tests := []struct { + name string + raw string + }{ + {"embedded newline", prefix + "." + encoded[:4] + "\n" + encoded[4:]}, + {"alternate tail bits", prefix + "." + operationAlternateBase64Tail(t, alternateEncoded)}, + {"oversized envelope", prefix + "." + strings.Repeat("A", base64.RawURLEncoding.EncodedLen(maxOperationTokenPayloadBytes)+1)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := newAssertions(t) + _, err := decodeOperationRunReference(test.raw, operationTestArchive) + assert.ErrorIs(err, errInvalidOperationRunReference) + }) + } + + atLimit := bytes.Repeat([]byte{'x'}, maxOperationTokenPayloadBytes) + encodedAtLimit := base64.RawURLEncoding.EncodeToString(atLimit) + decoded, err := decodeOperationToken(operationTokenVersion + "." + encodedAtLimit) + require.NoError(err) + assert.Equal(atLimit, decoded) + tooLarge := append(bytes.Clone(atLimit), 'x') + _, err = decodeOperationToken(operationTokenVersion + "." + base64.RawURLEncoding.EncodeToString(tooLarge)) + assert.Error(err) +} + +func TestOperationCursorRejectsOversizedArchiveUID(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + position := operations.Position{ + StartedAt: time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC), + ID: mustOperationIntID(t, operations.KindSourceSync, 42), + } + filter := operationHistoryFilter{Kind: operations.KindSourceSync} + atLimit := strings.Repeat("a", maxOperationArchiveUIDBytes) + token, err := encodeOperationCursor(position, filter, atLimit) + require.NoError(err) + decoded, err := decodeOperationCursor(token, filter, atLimit) + require.NoError(err) + assert.Equal(position, decoded) + + _, err = encodeOperationCursor(position, filter, + strings.Repeat("a", maxOperationArchiveUIDBytes+1)) + assert.Error(err) +} + +func TestOperationQueryParsesDefaultsAndNormalizedFilters(t *testing.T) { + tests := []struct { + name string + rawQuery string + wantKinds []operations.Kind + wantState []operations.State + wantLimit int + }{ + {name: "defaults", wantLimit: 25}, + {name: "kind", rawQuery: "kind=source_sync", wantKinds: []operations.Kind{operations.KindSourceSync}, wantLimit: 25}, + {name: "lane", rawQuery: "lane=messages", wantKinds: []operations.Kind{operations.KindSourceSync}, wantLimit: 25}, + {name: "mixed people lane", rawQuery: "lane=person_facts", wantKinds: []operations.Kind{operations.KindPersonSweep}, wantLimit: 25}, + {name: "unavailable-only lane", rawQuery: "lane=documents", wantKinds: []operations.Kind{}, wantLimit: 25}, + {name: "matching kind and lane", rawQuery: "kind=carddav_sync&lane=contacts", wantKinds: []operations.Kind{operations.KindCardDAVSync}, wantLimit: 25}, + {name: "state and limit", rawQuery: "state=partial&limit=100", wantState: []operations.State{operations.StatePartial}, wantLimit: 100}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + request := httptest.NewRequest(http.MethodGet, "/api/v1/operations/runs?"+test.rawQuery, nil) + parsed, err := parseOperationRunsQuery(request, operationTestArchive) + require.NoError(t, err) + assert.Equal(test.wantKinds, parsed.Query.Kinds) + assert.Equal(test.wantState, parsed.Query.States) + assert.Equal(test.wantLimit, parsed.Query.Limit) + assert.Nil(parsed.Query.Position) + assert.NoError(parsed.Query.Validate()) + }) + } +} + +func TestOperationQueryRejectsDuplicateUnknownAndOutOfRangeParameters(t *testing.T) { + tests := []struct { + name string + rawQuery string + }{ + {"duplicate kind", "kind=source_sync&kind=source_sync"}, + {"duplicate lane", "lane=messages&lane=messages"}, + {"duplicate state", "state=failed&state=failed"}, + {"duplicate limit", "limit=25&limit=25"}, + {"multiple cursors", "cursor=first&cursor=second"}, + {"unknown parameter", "provider=gmail"}, + {"unknown kind", "kind=provider_sync"}, + {"unknown lane", "lane=provider"}, + {"unknown state", "state=complete"}, + {"incompatible kind and lane", "kind=source_sync&lane=contacts"}, + {"empty kind", "kind="}, + {"empty cursor", "cursor="}, + {"zero limit", "limit=0"}, + {"limit above maximum", "limit=101"}, + {"negative limit", "limit=-1"}, + {"nonnumeric limit", "limit=twenty"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/api/v1/operations/runs?"+test.rawQuery, nil) + _, err := parseOperationRunsQuery(request, operationTestArchive) + assert.Error(t, err) + }) + } +} + +func TestOperationQueryCursorExcludesLimitAndBindsSemanticFilters(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + first := httptest.NewRequest(http.MethodGet, + "/api/v1/operations/runs?state=failed&limit=10&lane=messages&kind=source_sync", nil) + parsedFirst, err := parseOperationRunsQuery(first, operationTestArchive) + require.NoError(err) + assert.Equal(operationTestHash, operationFilterFingerprint(parsedFirst.filter)) + + position := operations.Position{ + StartedAt: time.Date(2026, 8, 28, 12, 34, 56, 123456000, time.UTC), + ID: mustOperationIntID(t, operations.KindSourceSync, 42), + } + cursor, err := encodeOperationCursor(position, parsedFirst.filter, operationTestArchive) + require.NoError(err) + + second := httptest.NewRequest(http.MethodGet, + "/api/v1/operations/runs?cursor="+cursor+"&kind=source_sync&limit=99&lane=messages&state=failed", nil) + parsedSecond, err := parseOperationRunsQuery(second, operationTestArchive) + require.NoError(err) + assert.Equal(99, parsedSecond.Query.Limit) + require.NotNil(parsedSecond.Query.Position) + assert.Equal(position, *parsedSecond.Query.Position) + assert.Equal(operationFilterFingerprint(parsedFirst.filter), operationFilterFingerprint(parsedSecond.filter)) + + for _, changed := range []string{ + "kind=source_sync&lane=messages&state=partial", + "kind=source_sync&state=failed", + "lane=messages&state=failed", + } { + request := httptest.NewRequest(http.MethodGet, "/api/v1/operations/runs?"+changed+"&cursor="+cursor, nil) + _, err = parseOperationRunsQuery(request, operationTestArchive) + require.ErrorIs(err, errInvalidOperationCursor) + } + + crossArchive := httptest.NewRequest(http.MethodGet, + "/api/v1/operations/runs?kind=source_sync&lane=messages&state=failed&cursor="+cursor, nil) + _, err = parseOperationRunsQuery(crossArchive, "another-archive") + require.ErrorIs(err, errInvalidOperationCursor) +} + +func TestOperationTokensContainOnlyApprovedOpaqueFields(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + id := mustOperationIntID(t, operations.KindSourceSync, 42) + reference, err := encodeOperationRunReference(id, operationTestArchive) + require.NoError(err) + cursor, err := encodeOperationCursor(operations.Position{ + StartedAt: time.Date(2026, 8, 28, 12, 34, 56, 0, time.UTC), ID: id, + }, operationHistoryFilter{Kind: operations.KindSourceSync}, operationTestArchive) + require.NoError(err) + + var referenceFields map[string]json.RawMessage + require.NoError(json.Unmarshal([]byte(operationTokenPayload(t, reference)), &referenceFields)) + assert.ElementsMatch([]string{"kind", "id_type", "int_id", "archive_uid"}, operationMapKeys(referenceFields)) + var cursorFields map[string]json.RawMessage + require.NoError(json.Unmarshal([]byte(operationTokenPayload(t, cursor)), &cursorFields)) + assert.ElementsMatch([]string{"t", "k", "it", "i", "f", "a"}, operationMapKeys(cursorFields)) + + projected := strings.ToLower(reference + " " + cursor + " " + operationTokenPayload(t, reference) + " " + operationTokenPayload(t, cursor)) + for _, forbidden := range []string{ + "checkpoint", "provider_cursor", "source_id", "person_id", "message_id", "account_id", + "credential", "authorization", "href", "vcard", + } { + assert.NotContains(projected, forbidden) + } +} + +func operationVersionedToken(payload string) string { + return "1." + operationRawToken(payload) +} + +func operationVersionedBytes(payload []byte) string { + return "1." + base64.RawURLEncoding.EncodeToString(payload) +} + +func operationInvalidUTF8RunToken() string { + payload := []byte(`{"kind":"person_sweep","id_type":"text","string_id":"`) + payload = append(payload, 0xff) + payload = append(payload, []byte(`","archive_uid":"archive-fixture-01"}`)...) + return operationVersionedBytes(payload) +} + +func operationAlternateBase64Tail(t *testing.T, canonical string) string { + t.Helper() + want, err := base64.RawURLEncoding.DecodeString(canonical) + require.NoError(t, err) + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + for _, candidate := range alphabet { + if byte(candidate) == canonical[len(canonical)-1] { + continue + } + mutated := canonical[:len(canonical)-1] + string(candidate) + decoded, decodeErr := base64.RawURLEncoding.DecodeString(mutated) + if decodeErr == nil && bytes.Equal(want, decoded) { + return mutated + } + } + require.Fail(t, "fixture must have alternate noncanonical tail bits") + return "" +} + +func operationRawToken(payload string) string { + return base64.RawURLEncoding.EncodeToString([]byte(payload)) +} + +func operationTokenPayload(t *testing.T, token string) string { + t.Helper() + prefix, encoded, found := strings.Cut(token, ".") + require.True(t, found) + assert.Equal(t, "1", prefix) + payload, err := base64.RawURLEncoding.DecodeString(encoded) + require.NoError(t, err) + return string(payload) +} + +func mustOperationIntID(t *testing.T, kind operations.Kind, value int64) operations.StableID { + t.Helper() + id, err := operations.NewInt64ID(kind, value) + require.NoError(t, err) + return id +} + +func mustOperationTextID(t *testing.T, kind operations.Kind, value string) operations.StableID { + t.Helper() + id, err := operations.NewTextID(kind, value) + require.NoError(t, err) + return id +} + +func operationMapKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} diff --git a/internal/api/operations_test.go b/internal/api/operations_test.go new file mode 100644 index 000000000..f1507466b --- /dev/null +++ b/internal/api/operations_test.go @@ -0,0 +1,721 @@ +package api + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/operations" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" + "go.kenn.io/msgvault/internal/vector/visual" +) + +const operationTestArchiveUID = "1234567890abcdef1234567890abcdef" + +type operationArchiveStore struct { + *mockStore + + uid string + err error +} + +type operationArchiveRealStore struct { + *store.Store + + uid string +} + +func (s *operationArchiveRealStore) ArchiveUIDContext(context.Context) (string, error) { + return s.uid, nil +} + +func (s *operationArchiveStore) ArchiveUIDContext(context.Context) (string, error) { + return s.uid, s.err +} + +type operationHistoryStub struct { + runs []operations.Run + run operations.Run + listErr error + getErr error + status map[operations.Kind]operations.LaneHistoryStatus + statusErr map[operations.Kind]error + statusQueries []operations.Kind + queries []operations.Query +} + +func (*operationHistoryStub) Kinds() []operations.Kind { + return []operations.Kind{operations.KindCardDAVSync, operations.KindPersonSweep, operations.KindSourceSync} +} + +func (s *operationHistoryStub) ListRuns(_ context.Context, query operations.Query) ([]operations.Run, error) { + s.queries = append(s.queries, query) + return s.runs, s.listErr +} + +func (s *operationHistoryStub) GetRun(context.Context, operations.StableID) (operations.Run, error) { + return s.run, s.getErr +} + +func (s *operationHistoryStub) LaneStatus(_ context.Context, kind operations.Kind) (operations.LaneHistoryStatus, error) { + s.statusQueries = append(s.statusQueries, kind) + if err := s.statusErr[kind]; err != nil { + return operations.LaneHistoryStatus{}, err + } + if status, ok := s.status[kind]; ok { + return status, nil + } + for _, definition := range operations.LaneRegistry() { + if definition.Kind == kind { + return operations.LaneHistoryStatus{ + Kind: kind, Lane: definition.Lane, + HistoryAvailability: definition.HistoryAvailability, + UnavailableCode: definition.UnavailableCode, + }, nil + } + } + return operations.LaneHistoryStatus{}, errors.New("unknown operation kind") +} + +func newOperationTestServer(reader operations.HistoryReader, archive ArchiveIdentifier) *Server { + var messageStore MessageStore = &mockStore{} + if archive != nil { + var ok bool + messageStore, ok = archive.(MessageStore) + if !ok { + panic("operation test archive must implement MessageStore") + } + } + return NewServerWithOptions(ServerOptions{ + Config: &config.Config{}, + Store: messageStore, + OperationHistoryReader: reader, + Logger: testLogger(), + }) +} + +func operationRunFixture(t *testing.T) operations.Run { + t.Helper() + id, err := operations.NewInt64ID(operations.KindSourceSync, 17) + require.NoError(t, err) + finished := time.Date(2026, 8, 29, 12, 0, 1, 0, time.UTC) + return operations.Run{ + ID: id, Lane: operations.LaneMessages, State: operations.StateSucceeded, + StartedAt: finished.Add(-time.Second), FinishedAt: &finished, + Counters: []operations.PublicCounter{ + {Name: operations.CounterProcessed, Unit: operations.CounterUnitMessages, Value: 4}, + {Name: operations.CounterAdded, Unit: operations.CounterUnitMessages, Value: 2}, + {Name: operations.CounterUpdated, Unit: operations.CounterUnitMessages, Value: 1}, + {Name: operations.CounterItemErrors, Unit: operations.CounterUnitMessages, Value: 0}, + }, + } +} + +func TestOperationStatusReturnsExactRegistryAndNonNullActions(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + cfg := config.NewDefaultConfig() + cfg.Vector.Enabled = true + cfg.Vector.People.Enabled = true + cfg.Attachments.Documents.Enabled = true + cfg.Attachments.Documents.Index.Embeddings.Enabled = true + cfg.People.Sweep.Enabled = true + st := testutil.NewTestStore(t) + _, err := st.GetOrCreateSource("fixture", "synthetic-source") + require.NoError(err) + reader := &operationHistoryStub{} + srv := NewServerWithOptions(ServerOptions{ + Config: cfg, Store: st, OperationHistoryReader: reader, Logger: testLogger(), + }) + + w := doGet(srv, "/api/v1/operations/status") + require.Equalf(http.StatusOK, w.Code, "body: %s", w.Body.String()) + var body OperationStatusResponse + require.NoError(json.Unmarshal(w.Body.Bytes(), &body)) + require.Len(body.Lanes, 9) + assert.Equal([]operations.Kind{ + operations.KindCardDAVSync, + operations.KindDocumentEmbedding, + operations.KindDocumentExtraction, + operations.KindMessageEmbedding, + operations.KindPersonEmbedding, + operations.KindPersonEnrichment, + operations.KindPersonSweep, + operations.KindSourceSync, + operations.KindVisualEmbedding, + }, []operations.Kind{ + body.Lanes[0].Kind, body.Lanes[1].Kind, body.Lanes[2].Kind, + body.Lanes[3].Kind, body.Lanes[4].Kind, body.Lanes[5].Kind, + body.Lanes[6].Kind, body.Lanes[7].Kind, body.Lanes[8].Kind, + }) + assert.Equal([]operations.Lane{ + operations.LaneContacts, + operations.LaneDocuments, + operations.LaneDocuments, + operations.LaneMessages, + operations.LanePersonFacts, + operations.LanePersonFacts, + operations.LanePersonFacts, + operations.LaneMessages, + operations.LaneVisualAttachments, + }, []operations.Lane{ + body.Lanes[0].Lane, body.Lanes[1].Lane, body.Lanes[2].Lane, + body.Lanes[3].Lane, body.Lanes[4].Lane, body.Lanes[5].Lane, + body.Lanes[6].Lane, body.Lanes[7].Lane, body.Lanes[8].Lane, + }) + assert.Equal([]string{ + "", "document_embedding_history_unavailable", "document_extraction_history_unavailable", + "message_embedding_history_unavailable", "person_embedding_history_unavailable", + "person_enrichment_history_unavailable", "", "", "visual_embedding_history_unavailable", + }, []string{ + body.Lanes[0].UnavailableCode, body.Lanes[1].UnavailableCode, + body.Lanes[2].UnavailableCode, body.Lanes[3].UnavailableCode, + body.Lanes[4].UnavailableCode, body.Lanes[5].UnavailableCode, + body.Lanes[6].UnavailableCode, body.Lanes[7].UnavailableCode, + body.Lanes[8].UnavailableCode, + }) + for _, lane := range body.Lanes { + assert.NotNil(lane.SupportedActions, "lane %s", lane.Kind) + } + assert.Equal([]operations.Kind{ + operations.KindCardDAVSync, operations.KindPersonSweep, operations.KindSourceSync, + }, reader.statusQueries) + assert.False(body.Lanes[0].Configured) + assert.True(body.Lanes[1].Configured) + assert.True(body.Lanes[2].Configured) + assert.True(body.Lanes[3].Configured) + assert.True(body.Lanes[4].Configured) + assert.False(body.Lanes[5].Configured, "external enrichment remains inactive") + assert.True(body.Lanes[6].Configured) + assert.True(body.Lanes[7].Configured) + assert.False(body.Lanes[8].Configured) + assert.Equal(operations.RelatedStatusCardDAV, *body.Lanes[0].RelatedStatus) + assert.Equal(operations.RelatedStatusDocumentVector, *body.Lanes[1].RelatedStatus) + assert.Equal(operations.RelatedStatusDocumentIndex, *body.Lanes[2].RelatedStatus) + assert.Nil(body.Lanes[3].RelatedStatus) + assert.Nil(body.Lanes[4].RelatedStatus) + assert.Nil(body.Lanes[5].RelatedStatus) + assert.Nil(body.Lanes[6].RelatedStatus) + assert.Equal(operations.RelatedStatusSource, *body.Lanes[7].RelatedStatus) + assert.Equal(operations.RelatedStatusVisual, *body.Lanes[8].RelatedStatus) + assert.NotContains(w.Body.String(), `"supported_actions":null`) +} + +func TestOperationStatusProjectsRealStoreRunsAndDegradesOnlyOneLane(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + source, err := st.GetOrCreateSource("fixture", "private-source-identifier") + require.NoError(err) + started := time.Date(2026, 8, 29, 15, 0, 0, 0, time.UTC) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`INSERT INTO sync_runs ( + source_id, started_at, completed_at, status, messages_processed, + messages_added, messages_updated, errors_count, error_message + ) VALUES (?, ?, ?, 'completed', 2, 1, 0, 0, ?)`), source.ID, + operationAPITimestamp(st, started, false), + operationAPITimestamp(st, started.Add(time.Second), false), "private-ledger-error") + require.NoError(err) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`INSERT INTO sync_runs ( + source_id, started_at, status, messages_processed, messages_added, + messages_updated, errors_count + ) VALUES (?, ?, 'running', 0, 0, 0, 0)`), source.ID, + operationAPITimestamp(st, started.Add(2*time.Second), false)) + require.NoError(err) + + srv := NewServerWithOptions(ServerOptions{ + Config: config.NewDefaultConfig(), Store: st, OperationHistoryReader: st, Logger: testLogger(), + }) + w := doGet(srv, "/api/v1/operations/status") + require.Equalf(http.StatusOK, w.Code, "body: %s", w.Body.String()) + var body OperationStatusResponse + require.NoError(json.Unmarshal(w.Body.Bytes(), &body)) + sourceLane := body.Lanes[7] + assert.True(sourceLane.Configured) + assert.Equal(operations.HistoryAvailable, sourceLane.HistoryAvailability) + require.NotNil(sourceLane.Active) + require.NotNil(sourceLane.Latest) + require.NotNil(sourceLane.LatestSuccessful) + assert.Equal(operations.StateRunning, sourceLane.Active.State) + assert.Equal(sourceLane.Active.ID, sourceLane.Latest.ID) + assert.Equal(operations.StateSucceeded, sourceLane.LatestSuccessful.State) + assert.NotContains(w.Body.String(), "private-source-identifier") + assert.NotContains(w.Body.String(), "private-ledger-error") + + reader := &operationHistoryStub{statusErr: map[operations.Kind]error{ + operations.KindPersonSweep: errors.New("private person status failure"), + }} + degraded := NewServerWithOptions(ServerOptions{ + Config: config.NewDefaultConfig(), Store: st, OperationHistoryReader: reader, Logger: testLogger(), + }) + w = doGet(degraded, "/api/v1/operations/status") + require.Equal(http.StatusOK, w.Code) + require.NoError(json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(operations.HistoryAvailable, body.Lanes[0].HistoryAvailability) + assert.Equal(operations.HistoryUnavailable, body.Lanes[6].HistoryAvailability) + assert.Equal("person_sweep_history_unavailable", body.Lanes[6].UnavailableCode) + assert.Equal(operations.HistoryAvailable, body.Lanes[7].HistoryAvailability) + assert.NotContains(w.Body.String(), "private person status failure") +} + +func TestOperationStatusAdvertisesOnlySafeTypedActions(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + cfg, st, _ := savedCardDAVFixture(t) + controller, err := NewCardDAVController(cfg, st, slog.New(slog.DiscardHandler)) + require.NoError(err) + srv := NewServerWithOptions(ServerOptions{ + Config: cfg, Store: st, CardDAV: controller, OperationHistoryReader: st, Logger: testLogger(), + }) + srv.SetVisualOperations(func(context.Context) error { return nil }, func(context.Context) error { return nil }, + nil, func(context.Context, bool) (visual.Status, error) { + return visual.Status{ + Generation: store.VisualGeneration{ + ID: 987654321, State: store.VisualGenerationBuilding, + Fingerprint: "private-visual-fingerprint", Model: "private-visual-model", + }, + Formats: []visual.FormatCoverage{{MIMEType: "private/visual-format", Eligible: 123456789}}, + }, nil + }, nil) + + w := doGet(srv, "/api/v1/operations/status") + require.Equalf(http.StatusOK, w.Code, "body: %s", w.Body.String()) + var body OperationStatusResponse + require.NoError(json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal([]operations.ActionID{operations.ActionCardDAVSync}, body.Lanes[0].SupportedActions) + assert.Equal([]operations.ActionID{operations.ActionVisualBuild}, body.Lanes[8].SupportedActions) + for _, marker := range []string{ + "987654321", "private-visual-fingerprint", "private-visual-model", + "private/visual-format", "123456789", "formats", "generation", + } { + assert.NotContains(w.Body.String(), marker) + } + srv.SetVisualOperations(func(context.Context) error { return nil }, func(context.Context) error { return nil }, + nil, func(context.Context, bool) (visual.Status, error) { + return visual.Status{Generation: store.VisualGeneration{ + State: store.VisualGenerationBuilding, Consented: true, + }}, nil + }, nil) + w = doGet(srv, "/api/v1/operations/status") + require.NoError(json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal([]operations.ActionID{operations.ActionVisualResume}, body.Lanes[8].SupportedActions) + controller.mu.Lock() + controller.cfg.CardDAV.Username = "mismatched-user" + controller.mu.Unlock() + w = doGet(srv, "/api/v1/operations/status") + require.NoError(json.Unmarshal(w.Body.Bytes(), &body)) + assert.False(body.Lanes[0].Configured) + assert.Empty(body.Lanes[0].SupportedActions) + controller.mu.Lock() + controller.cfg.CardDAV.Username = "old-user" + controller.mu.Unlock() + + srv.SetVisualOperations(func(context.Context) error { return nil }, func(context.Context) error { return nil }, + nil, func(context.Context, bool) (visual.Status, error) { + return visual.Status{ + Generation: store.VisualGeneration{State: store.VisualGenerationActive}, + ReconciliationComplete: true, JournalLag: 1, + }, nil + }, nil) + w = doGet(srv, "/api/v1/operations/status") + require.NoError(json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal([]operations.ActionID{operations.ActionVisualResume}, body.Lanes[8].SupportedActions) + + srv.SetVisualOperations(func(context.Context) error { return nil }, func(context.Context) error { return nil }, + nil, func(context.Context, bool) (visual.Status, error) { + return visual.Status{ + Generation: store.VisualGeneration{State: store.VisualGenerationActive}, + ReconciliationComplete: true, Converged: 2, ConvergenceTotal: 2, + }, nil + }, nil) + w = doGet(srv, "/api/v1/operations/status") + require.NoError(json.Unmarshal(w.Body.Bytes(), &body)) + assert.Empty(body.Lanes[8].SupportedActions) + + srv.SetVisualOperations(func(context.Context) error { return nil }, func(context.Context) error { return nil }, + nil, func(context.Context, bool) (visual.Status, error) { + return visual.Status{}, errors.New("private visual provider failure") + }, nil) + w = doGet(srv, "/api/v1/operations/status") + require.NoError(json.Unmarshal(w.Body.Bytes(), &body)) + assert.True(body.Lanes[8].Configured) + assert.Empty(body.Lanes[8].SupportedActions) + assert.NotContains(w.Body.String(), "private visual provider failure") + + for _, lane := range body.Lanes[1:8] { + assert.Empty(lane.SupportedActions, "lane %s", lane.Kind) + } + for _, forbidden := range []string{ + `"method"`, `"path"`, `"url"`, `"args"`, "/cli/run", "cancel", + "source_sync_action", "people", "document_build", "visual_retry", + "scheduler-account", "scheduler-job", "private-holder-label", + } { + assert.NotContains(strings.ToLower(w.Body.String()), forbidden) + } +} + +func TestOperationStatusBypassesGateAndHandlesNilDependencies(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + gate := NewSerialOperationGate() + release, acquired := gate.BeginLabeledWorkContext(t.Context(), "private-holder-label") + require.True(acquired) + defer release() + srv := NewServerWithOptions(ServerOptions{ + Config: config.NewDefaultConfig(), Store: &mockStore{}, OperationGate: gate, Logger: testLogger(), + }) + + w := doGet(srv, "/api/v1/operations/status") + require.Equalf(http.StatusOK, w.Code, "body: %s", w.Body.String()) + var body OperationStatusResponse + require.NoError(json.Unmarshal(w.Body.Bytes(), &body)) + require.Len(body.Lanes, 9) + for _, index := range []int{0, 6, 7} { + assert.Equal(operations.HistoryUnavailable, body.Lanes[index].HistoryAvailability) + assert.Empty(body.Lanes[index].SupportedActions) + } + assert.NotContains(w.Body.String(), "private-holder-label") + assert.False(body.Lanes[5].Configured) + assert.Nil(body.Lanes[5].Active) + assert.Nil(body.Lanes[5].Latest) + assert.Nil(body.Lanes[5].LatestSuccessful) +} + +func TestOperationRunsPaginatesAndDeclaresUnavailableKinds(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + run := operationRunFixture(t) + reader := &operationHistoryStub{runs: []operations.Run{run, run}} + srv := newOperationTestServer(reader, &operationArchiveStore{mockStore: &mockStore{}, uid: operationTestArchiveUID}) + + w := doGet(srv, "/api/v1/operations/runs?limit=1") + require.Equalf(http.StatusOK, w.Code, "body: %s", w.Body.String()) + var body OperationRunsResponse + require.NoError(json.Unmarshal(w.Body.Bytes(), &body)) + require.Len(body.Runs, 1) + assert.NotEmpty(body.NextCursor) + assert.Len(body.UnavailableKinds, 6) + assert.Equal([]OperationUnavailableKind{ + {Kind: operations.KindDocumentEmbedding, Lane: operations.LaneDocuments, UnavailableCode: "document_embedding_history_unavailable"}, + {Kind: operations.KindDocumentExtraction, Lane: operations.LaneDocuments, UnavailableCode: "document_extraction_history_unavailable"}, + {Kind: operations.KindMessageEmbedding, Lane: operations.LaneMessages, UnavailableCode: "message_embedding_history_unavailable"}, + {Kind: operations.KindPersonEmbedding, Lane: operations.LanePersonFacts, UnavailableCode: "person_embedding_history_unavailable"}, + {Kind: operations.KindPersonEnrichment, Lane: operations.LanePersonFacts, UnavailableCode: "person_enrichment_history_unavailable"}, + {Kind: operations.KindVisualEmbedding, Lane: operations.LaneVisualAttachments, UnavailableCode: "visual_embedding_history_unavailable"}, + }, body.UnavailableKinds) + assert.Equal(1, reader.queries[0].Limit) + assert.Equal(operations.KindSourceSync, body.Runs[0].Kind) + assert.NotNil(body.Runs[0].Counters) + assert.NotContains(w.Body.String(), "archive") + + w = doGet(srv, "/api/v1/operations/runs") + require.Equal(http.StatusOK, w.Code) + assert.Equal(operationRunsDefaultLimit, reader.queries[1].Limit) +} + +func TestOperationRunsRejectsInvalidQueriesAndUnavailableKinds(t *testing.T) { + srv := newOperationTestServer(&operationHistoryStub{}, &operationArchiveStore{mockStore: &mockStore{}, uid: operationTestArchiveUID}) + tests := []struct { + name string + target string + status int + code string + }{ + {name: "zero limit", target: "/api/v1/operations/runs?limit=0", status: 400, code: "invalid_limit"}, + {name: "over max", target: "/api/v1/operations/runs?limit=101", status: 400, code: "invalid_limit"}, + {name: "duplicate", target: "/api/v1/operations/runs?kind=source_sync&kind=source_sync", status: 400, code: "invalid_kind"}, + {name: "unknown parameter", target: "/api/v1/operations/runs?provider=private", status: 400, code: "invalid_query"}, + {name: "unavailable kind", target: "/api/v1/operations/runs?kind=message_embedding", status: 503, code: "operation_history_unavailable"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + w := doGet(srv, test.target) + assert.Equal(t, test.status, w.Code) + assert.Equal(t, test.code, decodeErrorEnvelope(t, w).Error) + }) + } +} + +func TestOperationRunsRejectsBoundCursorAndFailsAtomically(t *testing.T) { + assert := assert.New(t) + run := operationRunFixture(t) + position := operations.Position{StartedAt: run.StartedAt, ID: run.ID} + cursor, err := encodeOperationCursor(position, operationHistoryFilter{}, operationTestArchiveUID) + require.NoError(t, err) + + reader := &operationHistoryStub{runs: []operations.Run{run}, listErr: errors.New("synthetic read failed")} + srv := newOperationTestServer(reader, &operationArchiveStore{mockStore: &mockStore{}, uid: operationTestArchiveUID}) + w := doGet(srv, "/api/v1/operations/runs?cursor="+cursor+"&kind=source_sync") + assert.Equal(http.StatusBadRequest, w.Code) + assert.Equal("invalid_cursor", decodeErrorEnvelope(t, w).Error) + + w = doGet(srv, "/api/v1/operations/runs") + assert.Equal(http.StatusInternalServerError, w.Code) + assert.Equal("operation_history_failed", decodeErrorEnvelope(t, w).Error) + assert.NotContains(w.Body.String(), "runs") + assert.NotContains(w.Body.String(), "next_cursor") +} + +func TestOperationRunDetailUsesOpaqueIdentityAndExactErrors(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + run := operationRunFixture(t) + reader := &operationHistoryStub{run: run} + srv := newOperationTestServer(reader, &operationArchiveStore{mockStore: &mockStore{}, uid: operationTestArchiveUID}) + ref, err := encodeOperationRunReference(run.ID, operationTestArchiveUID) + require.NoError(err) + w := doGet(srv, "/api/v1/operations/runs/"+ref) + require.Equalf(http.StatusOK, w.Code, "body: %s", w.Body.String()) + var detail OperationRunDetail + require.NoError(json.Unmarshal(w.Body.Bytes(), &detail)) + assert.Equal(ref, detail.ID) + assert.Equal(operations.KindSourceSync, detail.Kind) + + w = doGet(srv, "/api/v1/operations/runs/not-a-reference") + assert.Equal(http.StatusBadRequest, w.Code) + assert.Equal("invalid_operation_run_id", decodeErrorEnvelope(t, w).Error) + + reader.getErr = store.ErrOperationRunNotFound + w = doGet(srv, "/api/v1/operations/runs/"+ref) + assert.Equal(http.StatusNotFound, w.Code) + assert.Equal("operation_run_not_found", decodeErrorEnvelope(t, w).Error) + + reader.getErr = errors.New("private ordinary reader failure") + w = doGet(srv, "/api/v1/operations/runs/"+ref) + assert.Equal(http.StatusInternalServerError, w.Code) + assert.Equal("operation_history_failed", decodeErrorEnvelope(t, w).Error) + assert.NotContains(w.Body.String(), "private ordinary reader failure") + + reader.getErr = nil + crossArchive := newOperationTestServer(reader, &operationArchiveStore{ + mockStore: &mockStore{}, uid: "abcdef1234567890abcdef1234567890", + }) + w = doGet(crossArchive, "/api/v1/operations/runs/"+ref) + assert.Equal(http.StatusBadRequest, w.Code) + assert.Equal("invalid_operation_run_id", decodeErrorEnvelope(t, w).Error) + + badPair := "1." + base64.RawURLEncoding.EncodeToString([]byte(`{"kind":"person_sweep","id_type":"int64","int_id":17,"archive_uid":"`+operationTestArchiveUID+`"}`)) + w = doGet(srv, "/api/v1/operations/runs/"+badPair) + assert.Equal(http.StatusBadRequest, w.Code) + assert.Equal("invalid_operation_run_id", decodeErrorEnvelope(t, w).Error) +} + +func TestOperationHistoryAPIBypassesHeldOperationGate(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + run := operationRunFixture(t) + reader := &operationHistoryStub{runs: []operations.Run{run}, run: run} + gate := NewSerialOperationGate() + release, acquired := gate.BeginLabeledWorkContext(t.Context(), "private-holder-label") + require.True(acquired) + defer release() + var logs bytes.Buffer + srv := NewServerWithOptions(ServerOptions{ + Config: &config.Config{}, + Store: &operationArchiveStore{mockStore: &mockStore{}, uid: operationTestArchiveUID}, + OperationHistoryReader: reader, + OperationGate: gate, + Logger: slog.New(slog.NewTextHandler(&logs, nil)), + }) + + list := doGet(srv, "/api/v1/operations/runs") + require.Equalf(http.StatusOK, list.Code, "body: %s", list.Body.String()) + ref, err := encodeOperationRunReference(run.ID, operationTestArchiveUID) + require.NoError(err) + detail := doGet(srv, "/api/v1/operations/runs/"+ref) + require.Equalf(http.StatusOK, detail.Code, "body: %s", detail.Body.String()) + assert.NotContains(list.Body.String()+detail.Body.String(), "private-holder-label") + assert.NotContains(logs.String(), "private-holder-label") +} + +func TestOperationHistoryAPIDependencyFailures(t *testing.T) { + tests := []struct { + name string + reader operations.HistoryReader + archive ArchiveIdentifier + target string + status int + code string + }{ + {name: "nil reader", archive: &operationArchiveStore{mockStore: &mockStore{}, uid: operationTestArchiveUID}, target: "/api/v1/operations/runs", status: 503, code: "operation_history_unavailable"}, + {name: "nil reader detail", archive: &operationArchiveStore{mockStore: &mockStore{}, uid: operationTestArchiveUID}, target: "/api/v1/operations/runs/opaque", status: 503, code: "operation_history_unavailable"}, + {name: "nil archive", reader: &operationHistoryStub{}, target: "/api/v1/operations/runs", status: 503, code: "operation_history_unavailable"}, + {name: "nil archive detail", reader: &operationHistoryStub{}, target: "/api/v1/operations/runs/opaque", status: 503, code: "operation_history_unavailable"}, + {name: "archive failure", reader: &operationHistoryStub{}, archive: &operationArchiveStore{mockStore: &mockStore{}, err: errors.New("private archive failure")}, target: "/api/v1/operations/runs", status: 503, code: "operation_history_unavailable"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + srv := newOperationTestServer(test.reader, test.archive) + w := doGet(srv, test.target) + assert.Equal(t, test.status, w.Code) + env := decodeErrorEnvelope(t, w) + assert.Equal(t, test.code, env.Error) + assert.NotContains(t, strings.ToLower(w.Body.String()), "private") + }) + } +} + +func TestOperationHistoryAPIRealStoreSameSecondWalkAndPrivacy(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + started := time.Date(2026, 8, 29, 13, 0, 0, 0, time.UTC) + source, err := st.GetOrCreateSource("gmail", "private-source-identifier@example.invalid") + require.NoError(err) + var sourceRunID int64 + err = st.DB().QueryRowContext(t.Context(), st.Rebind(`INSERT INTO sync_runs ( + source_id, started_at, completed_at, status, messages_processed, messages_added, + messages_updated, errors_count, error_message, cursor_before, cursor_after + ) VALUES (?, ?, ?, 'completed', 7, 2, 1, 0, ?, ?, ?) RETURNING id`), source.ID, + operationAPITimestamp(st, started, false), operationAPITimestamp(st, started.Add(time.Second), false), + "private-source-error", "private-source-before", "private-source-after").Scan(&sourceRunID) + require.NoError(err) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`INSERT INTO sync_run_items ( + sync_run_id, source_message_id, phase, status, error_kind, error_message + ) VALUES (?, ?, 'fetch', 'error', 'private-source-item-kind', ?)`), + sourceRunID, "private-source-message-id", "private-source-item-error") + require.NoError(err) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`INSERT INTO person_sweep_runs ( + id, kind, mode, status, program_fingerprint, catalog_fingerprint, + provider_fingerprint, attempt_count, success_count, failure_count, + projected_write_count, started_at, completed_at + ) VALUES ('person-run', 'manual', 'incremental', 'succeeded', ?, ?, ?, 2, 2, 0, 1, ?, ?)`), + "private-person-program", "private-person-catalog", "private-person-provider", + operationAPITimestamp(st, started, true), operationAPITimestamp(st, started.Add(time.Second), true)) + require.NoError(err) + var personID int64 + err = st.DB().QueryRowContext(t.Context(), st.Rebind(`INSERT INTO persons ( + vcard_uid, display_name + ) VALUES (?, ?) RETURNING id`), "private-person-uid", "private-person-display").Scan(&personID) + require.NoError(err) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`INSERT INTO person_sweep_attempts ( + id, run_id, person_id, lease_fence, mode, status, failure_class, + cursor_envelope_json, envelope_hash, program_fingerprint, catalog_fingerprint, + provider_fingerprint, generation_key, provider_request_id, input_tokens, + output_tokens, estimated_cost_micro_usd, started_at, completed_at + ) VALUES (?, 'person-run', ?, 1, 'incremental', 'succeeded', '', ?, ?, ?, ?, ?, ?, ?, 987654321, 876543210, 765432109, ?, ?)`), + "private-person-attempt-id", personID, `{"private":"person-cursor-envelope"}`, + "private-person-envelope-hash", "private-person-attempt-program", + "private-person-attempt-catalog", "private-person-attempt-provider", + "private-person-model", "private-person-request-id", + operationAPITimestamp(st, started, true), operationAPITimestamp(st, started.Add(time.Second), true)) + require.NoError(err) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`INSERT INTO carddav_sync_runs ( + trigger, state, started_at, finished_at, books, created, updated, removed, error_code, error_message + ) VALUES ('manual', 'failed', ?, ?, 1, 2, 3, 4, 'sync_failed', ?)`), + operationAPITimestamp(st, started, false), operationAPITimestamp(st, started.Add(time.Second), false), "private-carddav-error") + require.NoError(err) + + srv := NewServerWithOptions(ServerOptions{Config: &config.Config{}, Store: st, OperationHistoryReader: st, Logger: testLogger()}) + var summaries []OperationRunSummary + cursor := "" + firstCursor := "" + for { + target := "/api/v1/operations/runs?limit=1" + if cursor != "" { + target += "&cursor=" + cursor + } + w := doGet(srv, target) + require.Equalf(http.StatusOK, w.Code, "body: %s", w.Body.String()) + for _, marker := range []string{ + "private-source-identifier", "private-source-error", "private-source-before", + "private-source-after", "private-source-message-id", "private-source-item-kind", + "private-source-item-error", "private-person-program", "private-person-catalog", + "private-person-provider", "private-person-uid", "private-person-display", + "private-person-attempt-id", "person-cursor-envelope", "private-person-envelope-hash", + "private-person-attempt-program", "private-person-attempt-catalog", + "private-person-attempt-provider", "private-person-model", "private-person-request-id", + "987654321", "876543210", "765432109", "private-carddav-error", + } { + assert.NotContains(w.Body.String(), marker) + } + var page OperationRunsResponse + require.NoError(json.Unmarshal(w.Body.Bytes(), &page)) + summaries = append(summaries, page.Runs...) + cursor = page.NextCursor + if firstCursor == "" { + firstCursor = cursor + } + if cursor == "" { + break + } + } + require.Len(summaries, 3) + assert.Equal([]operations.Kind{ + operations.KindCardDAVSync, operations.KindPersonSweep, operations.KindSourceSync, + }, []operations.Kind{summaries[0].Kind, summaries[1].Kind, summaries[2].Kind}) + for _, summary := range summaries { + w := doGet(srv, "/api/v1/operations/runs/"+summary.ID) + require.Equal(http.StatusOK, w.Code) + var detail OperationRunDetail + require.NoError(json.Unmarshal(w.Body.Bytes(), &detail)) + assert.Equal(summary, detail.OperationRunSummary) + } + + for _, test := range []struct { + query string + kind operations.Kind + unavailable []OperationUnavailableKind + }{ + {query: "kind=source_sync&state=succeeded", kind: operations.KindSourceSync, unavailable: []OperationUnavailableKind{}}, + {query: "lane=contacts&state=failed", kind: operations.KindCardDAVSync, unavailable: []OperationUnavailableKind{}}, + { + query: "lane=messages", kind: operations.KindSourceSync, + unavailable: []OperationUnavailableKind{{ + Kind: operations.KindMessageEmbedding, Lane: operations.LaneMessages, + UnavailableCode: "message_embedding_history_unavailable", + }}, + }, + { + query: "lane=person_facts", kind: operations.KindPersonSweep, + unavailable: []OperationUnavailableKind{ + {Kind: operations.KindPersonEmbedding, Lane: operations.LanePersonFacts, UnavailableCode: "person_embedding_history_unavailable"}, + {Kind: operations.KindPersonEnrichment, Lane: operations.LanePersonFacts, UnavailableCode: "person_enrichment_history_unavailable"}, + }, + }, + } { + w := doGet(srv, "/api/v1/operations/runs?"+test.query) + require.Equalf(http.StatusOK, w.Code, "body: %s", w.Body.String()) + var page OperationRunsResponse + require.NoError(json.Unmarshal(w.Body.Bytes(), &page)) + require.Len(page.Runs, 1) + assert.Equal(test.kind, page.Runs[0].Kind) + assert.Equal(test.unavailable, page.UnavailableKinds) + } + + for _, lane := range []operations.Lane{operations.LaneDocuments, operations.LaneVisualAttachments} { + w := doGet(srv, "/api/v1/operations/runs?lane="+string(lane)) + assert.Equal(http.StatusServiceUnavailable, w.Code) + assert.Equal("operation_history_unavailable", decodeErrorEnvelope(t, w).Error) + } + require.NotEmpty(firstCursor) + crossArchive := NewServerWithOptions(ServerOptions{ + Config: &config.Config{}, + Store: &operationArchiveRealStore{Store: st, uid: "abcdef1234567890abcdef1234567890"}, + OperationHistoryReader: st, + Logger: testLogger(), + }) + w := doGet(crossArchive, "/api/v1/operations/runs?limit=1&cursor="+firstCursor) + assert.Equal(http.StatusBadRequest, w.Code) + assert.Equal("invalid_cursor", decodeErrorEnvelope(t, w).Error) +} + +func operationAPITimestamp(st *store.Store, value time.Time, milliseconds bool) any { + if st.IsPostgreSQL() { + return value.UTC() + } + if milliseconds { + return value.UTC().Format("2006-01-02 15:04:05.000") + } + return value.UTC().Format("2006-01-02 15:04:05") +} diff --git a/internal/api/organizations.go b/internal/api/organizations.go index dfc4d9de8..f10a06f07 100644 --- a/internal/api/organizations.go +++ b/internal/api/organizations.go @@ -77,19 +77,20 @@ type MergeOrganizationBody struct { // profile PUT could never round-trip a row carrying that metadata and would // supersede and reinsert it without the metadata on every unrelated update. type OrganizationEnvelopeBody struct { - Ordinal *int `json:"ordinal,omitempty" minimum:"0"` - Pref *int `json:"pref,omitempty" nullable:"true"` - TypeLabel *string `json:"type_label,omitempty" nullable:"true"` - TypeTokens []string `json:"type_tokens,omitempty"` - VCardProperty *string `json:"vcard_property,omitempty" nullable:"true"` - VCardGroup *string `json:"vcard_group,omitempty" nullable:"true"` - VCardPropID *string `json:"vcard_prop_id,omitempty" nullable:"true"` - VCardPID []string `json:"vcard_pid,omitempty"` - VCardAltID *string `json:"vcard_altid,omitempty" nullable:"true"` - Source string `json:"source" enum:"user,carddav_import,vcard_import,archive_observation,extraction,enrichment,system"` - SourceRef *string `json:"source_ref,omitempty" nullable:"true"` - Confidence *float64 `json:"confidence,omitempty" nullable:"true"` - ActiveFrom *time.Time `json:"active_from,omitempty" nullable:"true"` + Ordinal *int `json:"ordinal,omitempty" minimum:"0"` + Pref *int `json:"pref,omitempty" nullable:"true"` + TypeLabel *string `json:"type_label,omitempty" nullable:"true"` + TypeTokens []string `json:"type_tokens,omitempty"` + VCardProperty *string `json:"vcard_property,omitempty" nullable:"true"` + VCardGroup *string `json:"vcard_group,omitempty" nullable:"true"` + VCardPropID *string `json:"vcard_prop_id,omitempty" nullable:"true"` + VCardPID []string `json:"vcard_pid,omitempty"` + VCardAltID *string `json:"vcard_altid,omitempty" nullable:"true"` + Source string `json:"source" enum:"user,carddav_import,vcard_import,archive_observation,extraction,enrichment,system"` + SourceRef *string `json:"source_ref,omitempty" nullable:"true"` + SourceResourceUID *string `json:"source_resource_uid,omitempty" nullable:"true"` + Confidence *float64 `json:"confidence,omitempty" nullable:"true"` + ActiveFrom *time.Time `json:"active_from,omitempty" nullable:"true"` } func (b OrganizationEnvelopeBody) envelope() store.ValueEnvelopeInput { @@ -101,7 +102,7 @@ func (b OrganizationEnvelopeBody) envelope() store.ValueEnvelopeInput { Ordinal: b.Ordinal, Pref: b.Pref, TypeLabel: b.TypeLabel, TypeTokens: b.TypeTokens, VCard: store.VCardIdentity{Property: property, Group: b.VCardGroup, PropID: b.VCardPropID, PID: b.VCardPID, AltID: b.VCardAltID}, - Source: store.Provenance(b.Source), SourceRef: b.SourceRef, + Source: store.Provenance(b.Source), SourceRef: b.SourceRef, SourceResourceUID: b.SourceResourceUID, Confidence: b.Confidence, ActiveFrom: b.ActiveFrom, } } diff --git a/internal/api/organizations_test.go b/internal/api/organizations_test.go index 31d755b7b..d93480a0a 100644 --- a/internal/api/organizations_test.go +++ b/internal/api/organizations_test.go @@ -587,6 +587,57 @@ func TestOrganizationHTTPProfilePutRoundTripsEnvelopeMetadata(t *testing.T) { assert.InDelta(0.75, *kept.Confidence, 1e-9) } +func TestOrganizationHTTPProfilePutRetainsSourceResourceUID(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, st := newOrganizationTestServerWithStore(t) + createdResponse := organizationRequest(t, srv, http.MethodPost, organizationsPath, + []byte(`{"name":"Example Org","kind":"company"}`), "") + require.Equal(http.StatusCreated, createdResponse.Code) + var created store.Organization + require.NoError(json.Unmarshal(createdResponse.Body.Bytes(), &created)) + + sourceRef := "address-book" + resourceUID := "card-42" + seeded, err := st.ReplaceOrganizationProfileContext(context.Background(), created.ID, created.Revision, + store.OrganizationProfileInput{Names: []store.OrganizationNameInput{{ + Name: "Imported alias", NameKind: store.OrganizationNameKindAlias, + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceVCardImport, SourceRef: &sourceRef, SourceResourceUID: &resourceUID, + }, + }}}) + require.NoError(err) + require.Len(seeded.Names, 1) + + getResponse := organizationRequest(t, srv, http.MethodGet, + fmt.Sprintf("%s/%d", organizationsPath, created.ID), nil, "") + require.Equal(http.StatusOK, getResponse.Code, getResponse.Body.String()) + var fetched store.OrganizationProfile + require.NoError(json.Unmarshal(getResponse.Body.Bytes(), &fetched)) + require.Len(fetched.Names, 1) + fetchedName := fetched.Names[0] + require.NotNil(fetchedName.Envelope.SourceResourceUID) + + putBody, err := json.Marshal(map[string]any{"names": []map[string]any{{ + "name": fetchedName.Name, "name_kind": fetchedName.NameKind, + "ordinal": fetchedName.Envelope.Ordinal, "source": fetchedName.Envelope.Source, + "source_ref": fetchedName.Envelope.SourceRef, + "source_resource_uid": fetchedName.Envelope.SourceResourceUID, + }}}) + require.NoError(err) + putResponse := organizationRequest(t, srv, http.MethodPut, + fmt.Sprintf("%s/%d/profile", organizationsPath, created.ID), putBody, + getResponse.Header().Get("ETag")) + require.Equal(http.StatusOK, putResponse.Code, putResponse.Body.String()) + var replaced store.OrganizationProfile + require.NoError(json.Unmarshal(putResponse.Body.Bytes(), &replaced)) + require.Len(replaced.Names, 1) + + assert.Equal(fetchedName.Envelope.ID, replaced.Names[0].Envelope.ID) + assert.Equal(fetchedName.Envelope.SourceRef, replaced.Names[0].Envelope.SourceRef) + assert.Equal(fetchedName.Envelope.SourceResourceUID, replaced.Names[0].Envelope.SourceResourceUID) +} + func TestOrganizationHTTPProfileMediaContentRoundTrip(t *testing.T) { assert := assert.New(t) require := require.New(t) diff --git a/internal/api/person_attributes_test.go b/internal/api/person_attributes_test.go index 927cab676..14c0547e3 100644 --- a/internal/api/person_attributes_test.go +++ b/internal/api/person_attributes_test.go @@ -74,6 +74,50 @@ func TestPersonAttributesHTTPListsDefinitionsSetsHistoryAndClears(t *testing.T) require.Equal(http.StatusOK, cleared.Code, cleared.Body.String()) } +// This catches coupling the writable preferred-channel attribute to the +// Directory's observed last-contact channel and its filter membership. +func TestPersonAttributesHTTPPreferredChannelDoesNotChangeDirectoryObservedChannel(t *testing.T) { + require := require.New(t) + srv, st := newIdentityLinkTestServer(t) + person := createDirectoryHTTPPerson( + t, st, "Channel Fixture", "channel@example.test", "friend", "Example Org", true, + ) + path := personAttributesPath(person.ID) + "/" + store.AttributeSlugPrimaryChannel + + assertObservedEmail := func() { + email := personRequest(t, srv, http.MethodGet, + peoplePath+"/directory?primary_channel=email", nil, "") + require.Equal(http.StatusOK, email.Code, email.Body.String()) + var page DirectoryPeopleResponse + require.NoError(json.Unmarshal(email.Body.Bytes(), &page)) + require.Len(page.People, 1) + assert.Equal(t, person.ID, page.People[0].ID) + assert.Equal(t, "email", page.People[0].PrimaryChannel) + + chat := personRequest(t, srv, http.MethodGet, + peoplePath+"/directory?primary_channel=chat", nil, "") + require.Equal(http.StatusOK, chat.Code, chat.Body.String()) + var chatPage DirectoryPeopleResponse + require.NoError(json.Unmarshal(chat.Body.Bytes(), &chatPage)) + assert.Empty(t, chatPage.People) + } + + assertObservedEmail() + set := attributeRequest(t, srv, http.MethodPut, path, + []byte(`{"value":{"type":"text","text":"chat"},"source":"user"}`), "") + require.Equal(http.StatusOK, set.Code, set.Body.String()) + var write store.PersonAttributeWrite + require.NoError(json.Unmarshal(set.Body.Bytes(), &write)) + require.NotNil(write.Value) + require.NotNil(write.Value.Value.Text) + assert.Equal(t, "chat", *write.Value.Value.Text) + assertObservedEmail() + + cleared := attributeRequest(t, srv, http.MethodDelete, path, nil, "") + require.Equal(http.StatusOK, cleared.Code, cleared.Body.String()) + assertObservedEmail() +} + func TestHTTPAppendPersonNoteUsesAtomicStorePath(t *testing.T) { assert := assert.New(t) require := require.New(t) diff --git a/internal/api/person_network.go b/internal/api/person_network.go new file mode 100644 index 000000000..e87b6e2bd --- /dev/null +++ b/internal/api/person_network.go @@ -0,0 +1,107 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/danielgtaylor/huma/v2" + "go.kenn.io/msgvault/internal/store" +) + +const ( + defaultPersonNetworkDepth = 1 + minPersonNetworkDepth = 1 + maxPersonNetworkDepth = 3 +) + +// PersonNetworkStore is the narrow curated-network capability consumed by the +// HTTP route. +type PersonNetworkStore interface { + GetPersonNetworkContext(ctx context.Context, personID int64, opts store.PersonNetworkOptions) (store.PersonNetwork, error) +} + +func (s *Server) registerPersonNetworkRoutes(api huma.API) { + get := rawAPIV1Operation("getPersonNetwork", http.MethodGet, "/people/{id}/network", + "Get a bounded curated person network") + get.Description = "Returns declared person relationships and employments only; archive-derived associations are excluded." + addPersonIDParameter(&get) + depth := queryIntegerParam("depth", "Breadth-first depth (default 1, minimum 1, maximum 3)") + minimumDepth := float64(minPersonNetworkDepth) + maximumDepth := float64(maxPersonNetworkDepth) + depth.Schema.Default = defaultPersonNetworkDepth + depth.Schema.Minimum = &minimumDepth + depth.Schema.Maximum = &maximumDepth + get.Parameters = append(get.Parameters, + depth, + queryBooleanParam("include_ended", "Include ended relationships and employment records"), + ) + get.Responses = jsonResponsesFor[store.PersonNetwork](api) + addErrorResponses(api, get.Responses, http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable) + registerRawHumaRoute(api, get, s.handleGetPersonNetwork) +} + +func (s *Server) handleGetPersonNetwork(w http.ResponseWriter, r *http.Request) { + networks, ok := s.personNetworkStore(w) + if !ok { + return + } + personID, ok := personProfileID(w, r) + if !ok { + return + } + depth, ok := personNetworkDepth(w, r) + if !ok { + return + } + includeEnded, ok := relationshipBoolQuery(w, r, "include_ended") + if !ok { + return + } + graph, err := networks.GetPersonNetworkContext(r.Context(), personID, + store.PersonNetworkOptions{Depth: depth, IncludeEnded: includeEnded}) + if err != nil { + s.writePersonNetworkError(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, graph) +} + +func (s *Server) personNetworkStore(w http.ResponseWriter) (PersonNetworkStore, bool) { + networks, ok := s.store.(PersonNetworkStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "person_network_unavailable", "Person network is unavailable") + } + return networks, ok +} + +func personNetworkDepth(w http.ResponseWriter, r *http.Request) (int, bool) { + raw := strings.TrimSpace(r.URL.Query().Get("depth")) + if raw == "" { + return defaultPersonNetworkDepth, true + } + depth, err := strconv.Atoi(raw) + if err != nil || depth < 1 || depth > 3 { + writeError(w, http.StatusBadRequest, "invalid_depth", "depth must be between 1 and 3") + return 0, false + } + return depth, true +} + +func (s *Server) writePersonNetworkError(w http.ResponseWriter, err error) { + if s.writeIfContextError(w, err) { + return + } + switch { + case errors.Is(err, store.ErrPersonNetworkInvalid): + writeError(w, http.StatusBadRequest, "invalid_depth", err.Error()) + case errors.Is(err, store.ErrPersonNotFound): + writeError(w, http.StatusNotFound, "person_profile_not_found", "Person profile not found") + default: + s.logger.Error("person network operation failed", "error", err) + writeError(w, http.StatusInternalServerError, "person_network_failed", "Person network operation failed") + } +} diff --git a/internal/api/person_network_test.go b/internal/api/person_network_test.go new file mode 100644 index 000000000..da6b1d139 --- /dev/null +++ b/internal/api/person_network_test.go @@ -0,0 +1,85 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" +) + +func TestPersonNetworkRouteReturnsProjection(t *testing.T) { + require := require.New(t) + srv, s := newPersonNetworkTestServer(t) + root := mustAPIPerson(t, s, "root@example.test", "Root") + peer := mustAPIPerson(t, s, "peer@example.test", "Peer") + _, err := s.AddPersonRelationshipContext(t.Context(), store.PersonRelationshipInput{ + SourcePersonID: root.ID, + TargetPersonID: peer.ID, + TypeSlug: "friend", + Source: store.ProvenanceUser, + Actor: "test", + }) + require.NoError(err) + + got := doRequest(t, srv.Router(), http.MethodGet, + fmt.Sprintf("/api/v1/people/%d/network?depth=1", root.ID), nil, nil) + require.Equal(http.StatusOK, got.Code) + var body store.PersonNetwork + require.NoError(json.NewDecoder(got.Body).Decode(&body)) + assert.Equal(t, root.ID, body.RootPersonID) +} + +func newPersonNetworkTestServer(t *testing.T) (*Server, *store.Store) { + t.Helper() + return newOrganizationTestServerWithStore(t) +} + +func TestPersonNetworkRouteRejectsInvalidDepth(t *testing.T) { + srv, s := newPersonNetworkTestServer(t) + root := mustAPIPerson(t, s, "root@example.test", "Root") + + got := doRequest(t, srv.Router(), http.MethodGet, + fmt.Sprintf("/api/v1/people/%d/network?depth=4", root.ID), nil, nil) + assert.Equal(t, http.StatusBadRequest, got.Code) + assert.Contains(t, got.Body.String(), "invalid_depth") +} + +func TestPersonNetworkRouteReturnsNotFoundForMissingRoot(t *testing.T) { + srv, _ := newPersonNetworkTestServer(t) + + got := doRequest(t, srv.Router(), http.MethodGet, "/api/v1/people/999/network?depth=1", nil, nil) + assert.Equal(t, http.StatusNotFound, got.Code) + assert.Contains(t, got.Body.String(), "person_profile_not_found") +} + +func TestPersonNetworkOpenAPIDocumentsDepthBounds(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + for _, document := range []*huma.OpenAPI{OpenAPIDocument(), openAPIClientDocument()} { + operation := document.Paths["/api/v1/people/{id}/network"].Get + require.NotNil(operation) + depth := personNetworkDepthParameter(t, operation.Parameters) + require.NotNil(depth.Schema) + require.NotNil(depth.Schema.Minimum) + require.NotNil(depth.Schema.Maximum) + assert.Equal(1, depth.Schema.Default) + assert.InDelta(1.0, *depth.Schema.Minimum, 0) + assert.InDelta(3.0, *depth.Schema.Maximum, 0) + } +} + +func personNetworkDepthParameter(t *testing.T, parameters []*huma.Param) *huma.Param { + t.Helper() + for _, parameter := range parameters { + if parameter.Name == "depth" { + return parameter + } + } + require.Fail(t, "depth parameter is not documented") + return nil +} diff --git a/internal/api/person_profile_values_test.go b/internal/api/person_profile_values_test.go index bd0a6e1cf..49bafcf50 100644 --- a/internal/api/person_profile_values_test.go +++ b/internal/api/person_profile_values_test.go @@ -35,6 +35,36 @@ func TestGetPersonProfileReturnsTypedValuesAndETag(t *testing.T) { assert.Equal("Alice@Example.com", profile.ContactPoints[0].OriginalValue) } +func TestPatchPersonProfileAcceptsServiceLessEmailAndRejectsUnknownService(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server, st := newProfileTestServer(t) + personID := seedAPIPerson(t, st) + + read := doRequest(t, server, http.MethodGet, personProfilePath(personID), nil, nil) + require.Equal(http.StatusOK, read.Code, read.Body.String()) + plainEmail := doRequest(t, server, http.MethodPatch, personProfilePath(personID), + []byte(`{"contact_points":{"add":[{"address_kind":"email","original_value":"plain@example.test","envelope":{"source":"user"}}]}}`), + map[string]string{"If-Match": read.Header().Get("ETag")}) + require.Equal(http.StatusOK, plainEmail.Code, plainEmail.Body.String()) + var profile store.PersonProfile + require.NoError(json.Unmarshal(plainEmail.Body.Bytes(), &profile), plainEmail.Body.String()) + var added *store.PersonContactPoint + for index := range profile.ContactPoints { + if profile.ContactPoints[index].OriginalValue == "plain@example.test" { + added = &profile.ContactPoints[index] + break + } + } + require.NotNil(added) + assert.Nil(added.ServiceSlug) + + unknownService := doRequest(t, server, http.MethodPatch, personProfilePath(personID), + []byte(`{"contact_points":{"add":[{"address_kind":"email","service_slug":"not-a-real-service","original_value":"scoped@example.test","envelope":{"source":"user"}}]}}`), + map[string]string{"If-Match": plainEmail.Header().Get("ETag")}) + assert.Equal(http.StatusBadRequest, unknownService.Code, unknownService.Body.String()) +} + func TestPatchPersonProfileRoundTripsPartialDatesAndAddresses(t *testing.T) { require := require.New(t) assert := assert.New(t) diff --git a/internal/api/person_profiles.go b/internal/api/person_profiles.go index 762727746..c8587637a 100644 --- a/internal/api/person_profiles.go +++ b/internal/api/person_profiles.go @@ -8,8 +8,10 @@ import ( "fmt" "io" "net/http" + "net/url" "strconv" "strings" + "time" "github.com/danielgtaylor/huma/v2" "go.kenn.io/msgvault/internal/store" @@ -29,6 +31,7 @@ type PersonProfileStore interface { CreatePersonFromParticipantContext(ctx context.Context, participantID int64) (*store.Person, bool, error) GetPersonContext(ctx context.Context, id int64) (*store.Person, error) ListPersonsContext(ctx context.Context) ([]store.Person, error) + DirectoryPeoplePageContext(ctx context.Context, query store.DirectoryPeopleQuery) (*store.DirectoryPeoplePage, error) UpdatePersonDisplayNameContext( ctx context.Context, id, expectedRevision int64, displayName *string, ) (*store.Person, error) @@ -58,6 +61,13 @@ type PeopleResponse struct { People []store.Person `json:"people"` } +// DirectoryPeopleResponse is the non-sensitive, paginated Directory view of +// durable person roots. +type DirectoryPeopleResponse struct { + People []store.DirectoryPersonSummary `json:"people" nullable:"false"` + NextCursor string `json:"next_cursor,omitempty"` +} + // PersonSearchEngine is the semantic people service consumed by the HTTP // route. Production installs the concrete personsearch engine with the vector // subsystem; tests can supply a focused service double. @@ -95,6 +105,14 @@ func (s *Server) registerPersonProfileRoutes(api huma.API) { addErrorResponses(api, list.Responses, http.StatusServiceUnavailable) registerRawHumaRoute(api, list, s.handleListPeople) + directory := rawAPIV1Operation("listDirectoryPeople", http.MethodGet, "/people/directory", + "Query durable people for the Directory") + directory.Description = "Returns one stable, non-sensitive page of promoted durable people." + addDirectoryPeopleParameters(&directory) + directory.Responses = jsonResponsesFor[DirectoryPeopleResponse](api) + addErrorResponses(api, directory.Responses, http.StatusBadRequest, http.StatusServiceUnavailable) + registerRawHumaRoute(api, directory, s.handleDirectoryPeople) + create := rawAPIV1Operation("createPerson", http.MethodPost, "/people", "Promote a participant cluster to a durable person") create.Description = "Returns 201 when a new person is created, or 200 when the cluster is already " + "represented by a person (idempotent re-promotion, which also binds any unbound cluster members)." @@ -296,6 +314,85 @@ func (s *Server) handleListPeople(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, PeopleResponse{People: persons}) } +func (s *Server) handleDirectoryPeople(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + query, err := directoryPeopleQuery(r.URL.Query()) + if err != nil { + s.rejectBadParam(w, err) + return + } + profiles, ok := s.personProfileStore(w) + if !ok { + return + } + page, err := profiles.DirectoryPeoplePageContext(r.Context(), query) + if err != nil { + s.writeDirectoryPeopleError(w, err) + return + } + writeJSON(w, http.StatusOK, DirectoryPeopleResponse{People: page.People, NextCursor: page.NextCursor}) +} + +// directoryPeopleQuery reads each Directory query parameter once so the route +// has one canonical request representation for validation and pagination. +func directoryPeopleQuery(values url.Values) (store.DirectoryPeopleQuery, error) { + query := store.DirectoryPeopleQuery{ + Query: values.Get("q"), + Cursor: values.Get("cursor"), + ContactState: values.Get("contact_state"), + Category: values.Get("category"), + Organization: values.Get("organization"), + PrimaryChannel: values.Get("primary_channel"), + Sort: values.Get("sort"), + } + for _, field := range []struct { + name string + target **time.Time + }{ + {name: "last_contact_after", target: &query.LastContactAfter}, + {name: "last_contact_before", target: &query.LastContactBefore}, + } { + value := strings.TrimSpace(values.Get(field.name)) + if value == "" { + continue + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return store.DirectoryPeopleQuery{}, fmt.Errorf("%w: %s must be RFC3339", store.ErrInvalidDirectoryQuery, field.name) + } + *field.target = &parsed + } + limit := strings.TrimSpace(values.Get("limit")) + if limit == "" { + return query, nil + } + parsed, err := strconv.Atoi(limit) + if err != nil { + return store.DirectoryPeopleQuery{}, newParamError("limit", + fmt.Sprintf("query parameter %q must be an integer, got %q", "limit", limit)) + } + query.Limit = parsed + return query, nil +} + +func (s *Server) writeDirectoryPeopleError(w http.ResponseWriter, err error) { + if s.writeIfContextError(w, err) { + return + } + switch { + case errors.Is(err, store.ErrInvalidDirectoryCursor): + writeError(w, http.StatusBadRequest, "invalid_cursor", "Directory cursor is invalid") + case errors.Is(err, store.ErrInvalidDirectoryQuery): + writeError(w, http.StatusBadRequest, "invalid_query", "Directory query is invalid") + case errors.Is(err, store.ErrDirectoryProjectionStale): + writeError(w, http.StatusServiceUnavailable, "directory_projection_stale", + "Directory data is refreshing; retry shortly") + default: + s.logger.Error("directory people query failed", "error", err) + writeError(w, http.StatusServiceUnavailable, "directory_unavailable", "Directory is unavailable") + } +} + func (s *Server) handlePatchPerson(w http.ResponseWriter, r *http.Request) { profiles, ok := s.personProfileStore(w) if !ok { @@ -386,6 +483,31 @@ func addPersonIDParameter(operation *huma.Operation) { }) } +func addDirectoryPeopleParameters(operation *huma.Operation) { + lastContactAfter := queryStringParam("last_contact_after", "Return people contacted at or after this RFC3339 timestamp", false) + lastContactAfter.Schema.Format = "date-time" + lastContactBefore := queryStringParam("last_contact_before", "Return people contacted at or before this RFC3339 timestamp", false) + lastContactBefore.Schema.Format = "date-time" + sortParam := queryStringParam("sort", "Directory order: name, last_contact_desc, or last_contact_asc", false) + sortParam.Schema.Enum = []any{ + store.DirectoryPeopleSortName, + store.DirectoryPeopleSortLastContactDesc, + store.DirectoryPeopleSortLastContactAsc, + } + operation.Parameters = append(operation.Parameters, + queryStringParam("q", "Lexical query over person names, contact points, and organizations", false), + queryStringParam("cursor", "Opaque cursor returned by the previous Directory page", false), + queryIntegerParam("limit", "Maximum rows to return (default 50, max 100)"), + queryStringParam("contact_state", "Current contact state: active or inactive", false), + queryStringParam("category", "Current person category", false), + queryStringParam("organization", "Current organization", false), + queryStringParam("primary_channel", "Primary communication channel", false), + lastContactAfter, + lastContactBefore, + sortParam, + ) +} + func addPersonIfMatchParameter(operation *huma.Operation) { operation.Parameters = append(operation.Parameters, &huma.Param{ Name: ifMatchHeaderName, In: headerParamLocation, Required: true, diff --git a/internal/api/person_profiles_test.go b/internal/api/person_profiles_test.go index 62b46daaa..e9404f9fa 100644 --- a/internal/api/person_profiles_test.go +++ b/internal/api/person_profiles_test.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/danielgtaylor/huma/v2" @@ -15,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "go.kenn.io/msgvault/internal/config" "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" "go.kenn.io/msgvault/internal/vector" "go.kenn.io/msgvault/internal/vector/personsearch" ) @@ -99,6 +101,267 @@ func TestPersonProfileHTTPPromoteListGetUpdateAndConflictingLink(t *testing.T) { assert.Equal(http.StatusConflict, linkResponse.Code) } +func TestDirectoryPeopleHTTPReturnsNoStorePage(t *testing.T) { + require := require.New(t) + srv, st := newIdentityLinkTestServer(t) + participant := st.mustParticipant(t, "alice@example.test", "Alice Example", "example.test") + person, _, err := st.CreatePersonFromParticipant(participant) + require.NoError(err) + name := "Alice Example" + person, err = st.UpdatePersonDisplayNameContext(t.Context(), person.ID, person.Revision, &name) + require.NoError(err) + _, err = st.AddPersonCategoryContext(t.Context(), person.ID, store.PersonCategoryInput{ + OriginalValue: "friend", Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + + response := personRequest(t, srv, http.MethodGet, + peoplePath+"/directory?q=alcie&category=friend&limit=1", nil, "") + require.Equal(http.StatusOK, response.Code) + assert.Equal(t, "no-store", response.Header().Get("Cache-Control")) +} + +func TestDirectoryPeopleHTTPMapsEveryQueryParameter(t *testing.T) { + require := require.New(t) + srv, st := newIdentityLinkTestServer(t) + alice := createDirectoryHTTPPerson(t, st, "Alice Example", "alice@example.test", "friend", "Acme", true) + bob := createDirectoryHTTPPerson(t, st, "Bob Example", "bob@example.test", "colleague", "Other", false) + + for _, tc := range []struct { + name string + path string + }{ + {name: "query", path: peoplePath + "/directory?q=alcie"}, + {name: "contact state", path: peoplePath + "/directory?contact_state=active"}, + {name: "category", path: peoplePath + "/directory?category=friend"}, + {name: "organization", path: peoplePath + "/directory?organization=Acme"}, + {name: "primary channel", path: peoplePath + "/directory?primary_channel=email"}, + } { + t.Run(tc.name, func(t *testing.T) { + response := personRequest(t, srv, http.MethodGet, tc.path, nil, "") + require.Equal(http.StatusOK, response.Code) + assert.Equal(t, "no-store", response.Header().Get("Cache-Control")) + assertDirectoryPeopleResponseIDs(t, response, alice.ID) + }) + } + + first := personRequest(t, srv, http.MethodGet, peoplePath+"/directory?limit=1", nil, "") + require.Equal(http.StatusOK, first.Code) + var page DirectoryPeopleResponse + require.NoError(json.Unmarshal(first.Body.Bytes(), &page)) + require.NotEmpty(page.NextCursor) + second := personRequest(t, srv, http.MethodGet, + peoplePath+"/directory?limit=1&cursor="+page.NextCursor, nil, "") + require.Equal(http.StatusOK, second.Code) + assertDirectoryPeopleResponseIDs(t, second, bob.ID) +} + +func TestDirectoryPeopleHTTPFiltersSortsAndReturnsLastContact(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + srv, st := newIdentityLinkTestServer(t) + recent := createDirectoryHTTPPerson(t, st, "Alpha Recent", "recent@example.test", "friend", "Acme", true) + older := createDirectoryHTTPPerson(t, st, "Zulu Older", "older@example.test", "friend", "Acme", true) + never := createDirectoryHTTPPerson(t, st, "Bravo Never", "never@example.test", "friend", "Acme", false) + _, err := st.DB().ExecContext(t.Context(), st.Rebind(`UPDATE person_contact_state SET last_contact_at = ? WHERE person_id = ?`), "2026-08-20T10:00:00Z", recent.ID) + require.NoError(err) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`UPDATE person_contact_state SET last_contact_at = ? WHERE person_id = ?`), "2026-01-10T09:00:00Z", older.ID) + require.NoError(err) + + after := url.QueryEscape("2026-06-01T00:00:00Z") + filtered := personRequest(t, srv, http.MethodGet, + peoplePath+"/directory?sort=last_contact_desc&last_contact_after="+after, nil, "") + require.Equal(http.StatusOK, filtered.Code, filtered.Body.String()) + var filteredPage struct { + People []struct { + ID int64 `json:"id"` + LastContactAt *string `json:"last_contact_at"` + } `json:"people"` + } + require.NoError(json.Unmarshal(filtered.Body.Bytes(), &filteredPage)) + require.Len(filteredPage.People, 1) + assert.Equal(recent.ID, filteredPage.People[0].ID) + require.NotNil(filteredPage.People[0].LastContactAt) + assert.Equal("2026-08-20T10:00:00Z", *filteredPage.People[0].LastContactAt) + + first := personRequest(t, srv, http.MethodGet, + peoplePath+"/directory?contact_state=active&sort=last_contact_asc&limit=1", nil, "") + require.Equal(http.StatusOK, first.Code, first.Body.String()) + var firstPage DirectoryPeopleResponse + require.NoError(json.Unmarshal(first.Body.Bytes(), &firstPage)) + assert.Equal([]int64{older.ID}, directoryHTTPPersonIDs(firstPage.People)) + require.NotEmpty(firstPage.NextCursor) + + second := personRequest(t, srv, http.MethodGet, + peoplePath+"/directory?contact_state=active&sort=last_contact_asc&limit=1&cursor="+url.QueryEscape(firstPage.NextCursor), nil, "") + require.Equal(http.StatusOK, second.Code, second.Body.String()) + var secondPage DirectoryPeopleResponse + require.NoError(json.Unmarshal(second.Body.Bytes(), &secondPage)) + assert.Equal([]int64{recent.ID}, directoryHTTPPersonIDs(secondPage.People)) + assert.Empty(secondPage.NextCursor) + assert.NotEqual(never.ID, secondPage.People[0].ID) +} + +func TestDirectoryPeopleHTTPRejectsInvalidLastContactQuery(t *testing.T) { + srv, _ := newIdentityLinkTestServer(t) + for _, test := range []struct { + path string + code string + }{ + {path: peoplePath + "/directory?sort=oldestish", code: "invalid_query"}, + {path: peoplePath + "/directory?last_contact_after=yesterday", code: "invalid_parameter"}, + {path: peoplePath + "/directory?last_contact_before=tomorrow", code: "invalid_parameter"}, + } { + response := personRequest(t, srv, http.MethodGet, test.path, nil, "") + assert.Equal(t, http.StatusBadRequest, response.Code, test.path) + assertDirectoryPeopleError(t, response, test.code) + } +} + +func TestDirectoryPeopleHTTPRejectsInvalidParametersAndStaleProjection(t *testing.T) { + srv, _ := newIdentityLinkTestServer(t) + for _, tc := range []struct { + name string + path string + code string + }{ + {name: "cursor", path: peoplePath + "/directory?cursor=not-a-cursor", code: "invalid_cursor"}, + {name: "query", path: peoplePath + "/directory?contact_state=unknown", code: "invalid_query"}, + {name: "limit", path: peoplePath + "/directory?limit=many", code: "invalid_limit"}, + } { + t.Run(tc.name, func(t *testing.T) { + response := personRequest(t, srv, http.MethodGet, tc.path, nil, "") + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "no-store", response.Header().Get("Cache-Control")) + assertDirectoryPeopleError(t, response, tc.code) + }) + } + + staleServer := NewServer(&config.Config{Server: config.ServerConfig{APIPort: 8080}}, + &staleDirectoryPeopleStore{stubIdentityCacheStore: newDirectoryTestStore(t)}, nil, testLogger()) + stale := personRequest(t, staleServer, http.MethodGet, peoplePath+"/directory", nil, "") + require.Equal(t, http.StatusServiceUnavailable, stale.Code) + assert.Equal(t, "no-store", stale.Header().Get("Cache-Control")) + assertDirectoryPeopleError(t, stale, "directory_projection_stale") +} + +func TestDirectoryPeopleHTTPAcceptsEveryPublishedContactState(t *testing.T) { + srv, _ := newIdentityLinkTestServer(t) + for _, state := range []string{"", "active", "inactive"} { + path := peoplePath + "/directory" + if state != "" { + path += "?contact_state=" + state + } + response := personRequest(t, srv, http.MethodGet, path, nil, "") + require.Equal(t, http.StatusOK, response.Code, state) + } +} + +type staleDirectoryPeopleStore struct { + *stubIdentityCacheStore +} + +func (s *staleDirectoryPeopleStore) DirectoryPeoplePageContext( + context.Context, store.DirectoryPeopleQuery, +) (*store.DirectoryPeoplePage, error) { + return nil, store.ErrDirectoryProjectionStale +} + +func newDirectoryTestStore(t *testing.T) *stubIdentityCacheStore { + t.Helper() + return &stubIdentityCacheStore{Store: testutil.NewTestStore(t)} +} + +func createDirectoryHTTPPerson( + t *testing.T, + st *stubIdentityCacheStore, + displayName, email, category, organizationName string, + active bool, +) *store.Person { + t.Helper() + participantID, err := st.EnsureParticipantByIdentifier("email", email, displayName) + require.NoError(t, err) + person, _, err := st.CreatePersonFromParticipantContext(t.Context(), participantID) + require.NoError(t, err) + person, err = st.UpdatePersonDisplayNameContext(t.Context(), person.ID, person.Revision, &displayName) + require.NoError(t, err) + _, err = st.AddPersonContactPointContext(t.Context(), person.ID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: email, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(t, err) + _, err = st.AddPersonCategoryContext(t.Context(), person.ID, store.PersonCategoryInput{ + OriginalValue: category, Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(t, err) + organization, err := st.CreateOrganizationContext(t.Context(), store.OrganizationInput{ + Name: organizationName, Kind: store.OrganizationKindCompany, + }) + require.NoError(t, err) + _, err = st.AddEmploymentContext(t.Context(), store.EmploymentInput{ + PersonID: person.ID, OrganizationID: organization.ID, Source: store.ProvenanceUser, + }) + require.NoError(t, err) + if active { + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`INSERT INTO person_contact_state ( + person_id, last_contact_channel, last_contact_at, interaction_count + ) VALUES (?, 'email', CURRENT_TIMESTAMP, 1)`), person.ID) + require.NoError(t, err) + } + return person +} + +func assertDirectoryPeopleResponseIDs(t *testing.T, response *httptest.ResponseRecorder, want ...int64) { + t.Helper() + var page DirectoryPeopleResponse + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &page)) + got := make([]int64, len(page.People)) + for i, person := range page.People { + got[i] = person.ID + } + assert.Equal(t, want, got) +} + +func directoryHTTPPersonIDs(people []store.DirectoryPersonSummary) []int64 { + ids := make([]int64, 0, len(people)) + for _, person := range people { + ids = append(ids, person.ID) + } + return ids +} + +func TestDirectoryPeopleHTTPAlwaysEmitsArrays(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + srv, st := newIdentityLinkTestServer(t) + participantID, err := st.EnsureParticipantByIdentifier("email", "arrays@example.test", "Array Person") + require.NoError(err) + _, _, err = st.CreatePersonFromParticipantContext(t.Context(), participantID) + require.NoError(err) + response := personRequest(t, srv, http.MethodGet, peoplePath+"/directory", nil, "") + require.Equal(http.StatusOK, response.Code) + var body struct { + People []struct { + Categories []string `json:"categories"` + Organizations []string `json:"organizations"` + } `json:"people"` + } + require.NoError(json.Unmarshal(response.Body.Bytes(), &body)) + require.Len(body.People, 1) + assert.NotNil(body.People) + assert.NotNil(body.People[0].Categories) + assert.NotNil(body.People[0].Organizations) + assert.Empty(body.People[0].Categories) + assert.Empty(body.People[0].Organizations) +} + +func assertDirectoryPeopleError(t *testing.T, response *httptest.ResponseRecorder, want string) { + t.Helper() + var body ErrorResponse + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &body)) + assert.Equal(t, want, body.Error) +} + func TestPersonProfileHTTPDelete(t *testing.T) { assert := assert.New(t) require := require.New(t) diff --git a/internal/api/routes.go b/internal/api/routes.go index e8d7ac665..e72f61182 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -231,6 +231,7 @@ func (s *Server) registerHumaRoutes(api huma.API, apiV1 huma.API) { s.registerFilesRoutes(apiV1) s.registerDocumentSearchRoute(apiV1) s.registerPersonProfileRoutes(apiV1) + s.registerPersonNetworkRoutes(apiV1) s.registerPersonTrackingRoutes(apiV1) s.registerPersonMergeRoutes(apiV1) s.registerOrganizationRoutes(apiV1) @@ -429,6 +430,21 @@ func (s *Server) registerHumaRoutes(api huma.API, apiV1 huma.API) { http.StatusInternalServerError, http.StatusServiceUnavailable, ) + registerAPIV1RawHumaJSONRouteWithErrors[OperationRunsResponse]( + apiV1, "listOperationRuns", http.MethodGet, "/operations/runs", + "List normalized operation history", s.handleOperationRuns, + http.StatusBadRequest, http.StatusInternalServerError, http.StatusServiceUnavailable, + ) + registerAPIV1RawHumaJSONRoute[OperationStatusResponse]( + apiV1, "getOperationStatus", http.MethodGet, "/operations/status", + "Get normalized operation lane status", s.handleOperationStatus, + ) + registerAPIV1RawHumaJSONRouteWithErrors[OperationRunDetail]( + apiV1, "getOperationRun", http.MethodGet, "/operations/runs/{id}", + "Get one normalized operation run", s.handleOperationRunDetail, + http.StatusBadRequest, http.StatusNotFound, http.StatusInternalServerError, + http.StatusServiceUnavailable, + ) registerAPIV1RawHumaJSONRoute[GmailIDsResponse](apiV1, "getGmailIDsByFilter", http.MethodGet, "/messages/gmail-ids", "List Gmail message IDs matching a filter", s.handleGmailIDsByFilter) registerAPIV1RawHumaJSONRoute[TotalStatsResponse](apiV1, "getTotalStats", http.MethodGet, "/stats/total", "Get aggregate totals", s.handleTotalStats) registerAPIV1RawHumaJSONRoute[FilteredMessagesResponse](apiV1, "searchMessagesByDomains", http.MethodGet, "/search/domains", "Search messages by participant domains", s.handleSearchByDomains) @@ -611,6 +627,25 @@ func rawAPIV1Operation(operationID, method, path, summary string) huma.Operation func rawRouteParameters(operationID string) []*huma.Param { switch operationID { + case "listOperationRuns": + kind := queryStringParam("kind", "Exact operation kind", false) + kind.Schema.Enum = stringsToAny(operationKindValues()) + lane := queryStringParam("lane", "Exact semantic operation lane", false) + lane.Schema.Enum = stringsToAny(operationLaneValues()) + state := queryStringParam("state", "Exact operation state", false) + state.Schema.Enum = stringsToAny(operationStateValues()) + limit := queryIntegerParam("limit", "Maximum runs to return (default 25, max 100)") + minimum, maximum := float64(1), float64(100) + limit.Schema.Minimum, limit.Schema.Maximum = &minimum, &maximum + return []*huma.Param{ + kind, + lane, + state, + limit, + queryStringParam("cursor", "Opaque cursor bound to this archive and the exact kind, lane, and state filters", false), + } + case "getOperationRun": + return []*huma.Param{pathStringParam("id", "Opaque archive-bound operation run ID")} case "getCLIStats": return scopeParams() case "searchCLI": @@ -874,6 +909,14 @@ func rawRouteParameters(operationID string) []*huma.Param { } } +func stringsToAny(values []string) []any { + result := make([]any, 0, len(values)) + for _, value := range values { + result = append(result, value) + } + return result +} + func scopeParams() []*huma.Param { return []*huma.Param{ queryStringParam("account", "Restrict to one account/source", false), diff --git a/internal/api/server.go b/internal/api/server.go index c1095f226..63b0f8788 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -23,6 +23,8 @@ import ( "go.kenn.io/msgvault/internal/apiprotocol" "go.kenn.io/msgvault/internal/config" "go.kenn.io/msgvault/internal/daemonauth" + "go.kenn.io/msgvault/internal/operations" + "go.kenn.io/msgvault/internal/providercredentials" "go.kenn.io/msgvault/internal/provideridentity" "go.kenn.io/msgvault/internal/query" "go.kenn.io/msgvault/internal/scheduler" @@ -270,6 +272,7 @@ type Server struct { visualCoverageRateLimiter *RateLimiter idleTracker *IdleTracker operationGate OperationGate + operationHistoryReader operations.HistoryReader // ftsIndexComplete memoizes that the FTS index is fully populated so // handleCLISearch stops probing on every request. NeedsFTSBackfill runs an // anti-join that scans every message when the index is complete (the @@ -304,6 +307,11 @@ type Server struct { // settingsConfigEditor is the persisted config transaction boundary. Tests // replace it to deterministically exercise post-publication error handling. settingsConfigEditor func(string, string, []config.Edit) (config.ConfigFile, error) + // settingsCredentialDeleter is the independent credential-store transaction + // boundary used when a settings edit changes a provider origin. + settingsCredentialDeleter func( + string, providercredentials.Snapshot, []string, + ) (providercredentials.Snapshot, error) // activity reports request-scoped work that health should surface even // though it runs outside (or with more detail than) the operation gate, // e.g. the first-search FTS completeness probe and backfill progress. @@ -458,6 +466,11 @@ type ServerOptions struct { Logger *slog.Logger IdleTracker *IdleTracker OperationGate OperationGate + // OperationHistoryReader owns the normalized, privacy-bounded operation + // ledgers. It stays separate from MessageStore so unsupported stores can + // expose an explicit unavailable contract instead of implementing unrelated + // history methods. + OperationHistoryReader operations.HistoryReader // BlobStore serves attachment bytes for /api/v1/cli/attachment through // packed CAS storage with a loose-file fallback. Nil keeps the legacy // loose-file-only read path. @@ -540,6 +553,7 @@ func NewServerWithOptions(opts ServerOptions) *Server { daemonVersion: opts.DaemonVersion, idleTracker: opts.IdleTracker, operationGate: opts.OperationGate, + operationHistoryReader: opts.OperationHistoryReader, blobStore: opts.BlobStore, remoteImages: newRemoteImageFetcher(), inlineCache: newInlineParseCache(inlineCacheMaxEntries, inlineCacheMaxBytes), @@ -548,7 +562,7 @@ func NewServerWithOptions(opts ServerOptions) *Server { exploreState: newExploreServerState(time.Now), exploreCursorKey: newExploreCursorKey(), trustedProxies: trustedProxyPrefixes(opts.Config.Server.TrustedProxies), - settingsConfigEditor: config.EditConfigFile, + settingsConfigEditor: config.EditConfigFilePrivate, taskIntegrationProbe: taskProbe, taskLinkOperations: opts.TaskLinkOperations, taskIdentityResolver: opts.TaskIdentityResolver, diff --git a/internal/api/settings.go b/internal/api/settings.go index f7353a4cb..7f46685ef 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -9,21 +9,29 @@ import ( "math" "net/http" "net/url" + "slices" "strings" + "time" "github.com/danielgtaylor/huma/v2" "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/providercredentials" ) const ( - settingsPath = "/api/v1/settings" - settingsGroupSources = "sources" + settingsPath = "/api/v1/settings" + settingsGroupSources = "sources" + settingsGroupAttachments = "attachments" + settingsGroupEnrichment = "enrichment" + settingsCredentialETagHeader = "Credential-Etag" // #nosec G101 -- concurrency header, not a credential. + settingsCredentialETagSchema = "Credential-ETag" // #nosec G101 -- schema header name, not a credential. ) // SecretSettingState is the only representation of a secret returned to a // browser. The configured value never crosses the API boundary. type SecretSettingState struct { - Configured bool `json:"configured"` + Configured bool `json:"configured"` + Source string `json:"source,omitempty" enum:"stored,environment,none"` } // SettingValue is an explicit JSON union. Exactly one member is populated, @@ -37,12 +45,24 @@ type SettingValue struct { Strings *[]string `json:"strings,omitempty"` } +// SettingValidation lets generic Settings clients render the same basic +// input constraints enforced by the daemon. More complex cross-field rules +// remain authoritative at PATCH/PUT time. +type SettingValidation struct { + Hint string `json:"hint,omitempty"` + Required bool `json:"required,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` +} + // Setting describes one browser-managed allowlisted config value. ReadOnly // marks settings that are visible over HTTP but can only be changed by // editing config.toml on the daemon host; PATCH rejects updates to them. type Setting struct { Key string `json:"key"` Group string `json:"group"` + Label string `json:"label"` + Description string `json:"description"` Kind string `json:"kind"` Value *SettingValue `json:"value,omitempty"` Secret *SecretSettingState `json:"secret,omitempty"` @@ -50,11 +70,23 @@ type Setting struct { RestartRequired bool `json:"restart_required"` Testable bool `json:"testable,omitempty"` ReadOnly bool `json:"read_only,omitempty"` + Inherited bool `json:"inherited,omitempty"` + CredentialID string `json:"credential_id,omitempty"` + Validation *SettingValidation `json:"validation,omitempty"` +} + +type SettingGroup struct { + ID string `json:"id"` + Label string `json:"label"` + Description string `json:"description"` } type SettingsResponse struct { - Settings []Setting `json:"settings"` - PendingRestart bool `json:"pending_restart"` + Groups []SettingGroup `json:"groups"` + Settings []Setting `json:"settings"` + PersonEnrichmentProviders []PersonEnrichmentProviderSetting `json:"person_enrichment_providers,omitempty"` + CredentialETag string `json:"credential_etag"` + PendingRestart bool `json:"pending_restart"` } type SecretSettingUpdate struct { @@ -69,51 +101,74 @@ type SettingUpdate struct { } type SettingsPatchRequest struct { - Updates []SettingUpdate `json:"updates" minItems:"1" nullable:"false"` - ConfirmAPIKeyRestart bool `json:"confirm_api_key_restart,omitempty"` + Updates []SettingUpdate `json:"updates" minItems:"1" nullable:"false"` } +var ( + errInvalidSettingUpdate = errors.New("invalid setting update") + errSuppressionRollbackFailed = errors.New("generated suppression key could not be rolled back") +) + type settingDefinition struct { - key string - group string - kind string - options []string - testable bool + key string + group string + kind string + options []string + restartRequired bool // localOnly settings are visible over HTTP but can only be changed by // editing config.toml on the daemon host. Used for values that select // daemon-side resources (such as environment variable names) which a // remote session must never control. - localOnly bool - secret func(*config.Config) bool - serverSecret func(context.Context, *Server, *config.Config) bool - read func(*config.Config) any + localOnly bool + secret func(*config.Config) bool + serverSecret func(context.Context, *Server, *config.Config) bool + credentialID string + credentialEndpoint func(*config.Config) string + credentialEnvironment func(*config.Config) string + inherited func(*config.Config) bool + read func(*config.Config) any } var settingsCatalog = []settingDefinition{ - stringSetting("web.default_search_mode", "browser", []string{exploreSearchModeFullText, exploreSearchModeSemantic, exploreSearchModeHybrid}, func(c *config.Config) string { return c.Web.DefaultSearchMode }), - stringSetting("web.theme", "browser", []string{"system", "light", "dark"}, func(c *config.Config) string { return c.Web.Theme }), - stringSetting("web.density", "browser", []string{"compact", "comfortable"}, func(c *config.Config) string { return c.Web.Density }), - stringSetting("server.bind_addr", "server", nil, func(c *config.Config) string { return c.Server.BindAddr }), - intSetting("server.api_port", "server", func(c *config.Config) int { return c.Server.APIPort }), - secretSetting("server.api_key", "server", func(c *config.Config) bool { return c.Server.APIKey != "" }), - boolSetting("server.allow_insecure", "server", func(c *config.Config) bool { return c.Server.AllowInsecure }), - stringArraySetting("server.trusted_proxies", "server", func(c *config.Config) []string { return c.Server.TrustedProxies }), + liveStringSetting("web.default_search_mode", "browser", []string{exploreSearchModeFullText, exploreSearchModeSemantic, exploreSearchModeHybrid}, func(c *config.Config) string { return c.Web.DefaultSearchMode }), + liveStringSetting("web.theme", "browser", []string{"system", "light", "dark"}, func(c *config.Config) string { return c.Web.Theme }), + liveStringSetting("web.density", "browser", []string{"compact", "comfortable"}, func(c *config.Config) string { return c.Web.Density }), + readOnlyStringSetting("server.bind_addr", "server", func(c *config.Config) string { return c.Server.BindAddr }), + readOnlyIntSetting("server.api_port", "server", func(c *config.Config) int { return c.Server.APIPort }), + readOnlySecretSetting("server.api_key", "server", func(c *config.Config) bool { return c.Server.APIKey != "" }), + readOnlyBoolSetting("server.allow_insecure", "server", func(c *config.Config) bool { return c.Server.AllowInsecure }), + readOnlyStringArraySetting("server.trusted_proxies", "server", func(c *config.Config) []string { return c.Server.TrustedProxies }), + stringSetting("server.daemon_idle_timeout", "server", nil, func(c *config.Config) string { return c.Server.DaemonIdleTimeout.String() }), + stringSetting("server.daemon_auto_restart", "server", []string{config.DaemonAutoRestartNewer, config.DaemonAutoRestartNever, config.DaemonAutoRestartAlways}, func(c *config.Config) string { return c.Server.DaemonAutoRestart }), stringSetting("analytics.engine", "archive", []string{"auto", "sql", "duckdb"}, func(c *config.Config) string { return c.Analytics.Engine }), boolSetting("analytics.auto_build_cache", "archive", func(c *config.Config) bool { return c.Analytics.AutoBuildCache }), + stringSetting("analytics.min_rebuild_interval", "archive", nil, func(c *config.Config) string { return c.Analytics.MinRebuildInterval.String() }), + stringSetting("analytics.builder_memory_limit", "archive", nil, func(c *config.Config) string { return c.Analytics.BuilderMemoryLimit }), + intSetting("analytics.builder_threads", "archive", func(c *config.Config) int { return c.Analytics.BuilderThreads }), + stringSetting("analytics.builder_temp_limit", "archive", nil, func(c *config.Config) string { return c.Analytics.BuilderTempLimit }), + intSetting("sync.rate_limit_qps", "sync", func(c *config.Config) int { return c.Sync.RateLimitQPS }), + boolSetting("log.enabled", "logging", func(c *config.Config) bool { return c.Log.Enabled }), + stringSetting("log.level", "logging", []string{"", "debug", "info", "warn", "error"}, func(c *config.Config) string { return c.Log.Level }), + int64Setting("log.sql_slow_ms", "logging", func(c *config.Config) int64 { return c.Log.SQLSlowMs }), + boolSetting("log.sql_trace", "logging", func(c *config.Config) bool { return c.Log.SQLTrace }), boolSetting("vector.enabled", "search", func(c *config.Config) bool { return c.Vector.Enabled }), - stringSetting("vector.backend", "search", []string{"sqlite-vec", "pgvector"}, func(c *config.Config) string { return c.Vector.Backend }), - stringSetting("vector.db_path", "search", nil, func(c *config.Config) string { return c.Vector.DBPath }), - boolSetting("vector.skip_extension_create", "search", func(c *config.Config) bool { return c.Vector.SkipExtensionCreate }), + readOnlyStringSettingWithOptions("vector.backend", "search", []string{"sqlite-vec", "pgvector"}, func(c *config.Config) string { return c.Vector.Backend }), + readOnlyStringSetting("vector.db_path", "search", func(c *config.Config) string { return c.Vector.DBPath }), + readOnlyBoolSetting("vector.skip_extension_create", "search", func(c *config.Config) bool { return c.Vector.SkipExtensionCreate }), stringSetting("vector.embeddings.api_format", "search", []string{"openai", "voyage-contextual"}, func(c *config.Config) string { return string(c.Vector.Embeddings.EffectiveAPIFormat()) }), - testableStringSetting("vector.embeddings.endpoint", "search", func(c *config.Config) string { return c.Vector.Embeddings.Endpoint }), + stringSetting("vector.embeddings.endpoint", "search", nil, func(c *config.Config) string { return c.Vector.Embeddings.Endpoint }), localOnlyStringSetting("vector.embeddings.api_key_env", "search", func(c *config.Config) string { return c.Vector.Embeddings.APIKeyEnv }), + providerCredentialSetting("vector.embeddings.api_key", "search", providercredentials.VectorEmbeddingsID, + func(c *config.Config) string { return c.Vector.Embeddings.Endpoint }, + func(c *config.Config) string { return c.Vector.Embeddings.APIKeyEnv }), stringSetting("vector.embeddings.model", "search", nil, func(c *config.Config) string { return c.Vector.Embeddings.Model }), stringSetting("vector.embeddings.document_prefix", "search", nil, func(c *config.Config) string { return c.Vector.Embeddings.DocumentPrefix }), stringSetting("vector.embeddings.query_prefix", "search", nil, func(c *config.Config) string { return c.Vector.Embeddings.QueryPrefix }), intSetting("vector.embeddings.dimension", "search", func(c *config.Config) int { return c.Vector.Embeddings.Dimension }), intSetting("vector.embeddings.batch_size", "search", func(c *config.Config) int { return c.Vector.Embeddings.BatchSize }), + stringSetting("vector.embeddings.timeout", "search", nil, func(c *config.Config) string { return c.Vector.Embeddings.Timeout.String() }), intSetting("vector.embeddings.max_retries", "search", func(c *config.Config) int { return c.Vector.Embeddings.MaxRetries }), intSetting("vector.embeddings.max_input_chars", "search", func(c *config.Config) int { return c.Vector.Embeddings.MaxInputChars }), intSetting("vector.embeddings.eta_window", "search", func(c *config.Config) int { return c.Vector.Embeddings.ETAWindow }), @@ -128,10 +183,14 @@ var settingsCatalog = []settingDefinition{ boolSetting("vector.embed.schedule.run_after_sync", "search", func(c *config.Config) bool { return c.Vector.Embed.Schedule.RunAfterSync }), stringArraySetting("vector.embed.scope.message_types", "search", func(c *config.Config) []string { return c.Vector.Embed.Scope.MessageTypes }), stringArraySetting("vector.embed.scope.accounts", "search", func(c *config.Config) []string { return c.Vector.Embed.Scope.Accounts }), + stringSetting("vector.embed.backstop_interval", "search", nil, func(c *config.Config) string { return c.Vector.Embed.BackstopInterval.String() }), boolSetting("vector.multimodal.enabled", "search", func(c *config.Config) bool { return c.Vector.Multimodal.Enabled }), stringSetting("vector.multimodal.provider", "search", []string{"voyage"}, func(c *config.Config) string { return c.Vector.Multimodal.Provider }), - testableStringSetting("vector.multimodal.endpoint", "search", func(c *config.Config) string { return c.Vector.Multimodal.Endpoint }), + stringSetting("vector.multimodal.endpoint", "search", nil, func(c *config.Config) string { return c.Vector.Multimodal.Endpoint }), localOnlyStringSetting("vector.multimodal.api_key_env", "search", func(c *config.Config) string { return c.Vector.Multimodal.APIKeyEnv }), + providerCredentialSetting("vector.multimodal.api_key", "search", providercredentials.VectorMultimodalID, + func(c *config.Config) string { return c.Vector.Multimodal.Endpoint }, + func(c *config.Config) string { return c.Vector.Multimodal.APIKeyEnv }), localOnlyStringSetting("vector.multimodal.capabilities_file", "search", func(c *config.Config) string { return c.Vector.Multimodal.CapabilitiesFile }), stringSetting("vector.multimodal.model", "search", []string{"voyage-multimodal-3.5"}, func(c *config.Config) string { return c.Vector.Multimodal.Model }), intSetting("vector.multimodal.dimension", "search", func(c *config.Config) int { return c.Vector.Multimodal.Dimension }), @@ -150,15 +209,54 @@ var settingsCatalog = []settingDefinition{ intSetting("vector.search.rrf_k", "search", func(c *config.Config) int { return c.Vector.Search.RRFK }), intSetting("vector.search.k_per_signal", "search", func(c *config.Config) int { return c.Vector.Search.KPerSignal }), numberSetting("vector.search.subject_boost", "search", func(c *config.Config) float64 { return c.Vector.Search.SubjectBoost }), + intSetting("vector.search.max_page_size_hybrid", "search", func(c *config.Config) int { return c.Vector.Search.MaxPageSizeHybridClamp() }), + configuredBoolSetting("vector.preprocess.strip_quotes", "search", func(c *config.Config) bool { return c.Vector.Preprocess.StripQuotesEnabled() }, func(c *config.Config) bool { return c.Vector.Preprocess.StripQuotes == nil }), + configuredBoolSetting("vector.preprocess.strip_signatures", "search", func(c *config.Config) bool { return c.Vector.Preprocess.StripSignaturesEnabled() }, func(c *config.Config) bool { return c.Vector.Preprocess.StripSignatures == nil }), + configuredBoolSetting("vector.preprocess.strip_html", "search", func(c *config.Config) bool { return c.Vector.Preprocess.StripHTMLEnabled() }, func(c *config.Config) bool { return c.Vector.Preprocess.StripHTML == nil }), + configuredBoolSetting("vector.preprocess.strip_base64", "search", func(c *config.Config) bool { return c.Vector.Preprocess.StripBase64Enabled() }, func(c *config.Config) bool { return c.Vector.Preprocess.StripBase64 == nil }), + configuredBoolSetting("vector.preprocess.strip_url_tracking", "search", func(c *config.Config) bool { return c.Vector.Preprocess.StripURLTrackingEnabled() }, func(c *config.Config) bool { return c.Vector.Preprocess.StripURLTracking == nil }), + configuredBoolSetting("vector.preprocess.collapse_whitespace", "search", func(c *config.Config) bool { return c.Vector.Preprocess.CollapseWhitespaceEnabled() }, func(c *config.Config) bool { return c.Vector.Preprocess.CollapseWhitespace == nil }), boolSetting("beeper.enabled", settingsGroupSources, func(c *config.Config) bool { return c.Beeper.Enabled }), stringSetting("beeper.schedule", settingsGroupSources, nil, func(c *config.Config) string { return c.Beeper.Schedule }), + stringArraySetting("beeper.accounts", settingsGroupSources, func(c *config.Config) []string { return c.Beeper.Accounts }), + stringArraySetting("beeper.exclude_accounts", settingsGroupSources, func(c *config.Config) []string { return c.Beeper.ExcludeAccounts }), + numberSetting("beeper.rate_limit_qps", settingsGroupSources, func(c *config.Config) float64 { return c.Beeper.RateLimitQPS }), + boolSetting("slack.enabled", settingsGroupSources, func(c *config.Config) bool { return c.Slack.Enabled }), + stringSetting("slack.schedule", settingsGroupSources, nil, func(c *config.Config) string { return c.Slack.Schedule }), + stringArraySetting("slack.channels", settingsGroupSources, func(c *config.Config) []string { return c.Slack.Channels }), + stringArraySetting("slack.exclude_channels", settingsGroupSources, func(c *config.Config) []string { return c.Slack.ExcludeChannels }), + configuredBoolSetting("beeper.media", settingsGroupAttachments, func(c *config.Config) bool { return c.Beeper.MediaEnabled() }, func(c *config.Config) bool { return c.Beeper.Media == nil }), + stringSetting("beeper.media_scope", settingsGroupAttachments, []string{"all", "direct", "none"}, func(c *config.Config) string { return effectiveMediaScope(c.Beeper.MediaScope) }), + intSetting("beeper.media_max_participants", settingsGroupAttachments, func(c *config.Config) int { return c.Beeper.MediaMaxParticipants }), + intSetting("beeper.max_media_mb", settingsGroupAttachments, func(c *config.Config) int { return c.Beeper.MaxMediaMB }), + configuredBoolSetting("slack.media", settingsGroupAttachments, func(c *config.Config) bool { return c.Slack.MediaEnabled() }, func(c *config.Config) bool { return c.Slack.Media == nil }), + stringSetting("slack.media_scope", settingsGroupAttachments, []string{"all", "direct", "none"}, func(c *config.Config) string { return effectiveMediaScope(c.Slack.MediaScope) }), + intSetting("slack.media_max_participants", settingsGroupAttachments, func(c *config.Config) int { return c.Slack.MediaMaxParticipants }), + intSetting("slack.max_media_mb", settingsGroupAttachments, func(c *config.Config) int { return c.Slack.MaxMediaMB }), + configuredBoolSetting("discord.media", settingsGroupAttachments, func(c *config.Config) bool { return c.Discord.Media == nil || *c.Discord.Media }, func(c *config.Config) bool { return c.Discord.Media == nil }), + stringSetting("discord.media_scope", settingsGroupAttachments, []string{"all", "direct", "none"}, func(c *config.Config) string { return effectiveMediaScope(c.Discord.MediaScope) }), + intSetting("discord.media_max_participants", settingsGroupAttachments, func(c *config.Config) int { return c.Discord.MediaMaxParticipants }), + intSetting("discord.max_media_mb", settingsGroupAttachments, func(c *config.Config) int { return c.Discord.MaxMediaMB }), + configuredBoolSetting("teams.media", settingsGroupAttachments, func(c *config.Config) bool { return c.Teams.Media == nil || *c.Teams.Media }, func(c *config.Config) bool { return c.Teams.Media == nil }), + stringSetting("teams.media_scope", settingsGroupAttachments, []string{"all", "direct", "none"}, func(c *config.Config) string { return effectiveMediaScope(c.Teams.MediaScope) }), + intSetting("teams.media_max_participants", settingsGroupAttachments, func(c *config.Config) int { return c.Teams.MediaMaxParticipants }), + intSetting("teams.max_media_mb", settingsGroupAttachments, func(c *config.Config) int { return c.Teams.MaxMediaMB }), + stringSetting("activity.timezone", "activity", nil, func(c *config.Config) string { return c.Activity.Timezone }), + intSetting("activity.max_direct_counterparts", "activity", func(c *config.Config) int { return c.Activity.MaxDirectCounterparts }), + intSetting("activity.batch_size", "activity", func(c *config.Config) int { return c.Activity.BatchSize }), + stringSetting("activity.schedule", "activity", nil, func(c *config.Config) string { return c.Activity.Schedule }), + intSetting("backup.zstd_level", "backup", func(c *config.Config) int { return c.Backup.ZstdLevel }), + boolSetting("people.enrichment.enabled", settingsGroupEnrichment, func(c *config.Config) bool { return c.People.Enrichment.Enabled }), + stringSetting("people.enrichment.schedule", settingsGroupEnrichment, nil, func(c *config.Config) string { return c.People.Enrichment.Schedule }), + intSetting("people.enrichment.batch_size", settingsGroupEnrichment, func(c *config.Config) int { return c.People.Enrichment.BatchSize }), + stringSetting("people.enrichment.lease_duration", settingsGroupEnrichment, nil, func(c *config.Config) string { return c.People.Enrichment.LeaseDuration.String() }), readOnlyStringSetting("carddav.base_url", settingsGroupSources, func(c *config.Config) string { return c.CardDAV.BaseURL }), readOnlyStringSetting("carddav.username", settingsGroupSources, func(c *config.Config) string { return c.CardDAV.Username }), readOnlyStringSetting("carddav.schedule", settingsGroupSources, func(c *config.Config) string { return c.CardDAV.Schedule }), readOnlyBoolSetting("carddav.enabled", settingsGroupSources, func(c *config.Config) bool { return c.CardDAV.Enabled }), readOnlyCardDAVSecretSetting(), boolSetting("integrations.tasks.enabled", "integrations", func(c *config.Config) bool { return c.Integrations.Tasks.Enabled }), - testableStringSetting("integrations.tasks.endpoint", "integrations", func(c *config.Config) string { return c.Integrations.Tasks.Endpoint }), + stringSetting("integrations.tasks.endpoint", "integrations", nil, func(c *config.Config) string { return c.Integrations.Tasks.Endpoint }), secretSetting("integrations.tasks.api_key", "integrations", func(c *config.Config) bool { return c.Integrations.Tasks.APIKey != "" }), stringSetting("integrations.tasks.default_project", "integrations", nil, func(c *config.Config) string { return c.Integrations.Tasks.DefaultProject }), } @@ -167,6 +265,7 @@ func (s *Server) registerSettingsRoutes(api huma.API) { get := rawAPIV1Operation("getSettings", http.MethodGet, "/settings", "Get browser-managed settings") get.Responses = jsonResponsesFor[SettingsResponse](api) addSettingsETagHeader(get.Responses[httpStatusKey(http.StatusOK)]) + addSettingsCredentialETagHeader(get.Responses[httpStatusKey(http.StatusOK)]) registerRawHumaRoute(api, get, s.handleGetSettings) patch := rawAPIV1Operation("patchSettings", http.MethodPatch, "/settings", "Update browser-managed settings") @@ -189,7 +288,10 @@ func (s *Server) registerSettingsRoutes(api huma.API) { patch.Responses[httpStatusKey(status)] = errorResponseFor(api) } addSettingsETagHeader(patch.Responses[httpStatusKey(http.StatusOK)]) + addSettingsCredentialETagHeader(patch.Responses[httpStatusKey(http.StatusOK)]) registerRawHumaRoute(api, patch, s.handlePatchSettings) + s.registerProviderCredentialSettingsRoutes(api) + s.registerPersonEnrichmentSettingsRoute(api) } func addSettingsETagHeader(response *huma.Response) { @@ -201,13 +303,23 @@ func addSettingsETagHeader(response *huma.Response) { } } +func addSettingsCredentialETagHeader(response *huma.Response) { + if response.Headers == nil { + response.Headers = map[string]*huma.Param{} + } + response.Headers[settingsCredentialETagSchema] = &huma.Param{ + Description: "Strong content hash for the independent provider credential store", + Schema: &huma.Schema{Type: huma.TypeString}, + } +} + func stringSetting(key, group string, options []string, read func(*config.Config) string) settingDefinition { - return settingDefinition{key: key, group: group, kind: "string", options: options, read: func(c *config.Config) any { return read(c) }} + return settingDefinition{key: key, group: group, kind: "string", options: options, restartRequired: true, read: func(c *config.Config) any { return read(c) }} } -func testableStringSetting(key, group string, read func(*config.Config) string) settingDefinition { - definition := stringSetting(key, group, nil, read) - definition.testable = true +func liveStringSetting(key, group string, options []string, read func(*config.Config) string) settingDefinition { + definition := stringSetting(key, group, options, read) + definition.restartRequired = false return definition } @@ -221,12 +333,36 @@ func readOnlyStringSetting(key, group string, read func(*config.Config) string) return localOnlyStringSetting(key, group, read) } +func readOnlyStringSettingWithOptions(key, group string, options []string, read func(*config.Config) string) settingDefinition { + definition := stringSetting(key, group, options, read) + definition.localOnly = true + return definition +} + func readOnlyBoolSetting(key, group string, read func(*config.Config) bool) settingDefinition { definition := boolSetting(key, group, read) definition.localOnly = true return definition } +func readOnlyIntSetting(key, group string, read func(*config.Config) int) settingDefinition { + definition := intSetting(key, group, read) + definition.localOnly = true + return definition +} + +func readOnlyStringArraySetting(key, group string, read func(*config.Config) []string) settingDefinition { + definition := stringArraySetting(key, group, read) + definition.localOnly = true + return definition +} + +func readOnlySecretSetting(key, group string, configured func(*config.Config) bool) settingDefinition { + definition := secretSetting(key, group, configured) + definition.localOnly = true + return definition +} + func readOnlyCardDAVSecretSetting() settingDefinition { return settingDefinition{ key: "carddav.password", group: settingsGroupSources, kind: "secret", localOnly: true, @@ -238,23 +374,54 @@ func readOnlyCardDAVSecretSetting() settingDefinition { } func intSetting(key, group string, read func(*config.Config) int) settingDefinition { - return settingDefinition{key: key, group: group, kind: "integer", read: func(c *config.Config) any { return read(c) }} + return settingDefinition{key: key, group: group, kind: "integer", restartRequired: true, read: func(c *config.Config) any { return read(c) }} +} + +func int64Setting(key, group string, read func(*config.Config) int64) settingDefinition { + return intSetting(key, group, func(c *config.Config) int { return int(read(c)) }) } func numberSetting(key, group string, read func(*config.Config) float64) settingDefinition { - return settingDefinition{key: key, group: group, kind: "number", read: func(c *config.Config) any { return read(c) }} + return settingDefinition{key: key, group: group, kind: "number", restartRequired: true, read: func(c *config.Config) any { return read(c) }} } func boolSetting(key, group string, read func(*config.Config) bool) settingDefinition { - return settingDefinition{key: key, group: group, kind: "boolean", read: func(c *config.Config) any { return read(c) }} + return settingDefinition{key: key, group: group, kind: "boolean", restartRequired: true, read: func(c *config.Config) any { return read(c) }} +} + +func configuredBoolSetting( + key, group string, + read func(*config.Config) bool, + inherited func(*config.Config) bool, +) settingDefinition { + definition := boolSetting(key, group, read) + definition.inherited = inherited + return definition } func stringArraySetting(key, group string, read func(*config.Config) []string) settingDefinition { - return settingDefinition{key: key, group: group, kind: "string_array", read: func(c *config.Config) any { return read(c) }} + return settingDefinition{key: key, group: group, kind: "string_array", restartRequired: true, read: func(c *config.Config) any { return read(c) }} } func secretSetting(key, group string, configured func(*config.Config) bool) settingDefinition { - return settingDefinition{key: key, group: group, kind: "secret", secret: configured} + return settingDefinition{key: key, group: group, kind: "secret", restartRequired: true, secret: configured} +} + +func providerCredentialSetting( + key, group, credentialID string, + endpoint, environment func(*config.Config) string, +) settingDefinition { + return settingDefinition{ + key: key, group: group, kind: "secret", restartRequired: true, + credentialID: credentialID, credentialEndpoint: endpoint, credentialEnvironment: environment, + } +} + +func effectiveMediaScope(value string) string { + if strings.TrimSpace(value) == "" { + return "all" + } + return value } func settingsDefinitionByKey() map[string]settingDefinition { @@ -268,12 +435,27 @@ func settingsDefinitionByKey() map[string]settingDefinition { func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { snapshot, cfg, err := s.readPersistedSettings() if err != nil { - writeError(w, http.StatusInternalServerError, "settings_read_failed", err.Error()) + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + if err := validateSafePublicSettingsEndpoints(cfg); err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + credentials, err := providercredentials.Read(cfg.TokensDir()) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + response, err := s.buildSettingsResponse(r.Context(), cfg, credentials, s.settingsPendingRestart.Load()) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") return } w.Header().Set(etagHeaderName, snapshot.ETag) + w.Header().Set(settingsCredentialETagHeader, credentials.ETag) w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, s.buildSettingsResponse(r.Context(), cfg, s.settingsPendingRestart.Load())) + writeJSON(w, http.StatusOK, response) } func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) { @@ -299,56 +481,124 @@ func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) { } _, current, err := s.readPersistedSettings() if err != nil { - writeError(w, http.StatusInternalServerError, "settings_read_failed", err.Error()) + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + credentials, err := providercredentials.Read(current.TokensDir()) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") return } - edits, changesAPIKey, err := settingsEdits(current, request.Updates) + edits, restartRequired, err := settingsEdits(current, request.Updates) if err != nil { + if errors.Is(err, errInvalidSettingUpdate) { + writeError(w, http.StatusUnprocessableEntity, "validation_failed", "One or more settings are invalid") + return + } writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } - if changesAPIKey && !request.ConfirmAPIKeyRestart { - writeError(w, http.StatusBadRequest, "api_key_restart_confirmation_required", - "Changing the API key requires confirmation because it takes effect after restart") + suppressionEdits, updatedCredentials, generatedSuppression, err := + prepareFirstEnrichmentEnable(current, request.Updates, credentials) + if err != nil { + writeFirstEnrichmentEnableError(w, err) return } + credentials = updatedCredentials + edits = append(edits, suppressionEdits...) - editor := s.settingsConfigEditor - if editor == nil { - editor = config.EditConfigFile - } - snapshot, err := editor(s.cfg.ConfigFilePath(), ifMatches[0], edits) + snapshot, err := s.commitSettingsEdits(ifMatches[0], edits, generatedSuppression, current.TokensDir()) if err != nil { - if errors.Is(err, config.ErrConfigChanged) { + if errors.Is(err, config.ErrConfigChanged) && restartRequired { s.settingsPendingRestart.Store(true) } - switch { - case errors.Is(err, config.ErrConfigChanged): - writeError(w, http.StatusInternalServerError, "settings_write_failed", - "Settings changed, but the write did not complete cleanly; restart is required") - case errors.Is(err, config.ErrConfigConflict): - writeError(w, http.StatusPreconditionFailed, "settings_conflict", "The config file changed; reload settings and retry") - case errors.Is(err, config.ErrAmbiguousConfigTarget), errors.Is(err, config.ErrUnsafeConfigTarget): - writeError(w, http.StatusConflict, "settings_edit_rejected", err.Error()) - case errors.Is(err, config.ErrInvalidConfigCandidate): - writeError(w, http.StatusUnprocessableEntity, "validation_failed", err.Error()) - default: - writeError(w, http.StatusInternalServerError, "settings_write_failed", "Could not write settings") - } + s.writeSettingsConfigError(w, err) return } // A nil editor error means the candidate is already the committed config. // Record that fact before decoding the response snapshot so a subsequent // load failure cannot make the daemon report a false non-pending state. - s.settingsPendingRestart.Store(true) + if restartRequired { + s.settingsPendingRestart.Store(true) + } loaded, err := config.LoadConfigFile(snapshot, "") if err != nil { - writeError(w, http.StatusInternalServerError, "settings_read_failed", err.Error()) + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + credentials, err = deleteStoredCredentials(current.TokensDir(), credentials, + staleStoredCredentialIDs(loaded, credentials)) + if err != nil { + writeProviderCredentialError(w, err) + return + } + response, err := s.buildSettingsResponse(r.Context(), loaded, credentials, s.settingsPendingRestart.Load()) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") return } w.Header().Set(etagHeaderName, snapshot.ETag) + w.Header().Set(settingsCredentialETagHeader, credentials.ETag) w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, s.buildSettingsResponse(r.Context(), loaded, true)) + writeJSON(w, http.StatusOK, response) +} + +func writeFirstEnrichmentEnableError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, errSuppressionUnavailable): + writeError(w, http.StatusUnprocessableEntity, "suppression_key_unavailable", + "Person enrichment requires a valid host suppression key") + case errors.Is(err, providercredentials.ErrConflict): + writeError(w, http.StatusPreconditionFailed, "credential_conflict", + "Provider credentials changed; reload settings and retry") + default: + writeError(w, http.StatusInternalServerError, "credential_store_unavailable", + "Provider credential store is unavailable") + } +} + +// commitSettingsEdits writes the candidate config file. When this PATCH +// generated a suppression key for the first enrichment enable and the write +// provably did not reach the committed file, the generated key is removed +// again so the credential store does not keep a secret config never adopted. +func (s *Server) commitSettingsEdits( + ifMatch string, edits []config.Edit, generatedSuppression, tokensDir string, +) (config.ConfigFile, error) { + editor := s.settingsConfigEditor + if editor == nil { + editor = config.EditConfigFile + } + snapshot, err := editor(s.cfg.ConfigFilePath(), ifMatch, edits) + if err == nil { + return snapshot, nil + } + if generatedSuppression != "" && !errors.Is(err, config.ErrConfigChanged) { + if _, rollbackErr := providercredentials.DeleteSuppressionIfValue( + tokensDir, generatedSuppression, + ); rollbackErr != nil { + return config.ConfigFile{}, fmt.Errorf("%w: %w", errSuppressionRollbackFailed, rollbackErr) + } + } + return config.ConfigFile{}, err +} + +func (s *Server) writeSettingsConfigError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, errSuppressionRollbackFailed): + writeError(w, http.StatusInternalServerError, "credential_store_unavailable", + "Provider credential store is unavailable") + case errors.Is(err, config.ErrConfigChanged): + writeError(w, http.StatusInternalServerError, "settings_write_failed", + "Settings changed, but the write did not complete cleanly") + case errors.Is(err, config.ErrConfigConflict): + writeError(w, http.StatusPreconditionFailed, "settings_conflict", "The config file changed; reload settings and retry") + case errors.Is(err, config.ErrAmbiguousConfigTarget), errors.Is(err, config.ErrUnsafeConfigTarget): + writeError(w, http.StatusConflict, "settings_edit_rejected", "Settings could not be edited safely") + case errors.Is(err, config.ErrInvalidConfigCandidate): + writeError(w, http.StatusUnprocessableEntity, "validation_failed", "One or more settings are invalid") + default: + writeError(w, http.StatusInternalServerError, "settings_write_failed", "Could not write settings") + } } func (s *Server) readPersistedSettings() (config.ConfigFile, *config.Config, error) { @@ -366,19 +616,40 @@ func (s *Server) readPersistedSettings() (config.ConfigFile, *config.Config, err return snapshot, loaded, nil } -func (s *Server) buildSettingsResponse(ctx context.Context, cfg *config.Config, pendingRestart bool) SettingsResponse { +func (s *Server) buildSettingsResponse( + ctx context.Context, + cfg *config.Config, + credentials providercredentials.Snapshot, + pendingRestart bool, +) (SettingsResponse, error) { settings := make([]Setting, 0, len(settingsCatalog)) for _, definition := range settingsCatalog { + metadata := metadataForSetting(definition.key) setting := Setting{ Key: definition.key, Group: definition.group, + Label: metadata.label, + Description: metadata.description, Kind: definition.kind, Options: definition.options, - RestartRequired: true, - Testable: definition.testable, + RestartRequired: definition.restartRequired, ReadOnly: definition.localOnly, + CredentialID: definition.credentialID, + Validation: validationForSetting(definition.key), } - if definition.serverSecret != nil { + if definition.inherited != nil { + setting.Inherited = definition.inherited(cfg) + } + if definition.credentialID != "" { + _, state, err := credentials.Resolve(definition.credentialID, + definition.credentialEndpoint(cfg), definition.credentialEnvironment(cfg), osLookupEnv) + if errors.Is(err, providercredentials.ErrOriginMismatch) { + state = providercredentials.State{Configured: false, Source: providercredentials.SourceNone} + } else if err != nil { + return SettingsResponse{}, err + } + setting.Secret = &SecretSettingState{Configured: state.Configured, Source: string(state.Source)} + } else if definition.serverSecret != nil { setting.Secret = &SecretSettingState{Configured: definition.serverSecret(ctx, s, cfg)} } else if definition.secret != nil { setting.Secret = &SecretSettingState{Configured: definition.secret(cfg)} @@ -387,7 +658,15 @@ func (s *Server) buildSettingsResponse(ctx context.Context, cfg *config.Config, } settings = append(settings, setting) } - return SettingsResponse{Settings: settings, PendingRestart: pendingRestart} + providers, err := personEnrichmentProviderSettings(cfg, credentials) + if err != nil { + return SettingsResponse{}, err + } + return SettingsResponse{ + Groups: append([]SettingGroup(nil), settingsGroups...), Settings: settings, + PersonEnrichmentProviders: providers, CredentialETag: credentials.ETag, + PendingRestart: pendingRestart, + }, nil } // credentialBinding ties an endpoint setting to the credential that gets sent @@ -409,20 +688,77 @@ var credentialBindings = []credentialBinding{ currentEndpoint: func(c *config.Config) string { return c.Integrations.Tasks.Endpoint }, credentialSet: func(c *config.Config) bool { return c.Integrations.Tasks.APIKey != "" }, }, +} + +// storedCredentialBinding ties an endpoint setting to the write-only provider +// credential bound to its origin in the credential store. When a PATCH moves +// the endpoint to a different origin, the stored credential is deleted in the +// same request so it can never be replayed to the new destination. +type storedCredentialBinding struct { + credentialID string + currentEndpoint func(*config.Config) string +} + +var storedCredentialBindings = []storedCredentialBinding{ { - endpointKey: "vector.embeddings.endpoint", - credentialKey: "vector.embeddings.api_key_env", + credentialID: providercredentials.VectorEmbeddingsID, currentEndpoint: func(c *config.Config) string { return c.Vector.Embeddings.Endpoint }, - credentialSet: func(c *config.Config) bool { return c.Vector.Embeddings.APIKeyEnv != "" }, }, { - endpointKey: "vector.multimodal.endpoint", - credentialKey: "vector.multimodal.api_key_env", + credentialID: providercredentials.VectorMultimodalID, currentEndpoint: func(c *config.Config) string { return c.Vector.Multimodal.Endpoint }, - credentialSet: func(c *config.Config) bool { return c.Vector.Multimodal.APIKeyEnv != "" }, }, } +// staleStoredCredentialIDs lists the stored provider credentials that are +// bound to an origin other than the committed endpoint. Deciding from the +// committed state rather than from this request's edits makes severing +// idempotent: a credential left behind by an interrupted earlier cleanup, or +// by a host edit of config.toml, is removed by the next settings write. +func staleStoredCredentialIDs(committed *config.Config, credentials providercredentials.Snapshot) []string { + var ids []string + for _, binding := range storedCredentialBindings { + if storedCredentialOriginMismatch(credentials, binding.credentialID, binding.currentEndpoint(committed)) { + ids = append(ids, binding.credentialID) + } + } + for _, id := range credentials.StoredPersonEnrichmentIDs() { + binding, ok := providerCredentialBindingForID(committed, id) + if !ok || storedCredentialOriginMismatch(credentials, id, binding.endpoint) { + ids = append(ids, id) + } + } + return ids +} + +// storedCredentialOriginMismatch reports whether a stored credential exists +// for id but is bound to an origin other than endpoint's. +func storedCredentialOriginMismatch(credentials providercredentials.Snapshot, id, endpoint string) bool { + if !credentials.Stored(id) { + return false + } + _, _, err := credentials.Resolve(id, endpoint, "", nil) + return errors.Is(err, providercredentials.ErrOriginMismatch) +} + +// deleteStoredCredentials removes each stored credential from the store, +// threading the store ETag through successive writes. +func deleteStoredCredentials( + tokensDir string, credentials providercredentials.Snapshot, ids []string, +) (providercredentials.Snapshot, error) { + for _, id := range ids { + if !credentials.Stored(id) { + continue + } + next, err := providercredentials.Delete(tokensDir, credentials.ETag, id) + if err != nil { + return credentials, err + } + credentials = next + } + return credentials, nil +} + // endpointOrigin reduces an endpoint to the destination that would receive // credentials: scheme plus host for URLs with a host, the socket or opaque // path otherwise, and the trimmed raw value when it is not a URL. Values that @@ -474,16 +810,17 @@ func settingsEdits(current *config.Config, updates []SettingUpdate) ([]config.Ed definitions := settingsDefinitionByKey() seen := make(map[string]struct{}, len(updates)) edits := make([]config.Edit, 0, len(updates)) - changesAPIKey := false + restartRequired := false for _, update := range updates { definition, ok := definitions[update.Key] if !ok { return nil, false, fmt.Errorf("setting %q is not browser-managed", update.Key) } if definition.localOnly { - return nil, false, fmt.Errorf( - "setting %q names an environment variable on the machine running msgvault; edit config.toml on that machine to change it", - update.Key) + return nil, false, fmt.Errorf("setting %q is host-managed and cannot be changed through remote Settings", update.Key) + } + if definition.credentialID != "" { + return nil, false, fmt.Errorf("setting %q must use the provider credential endpoint", update.Key) } if _, duplicate := seen[update.Key]; duplicate { return nil, false, fmt.Errorf("setting %q is updated more than once", update.Key) @@ -518,13 +855,76 @@ func settingsEdits(current *config.Config, updates []SettingUpdate) ([]config.Ed } value = converted } - if update.Key == "server.api_key" { - changesAPIKey = true + if err := validateSettingUpdate(update.Key, value, definition.options); err != nil { + return nil, false, fmt.Errorf("%w: %s", errInvalidSettingUpdate, update.Key) } edits = append(edits, config.Edit{Key: update.Key, Value: value}) + restartRequired = restartRequired || definition.restartRequired } edits = append(edits, credentialSeveranceEdits(current, edits)...) - return edits, changesAPIKey, nil + return edits, restartRequired, nil +} + +func validateSettingUpdate(key string, value any, options []string) error { + if len(options) > 0 { + text, ok := value.(string) + if !ok { + return errors.New("option must be a string") + } + if !slices.Contains(options, text) { + return errors.New("unsupported option") + } + } + if err := validateSettingBounds(key, value); err != nil { + return err + } + switch key { + case "sync.rate_limit_qps": + integer, ok := value.(int) + if !ok || integer <= 0 { + return errors.New("must be positive") + } + case "log.sql_slow_ms", "beeper.media_max_participants", "beeper.max_media_mb", + "slack.media_max_participants", "slack.max_media_mb", "discord.media_max_participants", + "discord.max_media_mb", "teams.media_max_participants", "teams.max_media_mb", + "vector.search.max_page_size_hybrid": + integer, ok := value.(int) + if !ok || integer < 0 { + return errors.New("must be non-negative") + } + case "beeper.rate_limit_qps": + number, ok := value.(float64) + if !ok || number < 0 { + return errors.New("must be non-negative") + } + case "vector.embeddings.endpoint", "vector.multimodal.endpoint": + endpoint, ok := value.(string) + if !ok { + return errors.New("endpoint must be a string") + } + if _, err := providercredentials.EndpointOrigin(endpoint); err != nil { + return err + } + case "vector.embeddings.timeout", "server.daemon_idle_timeout", "analytics.min_rebuild_interval", + "people.enrichment.lease_duration": + text, ok := value.(string) + if !ok { + return errors.New("duration must be a string") + } + duration, err := time.ParseDuration(text) + if err != nil || duration < 0 || (key != "server.daemon_idle_timeout" && key != "analytics.min_rebuild_interval" && duration == 0) { + return errors.New("invalid duration") + } + case "vector.embed.backstop_interval": + text, ok := value.(string) + if !ok { + return errors.New("duration must be a string") + } + if _, err := time.ParseDuration(text); err != nil { + return errors.New("invalid duration") + } + } + return nil } func settingValue(kind string, value any) *SettingValue { diff --git a/internal/api/settings_credentials.go b/internal/api/settings_credentials.go new file mode 100644 index 000000000..09fd4e8c6 --- /dev/null +++ b/internal/api/settings_credentials.go @@ -0,0 +1,229 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/danielgtaylor/huma/v2" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/providercredentials" +) + +const settingsProviderCredentialRoute = "/settings/provider-credentials/{credential_id}" // #nosec G101 -- HTTP route, not a credential. + +type ProviderCredentialWriteRequest struct { + Value string `json:"value" minLength:"1"` +} + +type ProviderCredentialResponse struct { + CredentialID string `json:"credential_id"` + State SecretSettingState `json:"state"` + PendingRestart bool `json:"pending_restart"` +} + +type providerCredentialBinding struct { + id string + endpoint string + environment string +} + +func validateSafePublicSettingsEndpoints(cfg *config.Config) error { + endpoints := []string{ + cfg.Vector.Embeddings.Endpoint, + cfg.Vector.Multimodal.Endpoint, + } + for _, provider := range cfg.People.Enrichment.Providers { + if !provider.Enabled { + continue + } + endpoints = append(endpoints, provider.Endpoint, provider.PollEndpoint) + } + for _, endpoint := range endpoints { + if strings.TrimSpace(endpoint) == "" { + continue + } + if _, err := providercredentials.EndpointOrigin(endpoint); err != nil { + return fmt.Errorf("unsafe public provider endpoint: %w", err) + } + } + return nil +} + +func (s *Server) registerProviderCredentialSettingsRoutes(api huma.API) { + for _, method := range []string{http.MethodPut, http.MethodDelete} { + operationID := "putSettingsProviderCredential" + summary := "Set a write-only provider credential" + if method == http.MethodDelete { + operationID = "deleteSettingsProviderCredential" + summary = "Clear a stored provider credential" + } + operation := rawAPIV1Operation(operationID, method, settingsProviderCredentialRoute, summary) + operation.Parameters = append(operation.Parameters, + &huma.Param{Name: "credential_id", In: "path", Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Strong ETag for the provider credential store", Schema: &huma.Schema{Type: huma.TypeString}}, + ) + if method == http.MethodPut { + operation.RequestBody = jsonRequestBodyFor[ProviderCredentialWriteRequest](api) + } + operation.Responses = jsonResponsesFor[ProviderCredentialResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusNotFound, + http.StatusPreconditionFailed, http.StatusPreconditionRequired, http.StatusUnprocessableEntity} { + operation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + addSettingsETagHeader(operation.Responses[httpStatusKey(http.StatusOK)]) + if method == http.MethodPut { + registerRawHumaRoute(api, operation, s.handlePutProviderCredential) + } else { + registerRawHumaRoute(api, operation, s.handleDeleteProviderCredential) + } + } +} + +func (s *Server) handlePutProviderCredential(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + credentialID := strings.TrimSpace(r.PathValue("credential_id")) + if err := providercredentials.ValidateID(credentialID); err != nil { + writeError(w, http.StatusBadRequest, "invalid_credential_id", "Provider credential ID is invalid") + return + } + var request ProviderCredentialWriteRequest + if !decodeStrictSettingsJSON(w, r, &request) { + return + } + if request.Value == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Credential value is required") + return + } + _, cfg, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + binding, ok := providerCredentialBindingForID(cfg, credentialID) + if !ok { + writeError(w, http.StatusNotFound, "provider_not_found", "Configured provider was not found") + return + } + snapshot, err := providercredentials.Put(cfg.TokensDir(), ifMatch, credentialID, binding.endpoint, request.Value) + if err != nil { + writeProviderCredentialError(w, err) + return + } + pendingRestart := providerCredentialRestartRequired(credentialID) + if pendingRestart { + s.settingsPendingRestart.Store(true) + } + writeProviderCredentialResponse(w, snapshot.ETag, credentialID, + providercredentials.State{Configured: true, Source: providercredentials.SourceStored}, pendingRestart) +} + +func (s *Server) handleDeleteProviderCredential(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + credentialID := strings.TrimSpace(r.PathValue("credential_id")) + if err := providercredentials.ValidateID(credentialID); err != nil { + writeError(w, http.StatusBadRequest, "invalid_credential_id", "Provider credential ID is invalid") + return + } + _, cfg, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + current, err := providercredentials.Read(cfg.TokensDir()) + if err != nil { + writeProviderCredentialError(w, err) + return + } + if !current.Stored(credentialID) { + writeError(w, http.StatusNotFound, "credential_not_found", "No stored provider credential was found") + return + } + snapshot, err := providercredentials.Delete(cfg.TokensDir(), ifMatch, credentialID) + if err != nil { + writeProviderCredentialError(w, err) + return + } + // A credential may outlive the provider that config.toml referenced when + // it was stored; deleting it must not depend on that provider still being + // configured. The reported state falls back to the environment only when + // the provider is still known. + state := providercredentials.State{Configured: false, Source: providercredentials.SourceNone} + if binding, ok := providerCredentialBindingForID(cfg, credentialID); ok { + _, state, err = snapshot.Resolve(credentialID, binding.endpoint, binding.environment, osLookupEnv) + if err != nil { + writeProviderCredentialError(w, err) + return + } + } + pendingRestart := providerCredentialRestartRequired(credentialID) + if pendingRestart { + s.settingsPendingRestart.Store(true) + } + writeProviderCredentialResponse(w, snapshot.ETag, credentialID, state, pendingRestart) +} + +func providerCredentialRestartRequired(id string) bool { + return id == providercredentials.VectorEmbeddingsID || id == providercredentials.VectorMultimodalID +} + +func providerCredentialBindingForID(cfg *config.Config, id string) (providerCredentialBinding, bool) { + switch id { + case providercredentials.VectorEmbeddingsID: + return providerCredentialBinding{id: id, endpoint: cfg.Vector.Embeddings.Endpoint, + environment: cfg.Vector.Embeddings.APIKeyEnv}, true + case providercredentials.VectorMultimodalID: + return providerCredentialBinding{id: id, endpoint: cfg.Vector.Multimodal.Endpoint, + environment: cfg.Vector.Multimodal.APIKeyEnv}, true + } + if !strings.HasPrefix(id, "people.enrichment/") { + return providerCredentialBinding{}, false + } + name := strings.TrimPrefix(id, "people.enrichment/") + for _, provider := range cfg.People.Enrichment.Providers { + if provider.Name == name { + endpoint, err := provider.CredentialEndpoint() + if err != nil { + return providerCredentialBinding{}, false + } + return providerCredentialBinding{id: id, endpoint: endpoint, environment: provider.APIKeyEnv}, true + } + } + return providerCredentialBinding{}, false +} + +func writeProviderCredentialError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, providercredentials.ErrConflict): + writeError(w, http.StatusPreconditionFailed, "credential_conflict", "Provider credentials changed; reload settings and retry") + case errors.Is(err, providercredentials.ErrOriginMismatch): + writeError(w, http.StatusConflict, "credential_origin_mismatch", "Stored credential is bound to a different endpoint") + case errors.Is(err, providercredentials.ErrUnavailable): + writeError(w, http.StatusInternalServerError, "credential_store_unavailable", "Provider credential store is unavailable") + default: + writeError(w, http.StatusUnprocessableEntity, "validation_failed", "Provider credential settings are invalid") + } +} + +func writeProviderCredentialResponse( + w http.ResponseWriter, + etag, id string, + state providercredentials.State, + pendingRestart bool, +) { + w.Header().Set(etagHeaderName, etag) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, ProviderCredentialResponse{ + CredentialID: id, + State: SecretSettingState{Configured: state.Configured, Source: string(state.Source)}, + PendingRestart: pendingRestart, + }) +} diff --git a/internal/api/settings_enrichment.go b/internal/api/settings_enrichment.go new file mode 100644 index 000000000..aab43a331 --- /dev/null +++ b/internal/api/settings_enrichment.go @@ -0,0 +1,329 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/personenrichment" + "go.kenn.io/msgvault/internal/providercredentials" +) + +const settingsPersonEnrichmentProviderRoute = "/settings/person-enrichment/providers/{name}" + +// PersonEnrichmentProviderSetting is the browser/TUI-safe provider policy. +// APIKeyEnv and hard monetary caps deliberately remain host-only. +type PersonEnrichmentProviderSetting struct { + Name string `json:"name"` + Kind string `json:"kind" enum:"exa,sixtyfour"` + Enabled bool `json:"enabled"` + Endpoint string `json:"endpoint"` + PollEndpoint string `json:"poll_endpoint,omitempty"` + Mode string `json:"mode,omitempty"` + Tier string `json:"tier,omitempty"` + NumResults int `json:"num_results,omitempty"` + AllowedIdentifiers []string `json:"allowed_identifiers"` + TargetKeys []string `json:"target_keys"` + AllowSensitiveTargets bool `json:"allow_sensitive_targets"` + RetentionPosture string `json:"retention_posture"` + TrainingPosture string `json:"training_posture"` + RefreshInterval string `json:"refresh_interval"` + RequestTimeout string `json:"request_timeout"` + PollInterval string `json:"poll_interval"` + MaxJobAge string `json:"max_job_age"` + MaxRetries int `json:"max_retries"` + MaxRequestsPerRun int64 `json:"max_requests_per_run"` + MaxRequestsPerDay int64 `json:"max_requests_per_day"` + Credential *SecretSettingState `json:"credential,omitempty"` + CredentialID string `json:"credential_id"` +} + +type PersonEnrichmentProviderUpdate struct { + Kind string `json:"kind" enum:"exa,sixtyfour"` + Enabled bool `json:"enabled"` + Endpoint string `json:"endpoint"` + PollEndpoint string `json:"poll_endpoint,omitempty"` + Mode string `json:"mode,omitempty"` + Tier string `json:"tier,omitempty"` + NumResults *int `json:"num_results,omitempty"` + AllowedIdentifiers []string `json:"allowed_identifiers"` + TargetKeys []string `json:"target_keys"` + AllowSensitiveTargets bool `json:"allow_sensitive_targets"` + RetentionPosture string `json:"retention_posture"` + TrainingPosture string `json:"training_posture"` + RefreshInterval string `json:"refresh_interval"` + RequestTimeout string `json:"request_timeout"` + PollInterval string `json:"poll_interval,omitempty"` + MaxJobAge string `json:"max_job_age,omitempty"` + MaxRetries int `json:"max_retries"` + MaxRequestsPerRun int64 `json:"max_requests_per_run"` + MaxRequestsPerDay int64 `json:"max_requests_per_day"` +} + +func (s *Server) registerPersonEnrichmentSettingsRoute(api huma.API) { + operation := rawAPIV1Operation("putSettingsPersonEnrichmentProvider", http.MethodPut, + settingsPersonEnrichmentProviderRoute, "Create or update one named person-enrichment provider") + operation.Parameters = append(operation.Parameters, + &huma.Param{Name: "name", In: "path", Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Strong config ETag returned by the latest settings read", Schema: &huma.Schema{Type: huma.TypeString}}, + ) + operation.RequestBody = jsonRequestBodyFor[PersonEnrichmentProviderUpdate](api) + operation.Responses = jsonResponsesFor[SettingsResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict, + http.StatusPreconditionFailed, http.StatusPreconditionRequired, http.StatusUnprocessableEntity} { + operation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + addSettingsETagHeader(operation.Responses[httpStatusKey(http.StatusOK)]) + registerRawHumaRoute(api, operation, s.handlePutPersonEnrichmentProviderSetting) +} + +func (s *Server) handlePutPersonEnrichmentProviderSetting(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + name := strings.TrimSpace(r.PathValue("name")) + if err := providercredentials.ValidateID(providercredentials.PersonEnrichmentID(name)); err != nil { + writeError(w, http.StatusBadRequest, "invalid_provider_name", "Provider name is invalid") + return + } + var request PersonEnrichmentProviderUpdate + if !decodeStrictSettingsJSON(w, r, &request) { + return + } + snapshot, current, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + providers := append([]personenrichment.ProviderConfig(nil), current.People.Enrichment.Providers...) + index := -1 + for providerIndex := range providers { + if providers[providerIndex].Name == name { + index = providerIndex + break + } + } + var base personenrichment.ProviderConfig + if index >= 0 { + base = providers[index] + if request.Kind != base.Kind { + writeError(w, http.StatusConflict, "provider_kind_conflict", "Provider kind cannot change for an existing name") + return + } + } else { + base = personenrichment.ProviderConfig{Name: name, Kind: request.Kind} + base.ApplyDefaults() + } + updated, err := applyPersonEnrichmentProviderUpdate(base, request) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "validation_failed", "Person-enrichment provider settings are invalid") + return + } + if ifMatch != snapshot.ETag { + writeError(w, http.StatusPreconditionFailed, "settings_conflict", "The config file changed; reload settings and retry") + return + } + written, err := config.EditPersonEnrichmentProvider(s.cfg.ConfigFilePath(), ifMatch, name, updated) + if err != nil { + s.writeSettingsConfigError(w, err) + return + } + loaded, err := config.LoadConfigFile(written, "") + if err != nil { + s.settingsPendingRestart.Store(true) + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + credentials, err := providercredentials.Read(loaded.TokensDir()) + if err != nil { + s.settingsPendingRestart.Store(true) + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + // A stored credential is bound to the endpoint origin it was saved for. + // Moving the provider elsewhere severs it in the same request, and a + // credential left behind by an earlier interrupted cleanup or by a + // removed provider with the same name is severed here as well. + credentialID := providercredentials.PersonEnrichmentID(name) + if storedCredentialOriginMismatch(credentials, credentialID, updated.Endpoint) { + deleter := s.settingsCredentialDeleter + if deleter == nil { + deleter = deleteStoredCredentials + } + credentials, err = deleter(loaded.TokensDir(), credentials, []string{credentialID}) + if err != nil { + if _, rollbackErr := config.RestoreConfigFile(s.cfg.ConfigFilePath(), written, snapshot); rollbackErr != nil { + s.settingsPendingRestart.Store(true) + s.writeSettingsConfigError(w, errors.Join(config.ErrConfigChanged, err, rollbackErr)) + return + } + writeProviderCredentialError(w, err) + return + } + } + s.settingsPendingRestart.Store(true) + response, err := s.buildSettingsResponse(r.Context(), loaded, credentials, true) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + w.Header().Set(etagHeaderName, written.ETag) + w.Header().Set(settingsCredentialETagHeader, credentials.ETag) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} + +func applyPersonEnrichmentProviderUpdate( + provider personenrichment.ProviderConfig, + request PersonEnrichmentProviderUpdate, +) (personenrichment.ProviderConfig, error) { + // Kind is immutable once a name exists, so it must be valid even for a + // disabled provider; otherwise the name could never be enabled. + if request.Kind != personenrichment.ProviderExa && request.Kind != personenrichment.ProviderSixtyfour { + return personenrichment.ProviderConfig{}, fmt.Errorf( + "kind must be %q or %q", personenrichment.ProviderExa, personenrichment.ProviderSixtyfour) + } + provider.Kind = request.Kind + provider.Enabled = request.Enabled + provider.Endpoint = strings.TrimSpace(request.Endpoint) + provider.PollEndpoint = strings.TrimSpace(request.PollEndpoint) + provider.Mode = strings.TrimSpace(request.Mode) + provider.Tier = strings.TrimSpace(request.Tier) + if request.NumResults != nil { + provider.NumResults = *request.NumResults + } + provider.AllowedIdentifiers = make([]personenrichment.IdentifierClass, len(request.AllowedIdentifiers)) + for index, identifier := range request.AllowedIdentifiers { + provider.AllowedIdentifiers[index] = personenrichment.IdentifierClass(identifier) + } + provider.TargetKeys = append([]string(nil), request.TargetKeys...) + provider.AllowSensitiveTargets = request.AllowSensitiveTargets + provider.RetentionPosture = request.RetentionPosture + provider.TrainingPosture = request.TrainingPosture + var err error + if provider.RefreshInterval, err = time.ParseDuration(request.RefreshInterval); err != nil { + return personenrichment.ProviderConfig{}, fmt.Errorf("parse refresh interval: %w", err) + } + if provider.RequestTimeout, err = time.ParseDuration(request.RequestTimeout); err != nil { + return personenrichment.ProviderConfig{}, fmt.Errorf("parse request timeout: %w", err) + } + if request.PollInterval != "" { + if provider.PollInterval, err = time.ParseDuration(request.PollInterval); err != nil { + return personenrichment.ProviderConfig{}, fmt.Errorf("parse poll interval: %w", err) + } + } + if request.MaxJobAge != "" { + if provider.MaxJobAge, err = time.ParseDuration(request.MaxJobAge); err != nil { + return personenrichment.ProviderConfig{}, fmt.Errorf("parse maximum job age: %w", err) + } + } + provider.MaxRetries = request.MaxRetries + provider.MaxRequestsPerRun = request.MaxRequestsPerRun + provider.MaxRequestsPerDay = request.MaxRequestsPerDay + if provider.Endpoint != "" { + if _, err := providercredentials.EndpointOrigin(provider.Endpoint); err != nil { + return personenrichment.ProviderConfig{}, err + } + } + if provider.PollEndpoint != "" { + if _, err := providercredentials.EndpointOrigin(provider.PollEndpoint); err != nil { + return personenrichment.ProviderConfig{}, err + } + } + if _, err := provider.CredentialEndpoint(); err != nil { + return personenrichment.ProviderConfig{}, err + } + if err := provider.Validate(); err != nil { + return personenrichment.ProviderConfig{}, err + } + return provider, nil +} + +func personEnrichmentProviderSettings( + cfg *config.Config, + credentials providercredentials.Snapshot, +) ([]PersonEnrichmentProviderSetting, error) { + result := make([]PersonEnrichmentProviderSetting, 0, len(cfg.People.Enrichment.Providers)) + for _, provider := range cfg.People.Enrichment.Providers { + identifiers := make([]string, len(provider.AllowedIdentifiers)) + for index, identifier := range provider.AllowedIdentifiers { + identifiers[index] = string(identifier) + } + credentialID := providercredentials.PersonEnrichmentID(provider.Name) + var credentialState *SecretSettingState + credentialEndpoint, err := provider.CredentialEndpoint() + if err != nil { + if provider.Enabled { + return nil, err + } + } else { + _, state, resolveErr := credentials.Resolve(credentialID, credentialEndpoint, provider.APIKeyEnv, osLookupEnv) + if errors.Is(resolveErr, providercredentials.ErrOriginMismatch) { + state = providercredentials.State{Configured: false, Source: providercredentials.SourceNone} + } else if resolveErr != nil { + return nil, resolveErr + } + credentialState = &SecretSettingState{Configured: state.Configured, Source: string(state.Source)} + } + result = append(result, PersonEnrichmentProviderSetting{ + Name: provider.Name, Kind: provider.Kind, Enabled: provider.Enabled, + Endpoint: safePublicProviderEndpoint(provider.Endpoint), + PollEndpoint: safePublicProviderEndpoint(provider.PollEndpoint), Mode: provider.Mode, + Tier: provider.Tier, NumResults: provider.NumResults, AllowedIdentifiers: identifiers, + TargetKeys: append([]string(nil), provider.TargetKeys...), + AllowSensitiveTargets: provider.AllowSensitiveTargets, + RetentionPosture: provider.RetentionPosture, TrainingPosture: provider.TrainingPosture, + RefreshInterval: provider.RefreshInterval.String(), RequestTimeout: provider.RequestTimeout.String(), + PollInterval: provider.PollInterval.String(), MaxJobAge: provider.MaxJobAge.String(), + MaxRetries: provider.MaxRetries, MaxRequestsPerRun: provider.MaxRequestsPerRun, + MaxRequestsPerDay: provider.MaxRequestsPerDay, + Credential: credentialState, + CredentialID: credentialID, + }) + } + return result, nil +} + +func safePublicProviderEndpoint(endpoint string) string { + if strings.TrimSpace(endpoint) == "" { + return "" + } + if _, err := providercredentials.EndpointOrigin(endpoint); err != nil { + return "" + } + return endpoint +} + +func decodeStrictSettingsJSON(w http.ResponseWriter, r *http.Request, target any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid settings request") + return false + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid settings request") + return false + } + return true +} + +func requiredSingleIfMatch(w http.ResponseWriter, r *http.Request) (string, bool) { + values := r.Header.Values(ifMatchHeaderName) + if len(values) != 1 || strings.TrimSpace(values[0]) == "" { + writeError(w, http.StatusPreconditionRequired, "if_match_required", "If-Match is required") + return "", false + } + return values[0], true +} + +func osLookupEnv(name string) (string, bool) { return os.LookupEnv(name) } diff --git a/internal/api/settings_metadata.go b/internal/api/settings_metadata.go new file mode 100644 index 000000000..bea1b1a5e --- /dev/null +++ b/internal/api/settings_metadata.go @@ -0,0 +1,258 @@ +package api + +import ( + "errors" + "strings" +) + +type settingMetadata struct { + label string + description string +} + +var settingsGroups = []SettingGroup{ + {ID: "browser", Label: "Web appearance", Description: "Browser preferences applied without restarting the daemon."}, + {ID: "server", Label: "Daemon", Description: "Daemon lifecycle settings. Listener and authentication bootstrap values are host-managed and read-only."}, + {ID: "archive", Label: "Analytics", Description: "Analytics engine and bounded cache-builder resources."}, + {ID: "sync", Label: "Sync", Description: "Shared source synchronization limits."}, + {ID: "logging", Label: "Logging", Description: "Persistent diagnostics. SQL tracing can produce high-volume logs with statement metadata."}, + {ID: "search", Label: "Search and embeddings", Description: "Vector search, text embeddings, and visual Voyage embeddings."}, + {ID: settingsGroupSources, Label: "Sources", Description: "Safe schedules and filters for configured archive sources."}, + {ID: settingsGroupAttachments, Label: "Attachment downloads", Description: "Controls future attachment downloads only. Changes do not fetch, remove, or re-evaluate existing files."}, + {ID: "activity", Label: "Activity", Description: "Dated activity projection schedule and bounded batch settings."}, + {ID: "backup", Label: "Backups", Description: "Portable backup compression settings."}, + {ID: settingsGroupEnrichment, Label: "Person enrichment", Description: "Global orchestration and independently consented providers. Provider policies are keyed by stable name."}, + {ID: "integrations", Label: "Integrations", Description: "Optional outbound integrations."}, +} + +var settingsMetadata = map[string]settingMetadata{ + "web.default_search_mode": {"Default search mode", "Initial search mode used by the Web interface."}, + "web.theme": {"Theme", "Web color theme. Applied without a daemon restart."}, + "web.density": {"Density", "Web layout density. Applied without a daemon restart."}, + "server.bind_addr": {"Bind address", "Host-managed listener address; remote settings clients cannot change it."}, + "server.api_port": {"API port", "Host-managed listener port; 0 asks the daemon to select a port."}, + "server.api_key": {"API key", "Host-managed authentication bootstrap secret. Its value is never returned."}, + "server.allow_insecure": {"Allow insecure access", "Host-managed authentication boundary; remote settings clients cannot change it."}, + "server.trusted_proxies": {"Trusted proxies", "Host-managed proxy trust boundary; remote settings clients cannot change it."}, + "server.daemon_idle_timeout": {"Idle timeout", "How long an idle background daemon waits before stopping; 0 disables the timeout."}, + "server.daemon_auto_restart": {"Automatic restart", "Policy used when a compatible daemon executable changes."}, + "analytics.engine": {"Analytics engine", "Engine used for aggregate analytics queries; auto selects the best available engine."}, + "analytics.auto_build_cache": {"Build stale analytics cache", "Build the analytics cache automatically when a query finds it stale."}, + "analytics.min_rebuild_interval": {"Minimum cache rebuild interval", "Minimum time between automatic analytics cache rebuilds."}, + "analytics.builder_memory_limit": {"Cache-builder memory limit", "Optional memory limit applied while building the analytics cache."}, + "analytics.builder_threads": {"Cache-builder threads", "Maximum analytics cache-builder threads; 0 uses the engine default."}, + "analytics.builder_temp_limit": {"Cache-builder temporary storage limit", "Optional temporary-storage limit applied while building the analytics cache."}, + "sync.rate_limit_qps": {"Sync requests per second", "Positive shared request-rate limit used by source synchronization."}, + "log.enabled": {"Persistent logs", "Write structured logs to the daemon log directory."}, + "log.level": {"Log level", "Minimum persistent log severity; an empty value uses the built-in default."}, + "log.sql_slow_ms": {"Slow SQL threshold", "Warn when a SQL statement exceeds this many milliseconds; 0 uses the built-in default."}, + "log.sql_trace": {"Trace every SQL statement", "High-volume diagnostic mode that logs every SQL statement. Enable only while debugging."}, + "vector.enabled": {"Vector search master switch", "Master gate for text semantic indexing and search. Visual embeddings have an additional lane gate."}, + "vector.backend": {"Vector backend", "Host-managed storage backend; changing it requires local migration decisions."}, + "vector.db_path": {"Vector database path", "Host-managed filesystem/database location."}, + "vector.skip_extension_create": {"Skip extension creation", "Host-managed database privilege setting."}, + "vector.embeddings.api_format": {"Text embedding API format", "Request and response format used by the configured text embedding provider."}, + "vector.embeddings.endpoint": {"Text embedding endpoint", "API root used for text embedding requests. Save endpoint changes before storing a credential."}, + "vector.embeddings.api_key_env": {"Text embedding credential environment variable", "Host-managed environment variable used when no stored text embedding credential exists."}, + "vector.embeddings.api_key": {"Text embedding API key", "Write-only credential for the text embedding endpoint. Stored credentials override the configured environment variable."}, + "vector.embeddings.model": {"Text embedding model", "Provider model identifier included in the embedding generation fingerprint."}, + "vector.embeddings.document_prefix": {"Document embedding prefix", "Optional provider-specific prefix prepended to text when indexing documents."}, + "vector.embeddings.query_prefix": {"Query embedding prefix", "Optional provider-specific prefix prepended to text when embedding search queries."}, + "vector.embeddings.dimension": {"Text embedding dimension", "Vector dimension returned by the configured text embedding model."}, + "vector.embeddings.batch_size": {"Text embedding batch size", "Maximum number of inputs sent in one text embedding request."}, + "vector.embeddings.timeout": {"Text embedding timeout", "Maximum duration allowed for one text embedding provider request."}, + "vector.embeddings.max_retries": {"Text embedding retries", "Maximum transient request retries; 0 uses the built-in default."}, + "vector.embeddings.max_input_chars": {"Maximum text embedding input", "Maximum characters sent for one text embedding input."}, + "vector.embeddings.eta_window": {"Text embedding ETA window", "Recent progress samples used to estimate completion time."}, + "vector.people.enabled": {"Embed curated person fields", "Allow the explicitly consented person fields to use the text embedding provider when the vector master gate is enabled."}, + "vector.people.retention_posture": {"Person embedding retention posture", "Operator assertion describing provider retention for curated person embeddings."}, + "vector.people.training_posture": {"Person embedding training posture", "Operator assertion describing provider training use for curated person embeddings."}, + "vector.embed.schedule.cron": {"Text embedding schedule", "Five-field cron schedule for background text embedding; empty disables the schedule."}, + "vector.embed.schedule.run_after_sync": {"Embed text after sync", "Run text embedding after a successful source synchronization."}, + "vector.embed.scope.message_types": {"Text embedding message types", "Optional message-type allowlist for text embedding; empty uses all supported types."}, + "vector.embed.scope.accounts": {"Text embedding accounts", "Optional account-ID allowlist for text embedding; empty uses all accounts."}, + "vector.embed.backstop_interval": {"Text embedding backstop interval", "Maximum interval between background embedding checks when no schedule or sync trigger runs."}, + "vector.multimodal.enabled": {"Visual embedding lane", "Additional gate for hosted visual attachment indexing; the vector master gate must also be enabled."}, + "vector.multimodal.provider": {"Visual embedding provider", "Hosted provider used for visual attachment embeddings."}, + "vector.multimodal.endpoint": {"Visual embedding endpoint", "Pinned API root used for visual embedding requests. Save endpoint changes before storing a credential."}, + "vector.multimodal.api_key_env": {"Visual embedding credential environment variable", "Host-managed environment variable used when no stored visual embedding credential exists."}, + "vector.multimodal.api_key": {"Voyage API key", "Write-only credential for visual Voyage embeddings. Stored credentials override the configured environment variable."}, + "vector.multimodal.capabilities_file": {"Visual capability manifest", "Host-managed path to the locally probed provider capability manifest."}, + "vector.multimodal.model": {"Visual embedding model", "Provider model identifier included in the visual embedding generation fingerprint."}, + "vector.multimodal.dimension": {"Visual embedding dimension", "Vector dimension required by the pinned visual embedding model."}, + "vector.multimodal.max_context_chars": {"Maximum visual context", "Maximum normalized owning-message characters sent with one visual attachment."}, + "vector.multimodal.include_images": {"Index still images", "Allow supported still-image attachments to be sent to the visual embedding provider."}, + "vector.multimodal.include_animated_gifs": {"Index animated GIFs", "Allow animated GIF attachments only when the provider capability manifest permits them."}, + "vector.multimodal.include_video": {"Index videos", "Allow bounded supported video attachments to be sent to the visual embedding provider."}, + "vector.multimodal.allow_image_queries": {"Allow image queries", "Allow bounded query images to be sent to the visual embedding provider."}, + "vector.multimodal.scope.message_types": {"Visual embedding message types", "Optional message-type allowlist for visual attachment indexing."}, + "vector.multimodal.schedule.cron": {"Visual embedding schedule", "Five-field cron schedule for background visual indexing; empty disables the schedule."}, + "vector.multimodal.schedule.run_after_sync": {"Embed visuals after sync", "Run visual attachment indexing after a successful source synchronization."}, + "vector.search.rrf_k": {"Hybrid RRF constant", "Reciprocal-rank-fusion constant used to combine hybrid search signals."}, + "vector.search.k_per_signal": {"Hybrid candidates per signal", "Candidate count retained from each hybrid search signal."}, + "vector.search.subject_boost": {"Subject match boost", "Non-negative hybrid-ranking weight applied to subject matches."}, + "vector.search.max_page_size_hybrid": {"Maximum hybrid page size", "Maximum page size accepted for hybrid search; 0 disables this clamp."}, + "vector.preprocess.strip_quotes": {"Strip quoted replies", "Remove quoted reply blocks before text embedding when enabled."}, + "vector.preprocess.strip_signatures": {"Strip signatures", "Remove detected message signatures before text embedding when enabled."}, + "vector.preprocess.strip_html": {"Strip HTML", "Remove HTML markup before text embedding when enabled."}, + "vector.preprocess.strip_base64": {"Strip base64 payloads", "Remove embedded base64 payloads before text embedding when enabled."}, + "vector.preprocess.strip_url_tracking": {"Strip URL tracking parameters", "Remove common tracking parameters from URLs before text embedding when enabled."}, + "vector.preprocess.collapse_whitespace": {"Collapse whitespace", "Normalize repeated whitespace before text embedding when enabled."}, + "people.enrichment.enabled": {"Enable person enrichment", "Global gate. At least one named provider and a durable suppression key must be available before enablement."}, + "people.enrichment.schedule": {"Enrichment schedule", "Five-field cron schedule for person enrichment."}, + "people.enrichment.batch_size": {"Enrichment batch size", "Positive number of people leased per enrichment run."}, + "people.enrichment.lease_duration": {"Enrichment lease", "Positive duration for enrichment work leases."}, + "beeper.enabled": {"Scheduled Beeper sync", "Enable scheduled synchronization for configured Beeper accounts."}, + "beeper.schedule": {"Beeper sync schedule", "Five-field cron schedule for Beeper synchronization; empty disables the schedule."}, + "beeper.accounts": {"Included Beeper accounts", "Beeper account IDs to include; empty includes all accounts not explicitly excluded."}, + "beeper.exclude_accounts": {"Excluded Beeper accounts", "Beeper account IDs skipped during synchronization."}, + "beeper.rate_limit_qps": {"Beeper requests per second", "Non-negative request-rate limit; 0 uses the provider default."}, + "slack.enabled": {"Scheduled Slack sync", "Enable scheduled synchronization for configured Slack channels."}, + "slack.schedule": {"Slack sync schedule", "Five-field cron schedule for Slack synchronization; empty disables the schedule."}, + "slack.channels": {"Included Slack channels", "Channel names to include; direct messages are never filtered by this list."}, + "slack.exclude_channels": {"Excluded Slack channels", "Channel names to skip."}, + "beeper.media": {"Download Beeper attachments", "Provider default is enabled; changes affect future downloads only."}, + "slack.media": {"Download Slack files", "Provider default is enabled; changes affect future downloads only."}, + "discord.media": {"Download Discord attachments", "Provider default is enabled; changes affect future downloads only."}, + "teams.media": {"Download Teams attachments", "Provider default is enabled; changes affect future downloads only."}, + "beeper.media_scope": {"Beeper attachment scope", "Choose all conversations, direct conversations only, or none for future downloads."}, + "slack.media_scope": {"Slack attachment scope", "Choose all conversations, direct conversations only, or none for future downloads."}, + "discord.media_scope": {"Discord attachment scope", "Choose all conversations, direct conversations only, or none for future downloads."}, + "teams.media_scope": {"Teams attachment scope", "Choose all conversations, direct conversations only, or none for future downloads."}, + "beeper.media_max_participants": {"Beeper participant limit", "Skip future attachment downloads in conversations over this participant count; 0 means no participant limit."}, + "slack.media_max_participants": {"Slack participant limit", "Skip future attachment downloads in conversations over this participant count; 0 means no participant limit."}, + "discord.media_max_participants": {"Discord participant limit", "Skip future attachment downloads in conversations over this participant count; 0 means no participant limit."}, + "teams.media_max_participants": {"Teams participant limit", "Skip future attachment downloads in conversations over this participant count; 0 means no participant limit."}, + "beeper.max_media_mb": {"Beeper maximum attachment size", "Maximum future attachment size in MiB; 0 uses the Beeper default of 100 MiB."}, + "slack.max_media_mb": {"Slack maximum attachment size", "Maximum future attachment size in MiB; 0 uses the Slack default of 100 MiB."}, + "discord.max_media_mb": {"Discord maximum attachment size", "Maximum future attachment size in MiB; 0 uses the Discord default of 50 MiB."}, + "teams.max_media_mb": {"Teams maximum attachment size", "Maximum future attachment size in MiB; 0 uses the Teams default of 100 MiB."}, + "activity.timezone": {"Activity timezone", "IANA timezone used to group events into local calendar dates."}, + "activity.max_direct_counterparts": {"Maximum direct counterparts", "Bounded number of direct-message counterparts included in one activity projection pass."}, + "activity.batch_size": {"Activity batch size", "Bounded number of source records processed in one activity projection batch."}, + "activity.schedule": {"Activity projection schedule", "Five-field cron schedule for dated activity projection; empty disables the schedule."}, + "backup.zstd_level": {"Backup compression level", "Zstandard compression level for portable backups; 0 uses the encoder default."}, + "carddav.base_url": {"CardDAV base URL", "Current CardDAV server URL. Change it through the dedicated CardDAV account workflow."}, + "carddav.username": {"CardDAV username", "Current CardDAV account name. Change it through the dedicated CardDAV account workflow."}, + "carddav.schedule": {"CardDAV sync schedule", "Current CardDAV sync schedule. Change it through the dedicated CardDAV account workflow."}, + "carddav.enabled": {"CardDAV synchronization", "Current CardDAV synchronization state. Change it through the dedicated CardDAV account workflow."}, + "carddav.password": {"CardDAV password", "Write-only CardDAV credential managed through the dedicated CardDAV account workflow."}, + "integrations.tasks.enabled": {"Task integration", "Enable the configured provider-neutral outbound task integration."}, + "integrations.tasks.endpoint": {"Task integration endpoint", "HTTPS, loopback HTTP, owner-controlled Unix socket, or local discovery endpoint."}, + "integrations.tasks.api_key": {"Task integration API key", "Write-only bearer credential used only by the daemon for the task integration."}, + "integrations.tasks.default_project": {"Default task project", "Project used by default for task creation and lookup."}, +} + +var settingsValidation = map[string]SettingValidation{ + "server.api_port": numberValidation(0, new(float64(65_535)), "0 asks the daemon to select an available port."), + "server.daemon_idle_timeout": {Hint: "Go duration such as 30s, 15m, or 2h; 0 disables the idle timeout.", Required: true}, + "analytics.min_rebuild_interval": {Hint: "Go duration such as 15m or 2h; 0 allows immediate rebuilds.", Required: true}, + "analytics.builder_memory_limit": {Hint: "Optional positive size such as 512MiB or 2GB."}, + "analytics.builder_threads": numberValidation(0, nil, "0 uses the analytics engine default."), + "analytics.builder_temp_limit": {Hint: "Optional positive size such as 1GiB or 10GB."}, + "sync.rate_limit_qps": numberValidation(1, nil, "Positive requests-per-second limit."), + "log.sql_slow_ms": numberValidation(0, nil, "0 uses the built-in threshold."), + + "vector.embeddings.endpoint": { + Hint: "Absolute HTTP or HTTPS URL without credentials, query, or fragment.", Required: true, + }, + "vector.embeddings.model": {Hint: "Provider model identifier used in the vector generation fingerprint.", Required: true}, + "vector.embeddings.dimension": numberValidation(1, nil, "Positive embedding vector dimension."), + "vector.embeddings.batch_size": numberValidation(1, nil, "Positive provider request batch size."), + "vector.embeddings.timeout": {Hint: "Positive Go duration such as 30s or 2m.", Required: true}, + "vector.embeddings.max_retries": numberValidation(0, nil, "0 uses the built-in retry default."), + "vector.embeddings.max_input_chars": numberValidation(1, nil, "Maximum characters sent for one embedding input."), + "vector.embeddings.eta_window": numberValidation(1, nil, "Positive rolling window used for progress estimates."), + "vector.people.retention_posture": {Hint: "Explicit provider data-retention posture.", Required: true}, + "vector.people.training_posture": {Hint: "Explicit provider model-training posture.", Required: true}, + "vector.embed.schedule.cron": {Hint: "Five-field cron expression; leave empty to disable scheduled embedding."}, + "vector.embed.backstop_interval": {Hint: "Go duration; 0 uses the default and a negative duration disables the backstop.", Required: true}, + "vector.multimodal.endpoint": { + Hint: "Pinned Voyage HTTPS API root without credentials, query, or fragment.", Required: true, + }, + "vector.multimodal.model": {Hint: "Pinned visual embedding model identifier.", Required: true}, + "vector.multimodal.dimension": numberValidation(1024, new(float64(1024)), "Voyage visual embeddings require 1024 dimensions."), + "vector.multimodal.max_context_chars": numberValidation(1, nil, "Positive maximum context characters per visual document."), + "vector.multimodal.schedule.cron": {Hint: "Five-field cron expression; leave empty to disable scheduled visual embedding."}, + "vector.search.rrf_k": numberValidation(1, nil, "Positive reciprocal-rank-fusion constant."), + "vector.search.k_per_signal": numberValidation(1, nil, "Positive candidates retained per search signal."), + "vector.search.subject_boost": numberValidation(0, nil, "Non-negative subject-match weight."), + "vector.search.max_page_size_hybrid": numberValidation(0, nil, "0 disables the hybrid-result page-size clamp."), + + "beeper.schedule": {Hint: "Five-field cron expression; leave empty to disable scheduled sync."}, + "slack.schedule": {Hint: "Five-field cron expression; leave empty to disable scheduled sync."}, + "beeper.rate_limit_qps": numberValidation(0, nil, "0 uses the Beeper provider default."), + "beeper.media_max_participants": numberValidation(0, nil, "0 means no participant limit."), + "slack.media_max_participants": numberValidation(0, nil, "0 means no participant limit."), + "discord.media_max_participants": numberValidation(0, nil, "0 means no participant limit."), + "teams.media_max_participants": numberValidation(0, nil, "0 means no participant limit."), + "beeper.max_media_mb": numberValidation(0, nil, "0 uses the Beeper default of 100 MiB."), + "slack.max_media_mb": numberValidation(0, nil, "0 uses the Slack default of 100 MiB."), + "discord.max_media_mb": numberValidation(0, nil, "0 uses the Discord default of 50 MiB."), + "teams.max_media_mb": numberValidation(0, nil, "0 uses the Teams default of 100 MiB."), + + "activity.timezone": {Hint: "UTC or an IANA timezone such as America/New_York.", Required: true}, + "activity.max_direct_counterparts": numberValidation(1, new(float64(10_000)), "Bounded direct-counterpart count."), + "activity.batch_size": numberValidation(1, new(float64(10_000)), "Bounded projection batch size."), + "activity.schedule": {Hint: "Five-field cron expression; leave empty to disable scheduled projection."}, + "backup.zstd_level": numberValidation(0, new(float64(19)), "0 uses the backup encoder default; explicit levels are 1 through 19."), + "people.enrichment.schedule": {Hint: "Required five-field cron expression.", Required: true}, + "people.enrichment.batch_size": numberValidation(1, nil, "Positive number of people leased per run."), + "people.enrichment.lease_duration": {Hint: "Positive Go duration such as 5m or 1h.", Required: true}, + "integrations.tasks.endpoint": {Hint: "Optional HTTPS URL, loopback HTTP URL, or owner-controlled Unix socket."}, +} + +func numberValidation(minimum float64, maximum *float64, hint string) SettingValidation { + return SettingValidation{Hint: hint, Minimum: new(minimum), Maximum: maximum} +} + +func validationForSetting(key string) *SettingValidation { + validation, ok := settingsValidation[key] + if !ok { + return nil + } + return &validation +} + +func validateSettingBounds(key string, value any) error { + validation := validationForSetting(key) + if validation == nil || (validation.Minimum == nil && validation.Maximum == nil) { + return nil + } + var number float64 + switch typed := value.(type) { + case int: + number = float64(typed) + case int64: + number = float64(typed) + case float64: + number = typed + default: + return nil + } + if validation.Minimum != nil && number < *validation.Minimum { + return errors.New("below minimum") + } + if validation.Maximum != nil && number > *validation.Maximum { + return errors.New("above maximum") + } + return nil +} + +func metadataForSetting(key string) settingMetadata { + if metadata, ok := settingsMetadata[key]; ok { + return metadata + } + last := key + if dot := strings.LastIndexByte(key, '.'); dot >= 0 { + last = key[dot+1:] + } + words := strings.Fields(strings.ReplaceAll(last, "_", " ")) + for index := range words { + if words[index] != "" { + words[index] = strings.ToUpper(words[index][:1]) + words[index][1:] + } + } + label := strings.Join(words, " ") + return settingMetadata{label: label, description: "Configures " + strings.ReplaceAll(key, "_", " ") + "."} +} diff --git a/internal/api/settings_suppression.go b/internal/api/settings_suppression.go new file mode 100644 index 000000000..775b01266 --- /dev/null +++ b/internal/api/settings_suppression.go @@ -0,0 +1,70 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "os" + + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/personenrichment" + "go.kenn.io/msgvault/internal/providercredentials" +) + +var errSuppressionUnavailable = errors.New("person-enrichment suppression key is unavailable") + +func prepareFirstEnrichmentEnable( + cfg *config.Config, + updates []SettingUpdate, + credentials providercredentials.Snapshot, +) ([]config.Edit, providercredentials.Snapshot, string, error) { + if cfg.People.Enrichment.Enabled || !requestsEnrichmentEnable(updates) { + return nil, credentials, "", nil + } + environmentName := cfg.People.Enrichment.SuppressionKeyEnv + if environmentName != "" && environmentName != providercredentials.StoredSuppressionEnvironment { + value, ok := os.LookupEnv(environmentName) + if !ok { + return nil, credentials, "", errSuppressionUnavailable + } + if _, err := personenrichment.NewSuppressionHasher([]byte(value)); err != nil { + return nil, credentials, "", errSuppressionUnavailable + } + return nil, credentials, "", nil + } + stored, ok, err := credentials.ResolveSuppression() + if err != nil { + return nil, credentials, "", err + } + generated := "" + if !ok { + secretBytes := make([]byte, 48) + if _, err := rand.Read(secretBytes); err != nil { + return nil, credentials, "", fmt.Errorf("generate suppression key: %w", err) + } + stored = base64.RawURLEncoding.EncodeToString(secretBytes) + clear(secretBytes) + credentials, err = providercredentials.PutSuppression(cfg.TokensDir(), credentials.ETag, stored) + if err != nil { + return nil, credentials, "", err + } + generated = stored + } + if _, err := personenrichment.NewSuppressionHasher([]byte(stored)); err != nil { + return nil, credentials, generated, errSuppressionUnavailable + } + return []config.Edit{{ + Key: "people.enrichment.suppression_key_env", Value: providercredentials.StoredSuppressionEnvironment, + }}, credentials, generated, nil +} + +func requestsEnrichmentEnable(updates []SettingUpdate) bool { + for _, update := range updates { + if update.Key == "people.enrichment.enabled" && update.Value != nil && + update.Value.Boolean != nil && *update.Value.Boolean { + return true + } + } + return false +} diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index f386f1367..05facc1c8 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "runtime" @@ -18,61 +19,648 @@ import ( "github.com/stretchr/testify/require" "go.kenn.io/msgvault/internal/carddav" "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/providercredentials" "go.kenn.io/msgvault/internal/store" "go.kenn.io/msgvault/internal/testutil" ) func TestGetSettingsUsesAllowlistETagAndSecretStates(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertions := assert.New(t) + requirements := require.New(t) srv, _ := newSettingsTestServer(t, "# keep\n[web]\ntheme = \"dark\"\n"+ "[server]\napi_key = \"test-api-key\"\n"+ "[vector.embeddings]\ndocument_prefix = \"search_document: \"\nquery_prefix = \"search_query: \"\n"+ "[integrations.tasks]\napi_key = \"task-secret\"\n"+ "[unsupported]\nprivate_value = \"must-not-leak\"\n") resp := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "test-api-key") - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) - assert.NotEmpty(resp.Header().Get("ETag")) - assert.Equal("no-store", resp.Header().Get("Cache-Control")) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) + assertions.NotEmpty(resp.Header().Get("ETag")) + assertions.Equal("no-store", resp.Header().Get("Cache-Control")) var body SettingsResponse - require.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + requirements.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) byKey := settingsByKey(body.Settings) - require.NotNil(byKey["web.theme"].Value) - require.NotNil(byKey["web.theme"].Value.String) - assert.Equal("dark", *byKey["web.theme"].Value.String) - assert.Equal(&SecretSettingState{Configured: true}, byKey["server.api_key"].Secret) - assert.Nil(byKey["server.api_key"].Value) - assert.Equal(&SecretSettingState{Configured: true}, byKey["integrations.tasks.api_key"].Secret) - require.NotNil(byKey["vector.embeddings.api_format"].Value) - require.NotNil(byKey["vector.embeddings.api_format"].Value.String) - assert.Equal("openai", *byKey["vector.embeddings.api_format"].Value.String) - assert.Equal([]string{"openai", "voyage-contextual"}, byKey["vector.embeddings.api_format"].Options) - require.NotNil(byKey["vector.embeddings.document_prefix"].Value) - require.NotNil(byKey["vector.embeddings.document_prefix"].Value.String) - assert.Equal("search_document: ", *byKey["vector.embeddings.document_prefix"].Value.String) - require.NotNil(byKey["vector.embeddings.query_prefix"].Value) - require.NotNil(byKey["vector.embeddings.query_prefix"].Value.String) - assert.Equal("search_query: ", *byKey["vector.embeddings.query_prefix"].Value.String) - require.NotNil(byKey["vector.people.enabled"].Value) - require.NotNil(byKey["vector.people.enabled"].Value.Boolean) - assert.False(*byKey["vector.people.enabled"].Value.Boolean) - require.NotNil(byKey["vector.people.retention_posture"].Value) - require.NotNil(byKey["vector.people.training_posture"].Value) - require.NotNil(byKey["server.trusted_proxies"].Value) - assert.NotNil(byKey["server.trusted_proxies"].Value.Strings) - assert.NotContains(byKey, "unsupported.private_value") + requirements.NotNil(byKey["web.theme"].Value) + requirements.NotNil(byKey["web.theme"].Value.String) + assertions.Equal("dark", *byKey["web.theme"].Value.String) + assertions.Equal(&SecretSettingState{Configured: true}, byKey["server.api_key"].Secret) + assertions.Nil(byKey["server.api_key"].Value) + assertions.Equal(&SecretSettingState{Configured: true}, byKey["integrations.tasks.api_key"].Secret) + requirements.NotNil(byKey["vector.embeddings.api_format"].Value) + requirements.NotNil(byKey["vector.embeddings.api_format"].Value.String) + assertions.Equal("openai", *byKey["vector.embeddings.api_format"].Value.String) + assertions.Equal([]string{"openai", "voyage-contextual"}, byKey["vector.embeddings.api_format"].Options) + requirements.NotNil(byKey["vector.embeddings.document_prefix"].Value) + requirements.NotNil(byKey["vector.embeddings.document_prefix"].Value.String) + assertions.Equal("search_document: ", *byKey["vector.embeddings.document_prefix"].Value.String) + requirements.NotNil(byKey["vector.embeddings.query_prefix"].Value) + requirements.NotNil(byKey["vector.embeddings.query_prefix"].Value.String) + assertions.Equal("search_query: ", *byKey["vector.embeddings.query_prefix"].Value.String) + requirements.NotNil(byKey["vector.people.enabled"].Value) + requirements.NotNil(byKey["vector.people.enabled"].Value.Boolean) + assertions.False(*byKey["vector.people.enabled"].Value.Boolean) + requirements.NotNil(byKey["vector.people.retention_posture"].Value) + requirements.NotNil(byKey["vector.people.training_posture"].Value) + requirements.NotNil(byKey["server.trusted_proxies"].Value) + assertions.NotNil(byKey["server.trusted_proxies"].Value.Strings) + assertions.NotContains(byKey, "unsupported.private_value") for _, setting := range body.Settings { - assert.True(setting.RestartRequired, setting.Key) - wantReadOnly := setting.Key == "vector.embeddings.api_key_env" || + wantRestartRequired := !strings.HasPrefix(setting.Key, "web.") && setting.Key != "carddav.password" + assertions.Equal(wantRestartRequired, setting.RestartRequired, setting.Key) + wantReadOnly := strings.HasPrefix(setting.Key, "server.bind_addr") || + setting.Key == "server.api_port" || + setting.Key == "server.api_key" || + setting.Key == "server.allow_insecure" || + setting.Key == "server.trusted_proxies" || + setting.Key == "vector.backend" || + setting.Key == "vector.db_path" || + setting.Key == "vector.skip_extension_create" || + setting.Key == "vector.embeddings.api_key_env" || setting.Key == "vector.multimodal.api_key_env" || setting.Key == "vector.multimodal.capabilities_file" || strings.HasPrefix(setting.Key, "carddav.") - assert.Equal(wantReadOnly, setting.ReadOnly, setting.Key) + assertions.Equal(wantReadOnly, setting.ReadOnly, setting.Key) + } + assertions.NotContains(resp.Body.String(), "test-api-key") + assertions.NotContains(resp.Body.String(), "task-secret") + assertions.NotContains(resp.Body.String(), "must-not-leak") +} + +func TestGetSettingsIsSelfDescribingAndIncludesSafeCatalog(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, _ := newSettingsTestServer(t, "") + + resp := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) + var body struct { + Groups []map[string]any `json:"groups"` + Settings []map[string]any `json:"settings"` + } + requirements.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + requirements.NotEmpty(body.Groups, "the daemon must describe category labels for generic clients") + + byKey := make(map[string]map[string]any, len(body.Settings)) + for _, setting := range body.Settings { + key, ok := setting["key"].(string) + requirements.True(ok) + assertions.NotEmpty(setting["label"], "setting %s must have a stable label", key) + assertions.NotEmpty(setting["description"], "setting %s must have a stable description", key) + byKey[key] = setting + } + for _, key := range []string{ + "sync.rate_limit_qps", + "log.enabled", "log.level", "log.sql_slow_ms", "log.sql_trace", + "analytics.min_rebuild_interval", "analytics.builder_memory_limit", + "analytics.builder_threads", "analytics.builder_temp_limit", + "server.daemon_idle_timeout", "server.daemon_auto_restart", + "activity.timezone", "activity.max_direct_counterparts", "activity.batch_size", "activity.schedule", + "backup.zstd_level", + "beeper.accounts", "beeper.exclude_accounts", "beeper.rate_limit_qps", + "beeper.media", "beeper.media_scope", "beeper.media_max_participants", "beeper.max_media_mb", + "slack.enabled", "slack.schedule", "slack.channels", "slack.exclude_channels", + "slack.media", "slack.media_scope", "slack.media_max_participants", "slack.max_media_mb", + "discord.media", "discord.media_scope", "discord.media_max_participants", "discord.max_media_mb", + "teams.media", "teams.media_scope", "teams.media_max_participants", "teams.max_media_mb", + "vector.embeddings.timeout", "vector.preprocess.strip_quotes", "vector.preprocess.strip_signatures", + "vector.preprocess.strip_html", "vector.preprocess.strip_base64", + "vector.preprocess.strip_url_tracking", "vector.preprocess.collapse_whitespace", + "vector.search.max_page_size_hybrid", "vector.embed.backstop_interval", + "vector.embeddings.api_key", "vector.multimodal.api_key", + "people.enrichment.enabled", "people.enrichment.schedule", "people.enrichment.batch_size", + "people.enrichment.lease_duration", + } { + assertions.Contains(byKey, key) + } + for _, key := range []string{ + "server.bind_addr", "server.api_port", "server.api_key", "server.allow_insecure", "server.trusted_proxies", + "vector.backend", "vector.db_path", "vector.skip_extension_create", + } { + setting := byKey[key] + requirements.NotNil(setting, key) + assertions.Equal(true, setting["read_only"], key) + } + for _, key := range []string{"chat.server", "chat.model", "chat.max_results"} { + assertions.NotContains(byKey, key, "legacy chat settings have no production consumer") + } +} + +func TestGetSettingsPublishesValidationMetadataFromRegisteredRouter(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, _ := newSettingsTestServer(t, "") + + resp := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) + var body struct { + Settings []map[string]any `json:"settings"` + } + requirements.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + byKey := make(map[string]map[string]any, len(body.Settings)) + for _, setting := range body.Settings { + key, ok := setting["key"].(string) + requirements.True(ok) + byKey[key] = setting + } + + activityBatch, ok := byKey["activity.batch_size"]["validation"].(map[string]any) + requirements.True(ok, "activity.batch_size must publish validation metadata") + assertions.InDelta(float64(1), activityBatch["minimum"], 0) + assertions.InDelta(float64(10_000), activityBatch["maximum"], 0) + + backupLevel, ok := byKey["backup.zstd_level"]["validation"].(map[string]any) + requirements.True(ok, "backup.zstd_level must publish validation metadata") + assertions.InDelta(float64(0), backupLevel["minimum"], 0) + assertions.InDelta(float64(19), backupLevel["maximum"], 0) + + mediaSize, ok := byKey["discord.max_media_mb"]["validation"].(map[string]any) + requirements.True(ok, "attachment size controls must publish validation metadata") + assertions.InDelta(float64(0), mediaSize["minimum"], 0) + assertions.Contains(mediaSize["hint"], "0 uses the Discord default of 50 MiB") + + embeddingEndpoint, ok := byKey["vector.embeddings.endpoint"]["validation"].(map[string]any) + requirements.True(ok, "provider endpoints must publish safe input guidance") + assertions.Equal(true, embeddingEndpoint["required"]) + assertions.Contains(embeddingEndpoint["hint"], "without credentials, query, or fragment") + + activitySchedule, ok := byKey["activity.schedule"]["validation"].(map[string]any) + requirements.True(ok, "schedules must identify their accepted format") + hint, ok := activitySchedule["hint"].(string) + requirements.True(ok) + assertions.Contains(strings.ToLower(hint), "five-field cron") + assertions.NotEqual(true, byKey["integrations.tasks.endpoint"]["testable"], + "the daemon has no provider endpoint test operation") +} + +func TestSettingsCatalogDoesNotPublishGenericMetadataFallbacks(t *testing.T) { + assertions := assert.New(t) + for _, definition := range settingsCatalog { + _, ok := settingsMetadata[definition.key] + assertions.True(ok, "%s needs an intentional label and description", definition.key) + } +} + +func TestSettingsProviderCredentialsAreWriteOnlyOwnerOnlyAndETagProtected(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + t.Setenv("TEXT_EMBEDDING_KEY", "environment-secret-must-not-leak") + srv, _ := newSettingsTestServer(t, `[vector.embeddings] +endpoint = "https://embeddings.example.test/v1" +api_key_env = "TEXT_EMBEDDING_KEY" +model = "synthetic-model" +dimension = 8 +`) + + first := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, first.Code, first.Body.String()) + assertions.NotContains(first.Body.String(), "environment-secret-must-not-leak") + assertions.Equal(map[string]any{"configured": true, "source": "environment"}, + rawEmbeddingSecretState(t, first.Body.Bytes())) + configETag := first.Header().Get("ETag") + credentialETag := first.Header().Get("Credential-Etag") + requirements.NotEmpty(configETag) + requirements.NotEmpty(credentialETag) + + set := performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/provider-credentials/vector.embeddings", + []byte(`{"value":"browser-secret-must-not-leak"}`), credentialETag, "") + requirements.Equal(http.StatusOK, set.Code, set.Body.String()) + var setResponse ProviderCredentialResponse + requirements.NoError(json.Unmarshal(set.Body.Bytes(), &setResponse)) + assertions.True(setResponse.PendingRestart) + assertions.NotContains(set.Body.String(), "browser-secret-must-not-leak") + assertions.NotContains(set.Body.String(), "environment-secret-must-not-leak") + storedCredentialETag := set.Header().Get("ETag") + assertions.NotEqual(credentialETag, storedCredentialETag) + + stored := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, stored.Code, stored.Body.String()) + assertions.Equal(map[string]any{"configured": true, "source": "stored"}, + rawEmbeddingSecretState(t, stored.Body.Bytes())) + assertions.Equal(configETag, stored.Header().Get("ETag"), "credential writes must not masquerade as config writes") + assertions.Equal(storedCredentialETag, stored.Header().Get("Credential-Etag")) + + credentialPath := filepath.Join(srv.cfg.TokensDir(), "provider-credentials.json") + info, err := os.Stat(credentialPath) + requirements.NoError(err) + if runtime.GOOS != "windows" { + assertions.Equal(os.FileMode(0o600), info.Mode().Perm()) + dirInfo, statErr := os.Stat(srv.cfg.TokensDir()) + requirements.NoError(statErr) + assertions.Equal(os.FileMode(0o700), dirInfo.Mode().Perm()) + } + credentialBytes, err := os.ReadFile(credentialPath) + requirements.NoError(err) + assertions.Contains(string(credentialBytes), "browser-secret-must-not-leak") + + stale := performSettingsRequest(t, srv, http.MethodDelete, + "/api/v1/settings/provider-credentials/vector.embeddings", nil, credentialETag, "") + assertions.Equal(http.StatusPreconditionFailed, stale.Code, stale.Body.String()) + + clearResponseRecorder := performSettingsRequest(t, srv, http.MethodDelete, + "/api/v1/settings/provider-credentials/vector.embeddings", nil, storedCredentialETag, "") + requirements.Equal(http.StatusOK, clearResponseRecorder.Code, clearResponseRecorder.Body.String()) + var clearResponse ProviderCredentialResponse + requirements.NoError(json.Unmarshal(clearResponseRecorder.Body.Bytes(), &clearResponse)) + assertions.True(clearResponse.PendingRestart) + cleared := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, cleared.Code, cleared.Body.String()) + assertions.Equal(map[string]any{"configured": true, "source": "environment"}, + rawEmbeddingSecretState(t, cleared.Body.Bytes())) + assertions.NotContains(cleared.Body.String(), "environment-secret-must-not-leak") +} + +func TestPatchSettingsPersistsSafeScalarAndAttachmentPolicies(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, path := newSettingsTestServer(t, "") + body, err := json.Marshal(map[string]any{"updates": []map[string]any{ + {"key": "sync.rate_limit_qps", "value": map[string]any{"integer": 12}}, + {"key": "log.enabled", "value": map[string]any{"boolean": true}}, + {"key": "log.level", "value": map[string]any{"string": "debug"}}, + {"key": "log.sql_slow_ms", "value": map[string]any{"integer": 250}}, + {"key": "log.sql_trace", "value": map[string]any{"boolean": true}}, + {"key": "analytics.min_rebuild_interval", "value": map[string]any{"string": "2h"}}, + {"key": "analytics.builder_threads", "value": map[string]any{"integer": 3}}, + {"key": "server.daemon_idle_timeout", "value": map[string]any{"string": "30m"}}, + {"key": "server.daemon_auto_restart", "value": map[string]any{"string": "always"}}, + {"key": "activity.timezone", "value": map[string]any{"string": "America/New_York"}}, + {"key": "activity.max_direct_counterparts", "value": map[string]any{"integer": 50}}, + {"key": "activity.batch_size", "value": map[string]any{"integer": 750}}, + {"key": "activity.schedule", "value": map[string]any{"string": "5 * * * *"}}, + {"key": "backup.zstd_level", "value": map[string]any{"integer": 7}}, + {"key": "beeper.accounts", "value": map[string]any{"strings": []string{"signal"}}}, + {"key": "beeper.exclude_accounts", "value": map[string]any{"strings": []string{"whatsapp"}}}, + {"key": "beeper.rate_limit_qps", "value": map[string]any{"number": 8.5}}, + {"key": "beeper.media", "value": map[string]any{"boolean": false}}, + {"key": "beeper.media_scope", "value": map[string]any{"string": "direct"}}, + {"key": "beeper.media_max_participants", "value": map[string]any{"integer": 5}}, + {"key": "beeper.max_media_mb", "value": map[string]any{"integer": 80}}, + {"key": "slack.channels", "value": map[string]any{"strings": []string{"general"}}}, + {"key": "slack.media", "value": map[string]any{"boolean": true}}, + {"key": "slack.media_scope", "value": map[string]any{"string": "none"}}, + {"key": "slack.media_max_participants", "value": map[string]any{"integer": 0}}, + {"key": "slack.max_media_mb", "value": map[string]any{"integer": 90}}, + {"key": "discord.media", "value": map[string]any{"boolean": true}}, + {"key": "discord.media_scope", "value": map[string]any{"string": "all"}}, + {"key": "discord.media_max_participants", "value": map[string]any{"integer": 10}}, + {"key": "discord.max_media_mb", "value": map[string]any{"integer": 70}}, + {"key": "teams.media", "value": map[string]any{"boolean": false}}, + {"key": "teams.media_scope", "value": map[string]any{"string": "direct"}}, + {"key": "teams.media_max_participants", "value": map[string]any{"integer": 6}}, + {"key": "teams.max_media_mb", "value": map[string]any{"integer": 60}}, + {"key": "vector.embeddings.timeout", "value": map[string]any{"string": "45s"}}, + {"key": "vector.preprocess.strip_quotes", "value": map[string]any{"boolean": false}}, + {"key": "vector.search.max_page_size_hybrid", "value": map[string]any{"integer": 0}}, + {"key": "vector.embed.backstop_interval", "value": map[string]any{"string": "12h"}}, + }}) + requirements.NoError(err) + + resp := patchSettings(t, srv, string(body)) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) + loaded, err := config.Load(path, "") + requirements.NoError(err) + assertions.Equal(12, loaded.Sync.RateLimitQPS) + assertions.Equal("debug", loaded.Log.Level) + assertions.Equal(int64(250), loaded.Log.SQLSlowMs) + assertions.Equal(3, loaded.Analytics.BuilderThreads) + assertions.Equal("America/New_York", loaded.Activity.Timezone) + assertions.Equal(7, loaded.Backup.ZstdLevel) + assertions.Equal([]string{"signal"}, loaded.Beeper.Accounts) + assertions.Equal([]string{"whatsapp"}, loaded.Beeper.ExcludeAccounts) + assertions.InDelta(8.5, loaded.Beeper.RateLimitQPS, 0.001) + assertions.False(loaded.Beeper.MediaEnabled()) + assertions.Equal("direct", loaded.Beeper.MediaScope) + assertions.Equal(5, loaded.Beeper.MediaMaxParticipants) + assertions.Equal(80, loaded.Beeper.MaxMediaMB) + assertions.Equal("none", loaded.Slack.MediaScope) + assertions.Equal(90, loaded.Slack.MaxMediaMB) + assertions.Equal(10, loaded.Discord.MediaMaxParticipants) + assertions.Equal(60, loaded.Teams.MaxMediaMB) + assertions.False(loaded.Vector.Preprocess.StripQuotesEnabled()) + assertions.Equal(0, loaded.Vector.Search.MaxPageSizeHybridClamp()) +} + +func TestPatchSettingsRejectsInvalidAttachmentPolicy(t *testing.T) { + srv, _ := newSettingsTestServer(t, "") + resp := patchSettings(t, srv, + `{"updates":[{"key":"teams.media_max_participants","value":{"integer":-1}}]}`) + assert.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String()) + assert.NotContains(t, resp.Body.String(), "provider-credentials") +} + +func TestPatchSettingsRejectsHostAndAuthSettings(t *testing.T) { + tests := []struct { + key string + value string + }{ + {key: "server.bind_addr", value: `{"string":"127.0.0.2"}`}, + {key: "server.api_port", value: `{"integer":8080}`}, + {key: "server.api_key", value: `null`}, + {key: "server.allow_insecure", value: `{"boolean":true}`}, + {key: "server.trusted_proxies", value: `{"strings":["127.0.0.1"]}`}, + {key: "vector.backend", value: `{"string":"pgvector"}`}, + {key: "vector.db_path", value: `{"string":"/tmp/remote-controlled.db"}`}, + {key: "vector.skip_extension_create", value: `{"boolean":true}`}, + } + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + before := "[web]\ntheme = \"system\"\n" + srv, path := newSettingsTestServer(t, before) + body := fmt.Sprintf(`{"updates":[{"key":%q,"value":%s}]}`, tt.key, tt.value) + if tt.key == "server.api_key" { + body = `{"updates":[{"key":"server.api_key","secret":{"action":"set","value":"remote-secret"}}]}` + } + resp := patchSettings(t, srv, body) + assert.Equal(t, http.StatusBadRequest, resp.Code, resp.Body.String()) + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, before, string(got)) + }) + } +} + +func TestPutSettingsPersonEnrichmentProviderPreservesStableNamesAndOrder(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, path := newSettingsTestServer(t, `[people.enrichment] +enabled = false +schedule = "0 * * * *" +batch_size = 10 +lease_duration = "10m" +suppression_key_env = "SUPPRESSION_KEY" + +[[people.enrichment.providers]] +name = "exa-primary" +kind = "exa" +enabled = false +api_key_env = "EXA_KEY" +allowed_identifiers = ["name", "email"] +target_keys = ["attribute:bio"] +retention_posture = "zero_retention" +training_posture = "no_training" +refresh_interval = "24h" +max_requests_per_run = 10 +max_requests_per_day = 100 + +[[people.enrichment.providers]] +name = "sixtyfour-primary" +kind = "sixtyfour" +enabled = false +api_key_env = "SIXTYFOUR_KEY" +tier = "standard" +allowed_identifiers = ["name", "current_company"] +target_keys = ["attribute:bio"] +retention_posture = "zero_retention" +training_posture = "no_training" +refresh_interval = "24h" +max_requests_per_run = 5 +max_requests_per_day = 50 +`) + + get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, get.Code, get.Body.String()) + resp := performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/person-enrichment/providers/exa-primary", []byte(`{ +"kind":"exa", +"enabled":true, +"endpoint":"https://api.exa.ai/search", +"mode":"people", +"allowed_identifiers":["name","email"], +"target_keys":["attribute:bio"], +"allow_sensitive_targets":false, +"retention_posture":"zero_retention", +"training_posture":"no_training", +"refresh_interval":"24h", +"request_timeout":"1m", +"max_retries":5, +"max_requests_per_run":20, +"max_requests_per_day":100 +}`), get.Header().Get("ETag"), "") + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) + loaded, err := config.Load(path, "") + requirements.NoError(err) + requirements.Len(loaded.People.Enrichment.Providers, 2) + assertions.Equal("exa-primary", loaded.People.Enrichment.Providers[0].Name) + assertions.True(loaded.People.Enrichment.Providers[0].Enabled) + assertions.Equal(int64(20), loaded.People.Enrichment.Providers[0].MaxRequestsPerRun) + assertions.Equal("sixtyfour-primary", loaded.People.Enrichment.Providers[1].Name) + assertions.Equal(int64(50), loaded.People.Enrichment.Providers[1].MaxRequestsPerDay) +} + +func TestPatchSettingsFirstEnrichmentEnableGeneratesPrivateSuppressionKey(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, path := newSettingsTestServer(t, `[people.enrichment] +enabled = false + +[[people.enrichment.providers]] +name = "exa-primary" +kind = "exa" +enabled = true +api_key_env = "EXA_KEY" +allowed_identifiers = ["name", "email"] +target_keys = ["attribute:bio"] +retention_posture = "zero_retention" +training_posture = "no_training" +refresh_interval = "24h" +max_requests_per_run = 10 +max_requests_per_day = 100 +`) + + resp := patchSettings(t, srv, + `{"updates":[{"key":"people.enrichment.enabled","value":{"boolean":true}}]}`) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) + loaded, err := config.Load(path, "") + requirements.NoError(err) + assertions.True(loaded.People.Enrichment.Enabled) + assertions.NotEmpty(loaded.People.Enrichment.SuppressionKeyEnv) + credentialBytes, err := os.ReadFile(filepath.Join(loaded.TokensDir(), "provider-credentials.json")) + requirements.NoError(err) + credentials, err := providercredentials.Read(loaded.TokensDir()) + requirements.NoError(err) + suppressionKey, configured, err := credentials.ResolveSuppression() + requirements.NoError(err) + requirements.True(configured) + assertions.NotContains(resp.Body.String(), suppressionKey) + assertions.Greater(len(credentialBytes), 64) +} + +func TestPatchSettingsRejectedFirstEnrichmentEnableLeavesCredentialStoreUnchanged(t *testing.T) { + for _, test := range []struct { + name string + editorErr error + wantStatus int + }{ + {name: "invalid candidate", editorErr: config.ErrInvalidConfigCandidate, wantStatus: http.StatusUnprocessableEntity}, + {name: "stale config", editorErr: config.ErrConfigConflict, wantStatus: http.StatusPreconditionFailed}, + } { + t.Run(test.name, func(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, _ := newSettingsTestServer(t, `[people.enrichment] +enabled = false + +[[people.enrichment.providers]] +name = "exa-primary" +kind = "exa" +enabled = true +api_key_env = "EXA_KEY" +allowed_identifiers = ["name", "email"] +target_keys = ["attribute:bio"] +retention_posture = "zero_retention" +training_posture = "no_training" +refresh_interval = "24h" +max_requests_per_run = 10 +max_requests_per_day = 100 +`) + srv.settingsConfigEditor = func(string, string, []config.Edit) (config.ConfigFile, error) { + return config.ConfigFile{}, test.editorErr + } + + resp := patchSettings(t, srv, + `{"updates":[{"key":"people.enrichment.enabled","value":{"boolean":true}}]}`) + assertions.Equal(test.wantStatus, resp.Code, resp.Body.String()) + credentials, err := providercredentials.Read(srv.cfg.TokensDir()) + requirements.NoError(err) + _, configured, err := credentials.ResolveSuppression() + requirements.NoError(err) + assertions.False(configured) + }) + } +} + +func TestSettingsProviderCredentialStoreFailsClosedWhenUnsafeOrCorrupt(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + t.Setenv("TEXT_EMBEDDING_KEY", "environment-fallback-must-not-be-used") + srv, _ := newSettingsTestServer(t, `[vector.embeddings] +endpoint = "https://embeddings.example.test/v1" +api_key_env = "TEXT_EMBEDDING_KEY" +model = "synthetic-model" +dimension = 8 +`) + requirements.NoError(os.MkdirAll(srv.cfg.TokensDir(), 0o700)) + path := filepath.Join(srv.cfg.TokensDir(), "provider-credentials.json") + requirements.NoError(os.WriteFile(path, []byte(`{"version":1,"credentials":`), 0o600)) + + resp := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + assertions.Equal(http.StatusInternalServerError, resp.Code, resp.Body.String()) + assertions.NotContains(resp.Body.String(), "environment-fallback-must-not-be-used") + assertions.NotContains(resp.Body.String(), "provider-credentials.json") +} + +func TestSettingsStoredCredentialIsBoundToEndpointOrigin(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, _ := newSettingsTestServer(t, `[vector.embeddings] +endpoint = "https://first.example.test/v1" +api_key_env = "UNSET_TEXT_KEY" +model = "synthetic-model" +dimension = 8 +`) + get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, get.Code, get.Body.String()) + set := performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/provider-credentials/vector.embeddings", + []byte(`{"value":"origin-bound-secret"}`), get.Header().Get("Credential-Etag"), "") + requirements.Equal(http.StatusOK, set.Code, set.Body.String()) + + changed := performSettingsRequest(t, srv, http.MethodPatch, settingsPath, + []byte(`{"updates":[{"key":"vector.embeddings.endpoint","value":{"string":"https://second.example.test/v1"}}]}`), + get.Header().Get("ETag"), "") + requirements.Equal(http.StatusOK, changed.Code, changed.Body.String()) + assertions.Equal(map[string]any{"configured": false, "source": "none"}, + rawEmbeddingSecretState(t, changed.Body.Bytes())) + assertions.NotContains(changed.Body.String(), "origin-bound-secret") +} + +func TestPatchSettingsHardensSecretBearingConfigFile(t *testing.T) { + requirements := require.New(t) + if runtime.GOOS == "windows" { + t.Skip("Windows owner-only DACL coverage lives in platform-specific config tests") + } + srv, path := newSettingsTestServer(t, "[server]\napi_key = \"secret\"\n[web]\ntheme = \"system\"\n") + requirements.NoError(os.Chmod(path, 0o644)) + + resp := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "secret") + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) + patched := performSettingsRequest(t, srv, http.MethodPatch, settingsPath, + []byte(`{"updates":[{"key":"web.theme","value":{"string":"dark"}}]}`), + resp.Header().Get("ETag"), "secret") + requirements.Equal(http.StatusOK, patched.Code, patched.Body.String()) + info, err := os.Stat(path) + requirements.NoError(err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestPatchSettingsPreservesUntouchedMediaPointerAndOpaqueOverrides(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, path := newSettingsTestServer(t, `[discord] +media_scope = "all" +[discord.guilds.G01] +media = false +max_media_mb = 30 +`) + get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, get.Code, get.Body.String()) + var document struct { + Settings []map[string]any `json:"settings"` + } + requirements.NoError(json.Unmarshal(get.Body.Bytes(), &document)) + for _, setting := range document.Settings { + if setting["key"] == "discord.media" { + assertions.Equal(true, setting["inherited"], "omitted provider policy must be identified as inherited/default") + assertions.Equal("Provider default is enabled; changes affect future downloads only.", setting["description"]) + } + if setting["key"] == "discord.max_media_mb" { + assertions.Contains(setting["description"], "0 uses the Discord default of 50 MiB") + } + } + + resp := patchSettings(t, srv, + `{"updates":[{"key":"discord.media_scope","value":{"string":"direct"}}]}`) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) + loaded, err := config.Load(path, "") + requirements.NoError(err) + assertions.Nil(loaded.Discord.Media, "untouched provider default must remain omitted") + requirements.Contains(loaded.Discord.Guilds, "G01") + assertions.NotNil(loaded.Discord.Guilds["G01"].Media) + assertions.False(*loaded.Discord.Guilds["G01"].Media) + assertions.Equal(30, loaded.Discord.Guilds["G01"].MaxMediaMB) +} + +func TestSettingsRejectsAndRedactsCredentialBearingEmbeddingEndpoints(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, _ := newSettingsTestServer(t, `[vector.embeddings] +endpoint = "https://user:legacy-password@embeddings.example.test/v1" +api_key_env = "TEXT_KEY" +model = "synthetic-model" +dimension = 8 +`) + + get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + assertions.Equal(http.StatusInternalServerError, get.Code, get.Body.String()) + assertions.NotContains(get.Body.String(), "legacy-password") + assertions.NotContains(get.Body.String(), "user:") + + clean, _ := newSettingsTestServer(t, `[vector.embeddings] +endpoint = "https://embeddings.example.test/v1" +api_key_env = "TEXT_KEY" +model = "synthetic-model" +dimension = 8 +`) + first := performSettingsRequest(t, clean, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, first.Code, first.Body.String()) + patch := performSettingsRequest(t, clean, http.MethodPatch, settingsPath, + []byte(`{"updates":[{"key":"vector.embeddings.endpoint","value":{"string":"https://user:new-password@other.example.test/v1?api_key=query-secret#fragment-secret"}}]}`), + first.Header().Get("ETag"), "") + assertions.Equal(http.StatusUnprocessableEntity, patch.Code, patch.Body.String()) + for _, secret := range []string{"new-password", "query-secret", "fragment-secret", "user:"} { + assertions.NotContains(patch.Body.String(), secret) } - assert.NotContains(resp.Body.String(), "test-api-key") - assert.NotContains(resp.Body.String(), "task-secret") - assert.NotContains(resp.Body.String(), "must-not-leak") } func TestPatchSettingsExposesCompleteSemanticPersonOptInPolicy(t *testing.T) { @@ -99,8 +687,8 @@ func TestPatchSettingsExposesCompleteSemanticPersonOptInPolicy(t *testing.T) { } func TestGetSettingsExposesReadOnlyCardDAVAccountStateWithoutCredential(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertions := assert.New(t) + requirements := require.New(t) srv, _ := newSettingsTestServer(t, `[carddav] base_url = "https://contacts.example/dav" @@ -117,35 +705,35 @@ enabled = true CanonicalURL: "https://contacts.example/books/alice/personal/", }}, }) - require.NoError(err) - require.NoError(carddav.SaveCredential(srv.cfg.TokensDir(), carddav.Credential{ + requirements.NoError(err) + requirements.NoError(carddav.SaveCredential(srv.cfg.TokensDir(), carddav.Credential{ Password: "must-not-cross-api", BaseURL: srv.cfg.CardDAV.BaseURL, Username: srv.cfg.CardDAV.Username, ConnectionGeneration: account.ConnectionGeneration, })) - srv.cardDAV, err = NewCardDAVController(srv.cfg, st) - require.NoError(err) + srv.cardDAV, err = NewCardDAVController(srv.cfg, st, slog.New(slog.DiscardHandler)) + requirements.NoError(err) resp := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) var body SettingsResponse - require.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + requirements.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) byKey := settingsByKey(body.Settings) for _, key := range []string{"carddav.base_url", "carddav.username", "carddav.schedule", "carddav.enabled", "carddav.password"} { - require.Contains(byKey, key) - assert.True(byKey[key].ReadOnly, key) + requirements.Contains(byKey, key) + assertions.True(byKey[key].ReadOnly, key) } - assert.Equal(&SecretSettingState{Configured: true}, byKey["carddav.password"].Secret) - assert.NotContains(resp.Body.String(), "must-not-cross-api") + assertions.Equal(&SecretSettingState{Configured: true}, byKey["carddav.password"].Secret) + assertions.NotContains(resp.Body.String(), "must-not-cross-api") patch := performSettingsRequest(t, srv, http.MethodPatch, settingsPath, []byte(`{"updates":[{"key":"carddav.enabled","value":{"boolean":false}}]}`), resp.Header().Get("ETag"), "") - assert.Equal(http.StatusBadRequest, patch.Code, patch.Body.String()) + assertions.Equal(http.StatusBadRequest, patch.Code, patch.Body.String()) } func TestGetSettingsReportsStaleCardDAVCredentialAsNotConfigured(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertions := assert.New(t) + requirements := require.New(t) srv, _ := newSettingsTestServer(t, `[carddav] base_url = "https://contacts.example/dav" @@ -162,29 +750,29 @@ enabled = true }}, } account, _, err := st.ReplaceCardDAVDiscoveryContext(t.Context(), discovery) - require.NoError(err) - require.NoError(carddav.SaveCredential(srv.cfg.TokensDir(), carddav.Credential{ + requirements.NoError(err) + requirements.NoError(carddav.SaveCredential(srv.cfg.TokensDir(), carddav.Credential{ Password: "stale-password", BaseURL: srv.cfg.CardDAV.BaseURL, Username: srv.cfg.CardDAV.Username, ConnectionGeneration: account.ConnectionGeneration, })) discovery.CredentialsChanged = true account, _, err = st.ReplaceCardDAVDiscoveryContext(t.Context(), discovery) - require.NoError(err) - assert.Equal(int64(2), account.ConnectionGeneration) - srv.cardDAV, err = NewCardDAVController(srv.cfg, st) - require.NoError(err) + requirements.NoError(err) + assertions.Equal(int64(2), account.ConnectionGeneration) + srv.cardDAV, err = NewCardDAVController(srv.cfg, st, slog.New(slog.DiscardHandler)) + requirements.NoError(err) resp := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) var body SettingsResponse - require.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) - assert.Equal(&SecretSettingState{Configured: false}, settingsByKey(body.Settings)["carddav.password"].Secret) - assert.NotContains(resp.Body.String(), "stale-password") + requirements.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + assertions.Equal(&SecretSettingState{Configured: false}, settingsByKey(body.Settings)["carddav.password"].Secret) + assertions.NotContains(resp.Body.String(), "stale-password") } func TestPatchSettingsSelectsVoyageContextualEmbeddingFormat(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertions := assert.New(t) + requirements := require.New(t) srv, path := newSettingsTestServer(t, "[vector.embeddings]\n"+ "endpoint = \"https://api.voyageai.com/v1\"\n"+ "model = \"text-embedding-test\"\n"+ @@ -192,17 +780,17 @@ func TestPatchSettingsSelectsVoyageContextualEmbeddingFormat(t *testing.T) { resp := patchSettings(t, srv, `{"updates":[{"key":"vector.embeddings.api_format","value":{"string":"voyage-contextual"}},{"key":"vector.embeddings.model","value":{"string":"voyage-context-4"}}]}`) - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) got, err := os.ReadFile(path) - require.NoError(err) - assert.Contains(string(got), `api_format = "voyage-contextual"`) - assert.Contains(string(got), `model = "voyage-context-4"`) + requirements.NoError(err) + assertions.Contains(string(got), `api_format = "voyage-contextual"`) + assertions.Contains(string(got), `model = "voyage-context-4"`) } func TestGetSettingsExposesMultimodalPolicyWithoutCredentialState(t *testing.T) { - require := require.New(t) - assert := assert.New(t) + requirements := require.New(t) + assertions := assert.New(t) t.Setenv("SYNTHETIC_VOYAGE_KEY", "synthetic-key-value") srv, _ := newSettingsTestServer(t, `[vector.multimodal] enabled = true @@ -211,62 +799,63 @@ include_images = false include_video = true `) resp := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) var body SettingsResponse - require.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + requirements.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) byKey := settingsByKey(body.Settings) - require.NotNil(byKey["vector.multimodal.enabled"].Value.Boolean) - assert.True(*byKey["vector.multimodal.enabled"].Value.Boolean) - require.NotNil(byKey["vector.multimodal.include_images"].Value.Boolean) - assert.False(*byKey["vector.multimodal.include_images"].Value.Boolean) - require.NotNil(byKey["vector.multimodal.include_video"].Value.Boolean) - assert.True(*byKey["vector.multimodal.include_video"].Value.Boolean) - assert.True(byKey["vector.multimodal.api_key_env"].ReadOnly) - assert.NotContains(resp.Body.String(), "synthetic-key-value") + requirements.NotNil(byKey["vector.multimodal.enabled"].Value.Boolean) + assertions.True(*byKey["vector.multimodal.enabled"].Value.Boolean) + requirements.NotNil(byKey["vector.multimodal.include_images"].Value.Boolean) + assertions.False(*byKey["vector.multimodal.include_images"].Value.Boolean) + requirements.NotNil(byKey["vector.multimodal.include_video"].Value.Boolean) + assertions.True(*byKey["vector.multimodal.include_video"].Value.Boolean) + assertions.True(byKey["vector.multimodal.api_key_env"].ReadOnly) + assertions.NotContains(resp.Body.String(), "synthetic-key-value") } func TestPatchSettingsRequiresMatchingETag(t *testing.T) { - assert := assert.New(t) + assertions := assert.New(t) srv, path := newSettingsTestServer(t, "[web]\ntheme = \"system\"\n") missing := performSettingsRequest(t, srv, http.MethodPatch, settingsPath, []byte(`{"updates":[{"key":"web.theme","value":{"string":"dark"}}]}`), "", "") - assert.Equal(http.StatusPreconditionRequired, missing.Code, missing.Body.String()) + assertions.Equal(http.StatusPreconditionRequired, missing.Code, missing.Body.String()) mismatch := performSettingsRequest(t, srv, http.MethodPatch, settingsPath, []byte(`{"updates":[{"key":"web.theme","value":{"string":"dark"}}]}`), "\"sha256-stale\"", "") - assert.Equal(http.StatusPreconditionFailed, mismatch.Code, mismatch.Body.String()) + assertions.Equal(http.StatusPreconditionFailed, mismatch.Code, mismatch.Body.String()) got, err := os.ReadFile(path) require.NoError(t, err) - assert.Equal("[web]\ntheme = \"system\"\n", string(got)) + assertions.Equal("[web]\ntheme = \"system\"\n", string(got)) } func TestPatchSettingsPreservesFileAndReturnsNewETag(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertions := assert.New(t) + requirements := require.New(t) srv, path := newSettingsTestServer(t, "# operator comment\n[unknown]\nkeep = true\n\n"+ "[web]\ntheme = \"system\" # display\n") if runtime.GOOS != "windows" { - require.NoError(os.Chmod(path, 0o640)) + requirements.NoError(os.Chmod(path, 0o640)) } get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") etag := get.Header().Get("ETag") patch := performSettingsRequest(t, srv, http.MethodPatch, settingsPath, []byte(`{"updates":[{"key":"web.theme","value":{"string":"dark"}}]}`), etag, "") - require.Equal(http.StatusOK, patch.Code, patch.Body.String()) - assert.NotEqual(etag, patch.Header().Get("ETag")) + requirements.Equal(http.StatusOK, patch.Code, patch.Body.String()) + assertions.NotEqual(etag, patch.Header().Get("ETag")) got, err := os.ReadFile(path) - require.NoError(err) - assert.Equal("# operator comment\n[unknown]\nkeep = true\n\n[web]\ntheme = \"dark\" # display\n", string(got)) + requirements.NoError(err) + assertions.Equal("# operator comment\n[unknown]\nkeep = true\n\n[web]\ntheme = \"dark\" # display\n", string(got)) if runtime.GOOS != "windows" { - // Unix mode preservation. Windows security lives in the DACL, which + // Settings publication hardens the entire secret-bearing config to + // owner-only. Windows security lives in the DACL, which // the config package's own Windows tests verify; Stat mode bits there // are synthetic. info, err := os.Stat(path) - require.NoError(err) - assert.Equal(os.FileMode(0o640), info.Mode().Perm()) + requirements.NoError(err) + assertions.Equal(os.FileMode(0o600), info.Mode().Perm()) } } @@ -307,90 +896,54 @@ func TestPatchSettingsValidatesWholeCandidateAndRejectsUnknownKeys(t *testing.T) } } -func TestPatchSettingsProtectsAPIKeyRestartSequencing(t *testing.T) { - t.Run("confirmation required", func(t *testing.T) { - srv, _ := newSettingsTestServer(t, "[server]\napi_key = \"old-key\"\n") - get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "old-key") - resp := performSettingsRequest(t, srv, http.MethodPatch, settingsPath, - []byte(`{"updates":[{"key":"server.api_key","secret":{"action":"set","value":"new-key"}}]}`), - get.Header().Get("ETag"), "old-key") - assert.Equal(t, http.StatusBadRequest, resp.Code, resp.Body.String()) - }) - - t.Run("full candidate prevents remote self lockout", func(t *testing.T) { - srv, _ := newSettingsTestServer(t, "[server]\nbind_addr = \"0.0.0.0\"\napi_key = \"old-key\"\n") - get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "old-key") - resp := performSettingsRequest(t, srv, http.MethodPatch, settingsPath, - []byte(`{"confirm_api_key_restart":true,"updates":[{"key":"server.api_key","secret":{"action":"clear"}}]}`), - get.Header().Get("ETag"), "old-key") - assert.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String()) - }) - - t.Run("new key remains pending until restart", func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) +func TestPatchSettingsRejectsHostManagedServerAPIKeyUpdates(t *testing.T) { + assertions := assert.New(t) + requests := []string{ + `{"updates":[{"key":"server.api_key","value":{"string":"new-key"}}]}`, + `{"updates":[{"key":"server.api_key","secret":{"action":"set","value":"new-key"}}]}`, + `{"updates":[{"key":"server.api_key","secret":{"action":"clear"}}]}`, + } + for _, request := range requests { srv, path := newSettingsTestServer(t, "[server]\napi_key = \"old-key\"\n") get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "old-key") resp := performSettingsRequest(t, srv, http.MethodPatch, settingsPath, - []byte(`{"confirm_api_key_restart":true,"updates":[{"key":"server.api_key","secret":{"action":"set","value":"new-key"}}]}`), - get.Header().Get("ETag"), "old-key") - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) - assert.Equal("old-key", srv.cfg.Server.APIKey) + []byte(request), get.Header().Get("ETag"), "old-key") + + assertions.Equal(http.StatusBadRequest, resp.Code, resp.Body.String()) + assertions.Contains(resp.Body.String(), "host-managed") + assertions.NotContains(resp.Body.String(), "new-key") got, err := os.ReadFile(path) - require.NoError(err) - assert.Contains(string(got), "api_key = \"new-key\"") - assert.NotContains(resp.Body.String(), "new-key") - - stillActive := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "old-key") - assert.Equal(http.StatusOK, stillActive.Code, stillActive.Body.String()) - var persisted SettingsResponse - require.NoError(json.Unmarshal(stillActive.Body.Bytes(), &persisted)) - assert.True(persisted.PendingRestart) - - restartedConfig, err := config.Load(path, "") - require.NoError(err) - restarted := NewServer(restartedConfig, nil, nil, slog.New(slog.DiscardHandler)) - oldSession := performSessionRequest(t, restarted, http.MethodGet, sessionPath, nil, - http.Header{"Cookie": []string{requireSessionCookie(t, performSessionRequest( - t, srv, http.MethodPost, sessionLoginPath, []byte(`{"api_key":"old-key"}`), nil, false, - )).String()}}, false) - require.Equal(http.StatusOK, oldSession.Code, oldSession.Body.String()) - assert.Equal(AuthModeRequired, decodeSessionStatus(t, oldSession).AuthMode) - assert.Empty(oldSession.Header().Values("Set-Cookie"), "bootstrap must not reissue authority for the stale cookie") - - oldKey := performSettingsRequest(t, restarted, http.MethodGet, settingsPath, nil, "", "old-key") - assert.Equal(http.StatusUnauthorized, oldKey.Code, oldKey.Body.String()) - newKey := performSettingsRequest(t, restarted, http.MethodGet, settingsPath, nil, "", "new-key") - assert.Equal(http.StatusOK, newKey.Code, newKey.Body.String()) - }) + require.NoError(t, err) + assertions.Equal("[server]\napi_key = \"old-key\"\n", string(got)) + } } func TestPatchSettingsClearsTaskAPIKeyWhenEndpointOriginChanges(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertions := assert.New(t) + requirements := require.New(t) srv, path := newSettingsTestServer(t, "[integrations.tasks]\nendpoint = \"https://tasks.example.com/api\"\napi_key = \"task-secret\"\n") resp := patchSettings(t, srv, `{"updates":[{"key":"integrations.tasks.endpoint","value":{"string":"https://elsewhere.example.net/api"}}]}`) - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) got, err := os.ReadFile(path) - require.NoError(err) - assert.Contains(string(got), "endpoint = \"https://elsewhere.example.net/api\"") - assert.Contains(string(got), "api_key = \"\"") - assert.NotContains(string(got), "task-secret") + requirements.NoError(err) + assertions.Contains(string(got), "endpoint = \"https://elsewhere.example.net/api\"") + assertions.Contains(string(got), "api_key = \"\"") + assertions.NotContains(string(got), "task-secret") var body SettingsResponse - require.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + requirements.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) byKey := settingsByKey(body.Settings) - assert.Equal(&SecretSettingState{Configured: false}, byKey["integrations.tasks.api_key"].Secret) - assert.True(body.PendingRestart) + assertions.Equal(&SecretSettingState{Configured: false}, byKey["integrations.tasks.api_key"].Secret) + assertions.True(body.PendingRestart) } func TestPatchSettingsKeepsNewTaskAPIKeyProvidedWithEndpointChange(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertions := assert.New(t) + requirements := require.New(t) srv, path := newSettingsTestServer(t, "[integrations.tasks]\nendpoint = \"https://tasks.example.com/api\"\napi_key = \"task-secret\"\n") @@ -398,16 +951,16 @@ func TestPatchSettingsKeepsNewTaskAPIKeyProvidedWithEndpointChange(t *testing.T) `{"updates":[`+ `{"key":"integrations.tasks.endpoint","value":{"string":"https://elsewhere.example.net/api"}},`+ `{"key":"integrations.tasks.api_key","secret":{"action":"set","value":"rotated-secret"}}]}`) - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) got, err := os.ReadFile(path) - require.NoError(err) - assert.Contains(string(got), "endpoint = \"https://elsewhere.example.net/api\"") - assert.Contains(string(got), "api_key = \"rotated-secret\"") + requirements.NoError(err) + assertions.Contains(string(got), "endpoint = \"https://elsewhere.example.net/api\"") + assertions.Contains(string(got), "api_key = \"rotated-secret\"") var body SettingsResponse - require.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) - assert.Equal(&SecretSettingState{Configured: true}, + requirements.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + assertions.Equal(&SecretSettingState{Configured: true}, settingsByKey(body.Settings)["integrations.tasks.api_key"].Secret) } @@ -421,124 +974,123 @@ func TestPatchSettingsRetainsTaskAPIKeyWhenEndpointOriginIsUnchanged(t *testing. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertions := assert.New(t) + requirements := require.New(t) srv, path := newSettingsTestServer(t, "[integrations.tasks]\nendpoint = \"https://tasks.example.com/api\"\napi_key = \"task-secret\"\n") resp := patchSettings(t, srv, fmt.Sprintf( `{"updates":[{"key":"integrations.tasks.endpoint","value":{"string":%q}}]}`, tt.endpoint)) - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) got, err := os.ReadFile(path) - require.NoError(err) - assert.Contains(string(got), "api_key = \"task-secret\"") + requirements.NoError(err) + assertions.Contains(string(got), "api_key = \"task-secret\"") var body SettingsResponse - require.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) - assert.Equal(&SecretSettingState{Configured: true}, + requirements.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + assertions.Equal(&SecretSettingState{Configured: true}, settingsByKey(body.Settings)["integrations.tasks.api_key"].Secret) }) } } func TestPatchSettingsEndpointChangeWithoutStoredCredentialAddsNoKey(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertions := assert.New(t) + requirements := require.New(t) srv, path := newSettingsTestServer(t, "[integrations.tasks]\nendpoint = \"https://tasks.example.com/api\"\n") resp := patchSettings(t, srv, `{"updates":[{"key":"integrations.tasks.endpoint","value":{"string":"https://elsewhere.example.net/api"}}]}`) - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) got, err := os.ReadFile(path) - require.NoError(err) - assert.Contains(string(got), "endpoint = \"https://elsewhere.example.net/api\"") - assert.NotContains(string(got), "api_key") + requirements.NoError(err) + assertions.Contains(string(got), "endpoint = \"https://elsewhere.example.net/api\"") + assertions.NotContains(string(got), "api_key") } -func TestPatchSettingsClearsEmbeddingsAPIKeyEnvWhenEndpointOriginChanges(t *testing.T) { - assert := assert.New(t) - require := require.New(t) +func TestPatchSettingsRetainsEmbeddingsAPIKeyEnvWhenEndpointOriginChanges(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) srv, path := newSettingsTestServer(t, "[vector.embeddings]\nendpoint = \"https://embed.example.com/v1\"\napi_key_env = \"MSGVAULT_EMBED_API_KEY\"\n") resp := patchSettings(t, srv, `{"updates":[{"key":"vector.embeddings.endpoint","value":{"string":"https://elsewhere.example.net/v1"}}]}`) - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) got, err := os.ReadFile(path) - require.NoError(err) - assert.Contains(string(got), "endpoint = \"https://elsewhere.example.net/v1\"") - assert.Contains(string(got), "api_key_env = \"\"") + requirements.NoError(err) + assertions.Contains(string(got), "endpoint = \"https://elsewhere.example.net/v1\"") + assertions.Contains(string(got), "api_key_env = \"MSGVAULT_EMBED_API_KEY\"") var body SettingsResponse - require.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + requirements.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) byKey := settingsByKey(body.Settings) - require.NotNil(byKey["vector.embeddings.api_key_env"].Value) - require.NotNil(byKey["vector.embeddings.api_key_env"].Value.String) - assert.Empty(*byKey["vector.embeddings.api_key_env"].Value.String) + requirements.NotNil(byKey["vector.embeddings.api_key_env"].Value) + requirements.NotNil(byKey["vector.embeddings.api_key_env"].Value.String) + assertions.Equal("MSGVAULT_EMBED_API_KEY", *byKey["vector.embeddings.api_key_env"].Value.String) } -func TestPatchSettingsClearsMultimodalAPIKeyEnvWhenEndpointOriginChanges(t *testing.T) { - require := require.New(t) - assert := assert.New(t) +func TestPatchSettingsRetainsMultimodalAPIKeyEnvWhenEndpointOriginChanges(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) srv, path := newSettingsTestServer(t, "[vector.multimodal]\nendpoint = \"https://api.voyageai.com/v1\"\n"+ "api_key_env = \"SYNTHETIC_VOYAGE_KEY\"\n") resp := patchSettings(t, srv, `{"updates":[{"key":"vector.multimodal.endpoint","value":{"string":"https://voyage.example.test/v1"}}]}`) - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) got, err := os.ReadFile(path) - require.NoError(err) - assert.Contains(string(got), `endpoint = "https://voyage.example.test/v1"`) - assert.Contains(string(got), `api_key_env = ""`) - assert.NotContains(string(got), "SYNTHETIC_VOYAGE_KEY") + requirements.NoError(err) + assertions.Contains(string(got), `endpoint = "https://voyage.example.test/v1"`) + assertions.Contains(string(got), `api_key_env = "SYNTHETIC_VOYAGE_KEY"`) var body SettingsResponse - require.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + requirements.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) keySetting := settingsByKey(body.Settings)["vector.multimodal.api_key_env"] - require.NotNil(keySetting.Value) - require.NotNil(keySetting.Value.String) - assert.Empty(*keySetting.Value.String) + requirements.NotNil(keySetting.Value) + requirements.NotNil(keySetting.Value.String) + assertions.Equal("SYNTHETIC_VOYAGE_KEY", *keySetting.Value.String) } func TestPatchSettingsEditableChangeSucceedsWhileReadOnlySettingIsConfigured(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertions := assert.New(t) + requirements := require.New(t) srv, path := newSettingsTestServer(t, "[web]\ntheme = \"system\"\n"+ "[vector.embeddings]\napi_key_env = \"MSGVAULT_EMBED_API_KEY\"\n") resp := patchSettings(t, srv, `{"updates":[{"key":"web.theme","value":{"string":"dark"}}]}`) - require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + requirements.Equal(http.StatusOK, resp.Code, resp.Body.String()) got, err := os.ReadFile(path) - require.NoError(err) - assert.Contains(string(got), "theme = \"dark\"") - assert.Contains(string(got), "api_key_env = \"MSGVAULT_EMBED_API_KEY\"") + requirements.NoError(err) + assertions.Contains(string(got), "theme = \"dark\"") + assertions.Contains(string(got), "api_key_env = \"MSGVAULT_EMBED_API_KEY\"") var body SettingsResponse - require.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) - assert.True(settingsByKey(body.Settings)["vector.embeddings.api_key_env"].ReadOnly) + requirements.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + assertions.True(settingsByKey(body.Settings)["vector.embeddings.api_key_env"].ReadOnly) } func TestPatchSettingsRejectsEmbeddingsAPIKeyEnvUpdates(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertions := assert.New(t) + requirements := require.New(t) before := "[vector.embeddings]\nendpoint = \"https://embed.example.com/v1\"\napi_key_env = \"MSGVAULT_EMBED_API_KEY\"\n" srv, path := newSettingsTestServer(t, before) resp := patchSettings(t, srv, `{"updates":[{"key":"vector.embeddings.api_key_env","value":{"string":"AWS_SECRET_ACCESS_KEY"}}]}`) - require.Equal(http.StatusBadRequest, resp.Code, resp.Body.String()) - assert.Contains(resp.Body.String(), "edit config.toml") + requirements.Equal(http.StatusBadRequest, resp.Code, resp.Body.String()) + assertions.Contains(resp.Body.String(), "host-managed") got, err := os.ReadFile(path) - require.NoError(err) - assert.Equal(before, string(got)) + requirements.NoError(err) + assertions.Equal(before, string(got)) } func TestSettingsErrorsAreNotCached(t *testing.T) { @@ -588,33 +1140,33 @@ func TestPatchSettingsClassifiesFilesystemFailureAsServerError(t *testing.T) { } func TestPatchSettingsMarksRestartPendingWhenPublishedWriteReturnsError(t *testing.T) { - assert := assert.New(t) - require := require.New(t) - srv, path := newSettingsTestServer(t, "[web]\ntheme = \"system\"\n") + assertions := assert.New(t) + requirements := require.New(t) + srv, path := newSettingsTestServer(t, "[server]\ndaemon_idle_timeout = \"15m\"\n") get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") srv.settingsConfigEditor = func(configPath, ifMatch string, edits []config.Edit) (config.ConfigFile, error) { - require.Equal(path, configPath) - require.Equal(get.Header().Get("ETag"), ifMatch) - require.Len(edits, 1) - require.NoError(os.WriteFile(path, []byte("[web]\ntheme = \"dark\"\n"), 0o600)) + requirements.Equal(path, configPath) + requirements.Equal(get.Header().Get("ETag"), ifMatch) + requirements.Len(edits, 1) + requirements.NoError(os.WriteFile(path, []byte("[server]\ndaemon_idle_timeout = \"1h\"\n"), 0o600)) return config.ConfigFile{}, fmt.Errorf("%w: cleanup failed", config.ErrConfigChanged) } patch := performSettingsRequest(t, srv, http.MethodPatch, settingsPath, - []byte(`{"updates":[{"key":"web.theme","value":{"string":"dark"}}]}`), + []byte(`{"updates":[{"key":"server.daemon_idle_timeout","value":{"string":"1h"}}]}`), get.Header().Get("ETag"), "") - assert.Equal(http.StatusInternalServerError, patch.Code, patch.Body.String()) + assertions.Equal(http.StatusInternalServerError, patch.Code, patch.Body.String()) after := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") - require.Equal(http.StatusOK, after.Code, after.Body.String()) + requirements.Equal(http.StatusOK, after.Code, after.Body.String()) var persisted SettingsResponse - require.NoError(json.Unmarshal(after.Body.Bytes(), &persisted)) - assert.True(persisted.PendingRestart) + requirements.NoError(json.Unmarshal(after.Body.Bytes(), &persisted)) + assertions.True(persisted.PendingRestart) } func TestPatchSettingsMarksRestartPendingBeforeLoadingCommittedSnapshot(t *testing.T) { - assert := assert.New(t) - srv, _ := newSettingsTestServer(t, "[web]\ntheme = \"system\"\n") + assertions := assert.New(t) + srv, _ := newSettingsTestServer(t, "[server]\ndaemon_idle_timeout = \"15m\"\n") get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") srv.settingsConfigEditor = func(string, string, []config.Edit) (config.ConfigFile, error) { return config.ConfigFile{ @@ -627,10 +1179,10 @@ func TestPatchSettingsMarksRestartPendingBeforeLoadingCommittedSnapshot(t *testi } patch := performSettingsRequest(t, srv, http.MethodPatch, settingsPath, - []byte(`{"updates":[{"key":"web.theme","value":{"string":"dark"}}]}`), + []byte(`{"updates":[{"key":"server.daemon_idle_timeout","value":{"string":"1h"}}]}`), get.Header().Get("ETag"), "") - assert.Equal(http.StatusInternalServerError, patch.Code, patch.Body.String()) - assert.True(srv.settingsPendingRestart.Load()) + assertions.Equal(http.StatusInternalServerError, patch.Code, patch.Body.String()) + assertions.True(srv.settingsPendingRestart.Load()) } func TestPatchSettingsPrefersChangedOutcomeOverConflictClassification(t *testing.T) { @@ -647,40 +1199,46 @@ func TestPatchSettingsPrefersChangedOutcomeOverConflictClassification(t *testing } func TestSettingsOpenAPIContract(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertions := assert.New(t) + requirements := require.New(t) doc := OpenAPIDocument() - require.NotNil(doc.Paths[settingsPath]) + requirements.NotNil(doc.Paths[settingsPath]) get := doc.Paths[settingsPath].Get patch := doc.Paths[settingsPath].Patch - require.NotNil(get) - require.NotNil(patch) - assert.Contains(get.Responses["200"].Headers, "ETag") - require.Len(patch.Parameters, 1) - assert.Equal("If-Match", patch.Parameters[0].Name) - assert.Equal("header", patch.Parameters[0].In) - assert.True(patch.Parameters[0].Required) + requirements.NotNil(get) + requirements.NotNil(patch) + assertions.Contains(get.Responses["200"].Headers, "ETag") + requirements.Len(patch.Parameters, 1) + assertions.Equal("If-Match", patch.Parameters[0].Name) + assertions.Equal("header", patch.Parameters[0].In) + assertions.True(patch.Parameters[0].Required) for _, status := range []string{"400", "409", "412", "422", "428"} { - assert.Contains(patch.Responses, status) + assertions.Contains(patch.Responses, status) } - assert.Equal(APISchemaVersion, doc.Info.Version) + assertions.Equal(APISchemaVersion, doc.Info.Version) settingValue := doc.Components.Schemas.Map()["SettingValue"] - require.NotNil(settingValue) - assert.Len(settingValue.OneOf, 5) - assert.Empty(settingValue.Properties) + requirements.NotNil(settingValue) + assertions.Len(settingValue.OneOf, 5) + assertions.Empty(settingValue.Properties) for _, arm := range settingValue.OneOf { - assert.Len(arm.Required, 1) - assert.Equal([]string{arm.Required[0]}, arm.Required) - assert.Equal(false, arm.AdditionalProperties) + assertions.Len(arm.Required, 1) + assertions.Equal([]string{arm.Required[0]}, arm.Required) + assertions.Equal(false, arm.AdditionalProperties) } setting := doc.Components.Schemas.Map()["Setting"] - require.NotNil(setting) - assert.ElementsMatch([]any{"browser", "server", "archive", "search", "sources", "integrations"}, setting.Properties["group"].Enum) - assert.ElementsMatch([]any{"string", "integer", "number", "boolean", "string_array", "secret"}, setting.Properties["kind"].Enum) + requirements.NotNil(setting) + assertions.ElementsMatch([]any{ + "browser", "server", "archive", "sync", "logging", "search", "sources", "attachments", + "activity", "backup", "enrichment", "integrations", + }, setting.Properties["group"].Enum) + assertions.ElementsMatch([]any{"string", "integer", "number", "boolean", "string_array", "secret"}, setting.Properties["kind"].Enum) patchRequest := doc.Components.Schemas.Map()["SettingsPatchRequest"] - require.NotNil(patchRequest) - assert.False(patchRequest.Properties["updates"].Nullable) + requirements.NotNil(patchRequest) + assertions.False(patchRequest.Properties["updates"].Nullable) + settingsResponse := doc.Components.Schemas.Map()["SettingsResponse"] + requirements.NotNil(settingsResponse) + assertions.False(settingsResponse.Properties["groups"].Nullable) } func newSettingsTestServer(t *testing.T, content string) (*Server, string) { @@ -694,7 +1252,6 @@ func newSettingsTestServer(t *testing.T, content string) (*Server, string) { return NewServer(cfg, nil, nil, logger), path } -//nolint:unparam // Keep the actual route visible at each HTTP test call site. func performSettingsRequest( t *testing.T, srv *Server, @@ -738,3 +1295,405 @@ func settingsByKey(settings []Setting) map[string]Setting { } return result } + +func rawEmbeddingSecretState(t *testing.T, body []byte) map[string]any { + t.Helper() + var document struct { + Settings []struct { + Key string `json:"key"` + Secret map[string]any `json:"secret"` + } `json:"settings"` + } + require.NoError(t, json.Unmarshal(body, &document)) + for _, setting := range document.Settings { + if setting.Key == "vector.embeddings.api_key" { + return setting.Secret + } + } + require.FailNow(t, "setting not found", "vector.embeddings.api_key") + return nil +} + +const settingsStoredVectorConfig = `[vector.embeddings] +endpoint = "https://first.example.test/v1" +api_key_env = "UNSET_TEXT_KEY" +model = "synthetic-model" +dimension = 8 +` + +func storeVectorEmbeddingsCredential(t *testing.T, srv *Server, value string) string { + t.Helper() + get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + require.Equal(t, http.StatusOK, get.Code, get.Body.String()) + set := performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/provider-credentials/vector.embeddings", + []byte(`{"value":"`+value+`"}`), get.Header().Get("Credential-Etag"), "") + require.Equal(t, http.StatusOK, set.Code, set.Body.String()) + return set.Header().Get("ETag") +} + +func TestPatchSettingsSeversStoredCredentialOnlyWhenEndpointOriginChanges(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, _ := newSettingsTestServer(t, settingsStoredVectorConfig) + storeVectorEmbeddingsCredential(t, srv, "origin-bound-secret") + + samePath := patchSettings(t, srv, + `{"updates":[{"key":"vector.embeddings.endpoint","value":{"string":"https://first.example.test/v2"}}]}`) + requirements.Equal(http.StatusOK, samePath.Code, samePath.Body.String()) + assertions.Equal(map[string]any{"configured": true, "source": "stored"}, + rawEmbeddingSecretState(t, samePath.Body.Bytes())) + retained, err := providercredentials.Read(srv.cfg.TokensDir()) + requirements.NoError(err) + assertions.True(retained.Stored(providercredentials.VectorEmbeddingsID)) + + moved := patchSettings(t, srv, + `{"updates":[{"key":"vector.embeddings.endpoint","value":{"string":"https://second.example.test/v1"}}]}`) + requirements.Equal(http.StatusOK, moved.Code, moved.Body.String()) + assertions.Equal(map[string]any{"configured": false, "source": "none"}, + rawEmbeddingSecretState(t, moved.Body.Bytes())) + assertions.NotContains(moved.Body.String(), "origin-bound-secret") + severed, err := providercredentials.Read(srv.cfg.TokensDir()) + requirements.NoError(err) + assertions.False(severed.Stored(providercredentials.VectorEmbeddingsID)) + assertions.Equal(severed.ETag, moved.Header().Get("Credential-Etag")) + + after := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, after.Code, after.Body.String()) + assertions.Equal(map[string]any{"configured": false, "source": "none"}, + rawEmbeddingSecretState(t, after.Body.Bytes())) + credentialBytes, err := os.ReadFile(filepath.Join(srv.cfg.TokensDir(), providercredentials.Filename)) + requirements.NoError(err) + assertions.NotContains(string(credentialBytes), "origin-bound-secret") +} + +func TestDeleteProviderCredentialOutlivesConfiguredProvider(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, path := newSettingsTestServer(t, settingsStoredVectorConfig) + credentialETag := storeVectorEmbeddingsCredential(t, srv, "orphaned-secret") + + requirements.NoError(os.WriteFile(path, []byte("[analytics]\nengine = \"auto\"\n"), 0o600)) + + deleted := performSettingsRequest(t, srv, http.MethodDelete, + "/api/v1/settings/provider-credentials/vector.embeddings", nil, credentialETag, "") + requirements.Equal(http.StatusOK, deleted.Code, deleted.Body.String()) + var response ProviderCredentialResponse + requirements.NoError(json.Unmarshal(deleted.Body.Bytes(), &response)) + assertions.Equal(SecretSettingState{Configured: false, Source: "none"}, response.State) + remaining, err := providercredentials.Read(srv.cfg.TokensDir()) + requirements.NoError(err) + assertions.False(remaining.Stored(providercredentials.VectorEmbeddingsID)) + assertions.Equal(remaining.ETag, deleted.Header().Get("ETag")) + + missing := performSettingsRequest(t, srv, http.MethodDelete, + "/api/v1/settings/provider-credentials/vector.embeddings", nil, remaining.ETag, "") + assertions.Equal(http.StatusNotFound, missing.Code, missing.Body.String()) + assertions.Contains(missing.Body.String(), "credential_not_found") +} + +func TestPutPersonEnrichmentProviderSeversStoredCredentialWhenOriginChanges(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, _ := newSettingsTestServer(t, `[people.enrichment] +enabled = false +suppression_key_env = "SUPPRESSION_KEY" + +[[people.enrichment.providers]] +name = "exa-primary" +kind = "exa" +enabled = false +endpoint = "https://api.exa.example/search" +api_key_env = "EXA_KEY" +allowed_identifiers = ["name", "email"] +target_keys = ["attribute:bio"] +retention_posture = "zero_retention" +training_posture = "no_training" +refresh_interval = "24h" +max_requests_per_run = 10 +max_requests_per_day = 100 +`) + get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, get.Code, get.Body.String()) + credentialID := providercredentials.PersonEnrichmentID("exa-primary") + set := performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/provider-credentials/"+url.PathEscape(credentialID), + []byte(`{"value":"exa-origin-secret"}`), get.Header().Get("Credential-Etag"), "") + requirements.Equal(http.StatusOK, set.Code, set.Body.String()) + + providerUpdate := func(endpoint string) []byte { + return []byte(`{"kind":"exa","enabled":false,"endpoint":"` + endpoint + `","mode":"people",` + + `"allowed_identifiers":["name","email"],"target_keys":["attribute:bio"],` + + `"allow_sensitive_targets":false,"retention_posture":"zero_retention",` + + `"training_posture":"no_training","refresh_interval":"24h","request_timeout":"1m",` + + `"max_retries":5,"max_requests_per_run":20,"max_requests_per_day":100}`) + } + samePath := performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/person-enrichment/providers/exa-primary", + providerUpdate("https://api.exa.example/v2/search"), get.Header().Get("ETag"), "") + requirements.Equal(http.StatusOK, samePath.Code, samePath.Body.String()) + var retainedResponse SettingsResponse + requirements.NoError(json.Unmarshal(samePath.Body.Bytes(), &retainedResponse)) + requirements.Len(retainedResponse.PersonEnrichmentProviders, 1) + assertions.Equal(&SecretSettingState{Configured: true, Source: "stored"}, + retainedResponse.PersonEnrichmentProviders[0].Credential) + + moved := performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/person-enrichment/providers/exa-primary", + providerUpdate("https://elsewhere.example/search"), samePath.Header().Get("ETag"), "") + requirements.Equal(http.StatusOK, moved.Code, moved.Body.String()) + var severedResponse SettingsResponse + requirements.NoError(json.Unmarshal(moved.Body.Bytes(), &severedResponse)) + requirements.Len(severedResponse.PersonEnrichmentProviders, 1) + assertions.Equal(&SecretSettingState{Configured: false, Source: "none"}, + severedResponse.PersonEnrichmentProviders[0].Credential) + assertions.NotContains(moved.Body.String(), "exa-origin-secret") + severed, err := providercredentials.Read(srv.cfg.TokensDir()) + requirements.NoError(err) + assertions.False(severed.Stored(credentialID)) + assertions.Equal(severed.ETag, moved.Header().Get("Credential-Etag")) +} + +func TestPutPersonEnrichmentProviderRollsBackConfigWhenCredentialCleanupConflicts(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + const before = `[people.enrichment] +enabled = false + +[[people.enrichment.providers]] +name = "exa-primary" +kind = "exa" +enabled = false +endpoint = "https://api.exa.example/search" +api_key_env = "EXA_KEY" +allowed_identifiers = ["name", "email"] +target_keys = ["attribute:bio"] +retention_posture = "zero_retention" +training_posture = "no_training" +refresh_interval = "24h" +max_requests_per_run = 10 +max_requests_per_day = 100 +` + srv, path := newSettingsTestServer(t, before) + get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, get.Code, get.Body.String()) + credentialID := providercredentials.PersonEnrichmentID("exa-primary") + set := performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/provider-credentials/"+url.PathEscape(credentialID), + []byte(`{"value":"origin-bound-secret"}`), get.Header().Get("Credential-Etag"), "") + requirements.Equal(http.StatusOK, set.Code, set.Body.String()) + srv.settingsCredentialDeleter = func( + string, providercredentials.Snapshot, []string, + ) (providercredentials.Snapshot, error) { + return providercredentials.Snapshot{}, providercredentials.ErrConflict + } + + update := []byte(`{"kind":"exa","enabled":false,"endpoint":"https://elsewhere.example/search",` + + `"mode":"people","allowed_identifiers":["name","email"],"target_keys":["attribute:bio"],` + + `"allow_sensitive_targets":false,"retention_posture":"zero_retention",` + + `"training_posture":"no_training","refresh_interval":"24h","request_timeout":"1m",` + + `"max_retries":5,"max_requests_per_run":20,"max_requests_per_day":100}`) + conflict := performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/person-enrichment/providers/exa-primary", + update, get.Header().Get("ETag"), "") + assertions.Equal(http.StatusPreconditionFailed, conflict.Code, conflict.Body.String()) + + written, err := os.ReadFile(path) + requirements.NoError(err) + assertions.Equal(before, string(written)) + credentials, err := providercredentials.Read(srv.cfg.TokensDir()) + requirements.NoError(err) + value, state, err := credentials.Resolve( + credentialID, "https://api.exa.example/search", "", nil, + ) + requirements.NoError(err) + assertions.Equal("origin-bound-secret", value) + assertions.True(state.Configured) + assertions.False(srv.settingsPendingRestart.Load()) +} + +func TestPatchSettingsRemovesStaleStoredCredentialOnLaterWrite(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, path := newSettingsTestServer(t, settingsStoredVectorConfig) + storeVectorEmbeddingsCredential(t, srv, "stale-origin-secret") + + // The endpoint moves without the API severing the credential, as after an + // interrupted cleanup or a host edit of config.toml. + moved := strings.Replace(settingsStoredVectorConfig, + "https://first.example.test/v1", "https://second.example.test/v1", 1) + requirements.NoError(os.WriteFile(path, []byte(moved), 0o600)) + + before := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, before.Code, before.Body.String()) + assertions.Equal(map[string]any{"configured": false, "source": "none"}, + rawEmbeddingSecretState(t, before.Body.Bytes())) + stale, err := providercredentials.Read(srv.cfg.TokensDir()) + requirements.NoError(err) + requirements.True(stale.Stored(providercredentials.VectorEmbeddingsID)) + + unrelated := patchSettings(t, srv, + `{"updates":[{"key":"log.level","value":{"string":"debug"}}]}`) + requirements.Equal(http.StatusOK, unrelated.Code, unrelated.Body.String()) + assertions.NotContains(unrelated.Body.String(), "stale-origin-secret") + severed, err := providercredentials.Read(srv.cfg.TokensDir()) + requirements.NoError(err) + assertions.False(severed.Stored(providercredentials.VectorEmbeddingsID)) + assertions.Equal(severed.ETag, unrelated.Header().Get("Credential-Etag")) +} + +func TestPatchSettingsRemovesStaleNamedProviderCredentialsAfterHostEdit(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + const initial = `[people.enrichment] +enabled = false + +[[people.enrichment.providers]] +name = "removed-provider" +kind = "exa" +enabled = false +endpoint = "https://removed.example/search" +allowed_identifiers = ["name"] +target_keys = ["attribute:bio"] +retention_posture = "zero_retention" +training_posture = "no_training" +refresh_interval = "24h" +max_requests_per_run = 10 +max_requests_per_day = 100 + +[[people.enrichment.providers]] +name = "moved-provider" +kind = "exa" +enabled = false +endpoint = "https://first.example/search" +allowed_identifiers = ["name"] +target_keys = ["attribute:bio"] +retention_posture = "zero_retention" +training_posture = "no_training" +refresh_interval = "24h" +max_requests_per_run = 10 +max_requests_per_day = 100 +` + srv, path := newSettingsTestServer(t, initial) + get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, get.Code, get.Body.String()) + removedID := providercredentials.PersonEnrichmentID("removed-provider") + movedID := providercredentials.PersonEnrichmentID("moved-provider") + stored := performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/provider-credentials/"+url.PathEscape(removedID), + []byte(`{"value":"removed-provider-secret"}`), get.Header().Get("Credential-Etag"), "") + requirements.Equal(http.StatusOK, stored.Code, stored.Body.String()) + stored = performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/provider-credentials/"+url.PathEscape(movedID), + []byte(`{"value":"moved-provider-secret"}`), stored.Header().Get("ETag"), "") + requirements.Equal(http.StatusOK, stored.Code, stored.Body.String()) + + const hostEdited = `[people.enrichment] +enabled = false + +[[people.enrichment.providers]] +name = "moved-provider" +kind = "exa" +enabled = false +endpoint = "https://second.example/search" +allowed_identifiers = ["name"] +target_keys = ["attribute:bio"] +retention_posture = "zero_retention" +training_posture = "no_training" +refresh_interval = "24h" +max_requests_per_run = 10 +max_requests_per_day = 100 +` + requirements.NoError(os.WriteFile(path, []byte(hostEdited), 0o600)) + stale, err := providercredentials.Read(srv.cfg.TokensDir()) + requirements.NoError(err) + requirements.True(stale.Stored(removedID)) + requirements.True(stale.Stored(movedID)) + + unrelated := patchSettings(t, srv, + `{"updates":[{"key":"log.level","value":{"string":"debug"}}]}`) + requirements.Equal(http.StatusOK, unrelated.Code, unrelated.Body.String()) + cleaned, err := providercredentials.Read(srv.cfg.TokensDir()) + requirements.NoError(err) + assertions.False(cleaned.Stored(removedID)) + assertions.False(cleaned.Stored(movedID)) + assertions.Equal(cleaned.ETag, unrelated.Header().Get("Credential-Etag")) +} + +func TestPutPersonEnrichmentProviderRequiresValidKindEvenWhenDisabled(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, path := newSettingsTestServer(t, "[people.enrichment]\nenabled = false\n") + get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, get.Code, get.Body.String()) + + for _, kind := range []string{"", "other"} { + body := []byte(`{"kind":"` + kind + `","enabled":false,"endpoint":"https://api.exa.example/search",` + + `"allowed_identifiers":["name"],"target_keys":["attribute:bio"],` + + `"allow_sensitive_targets":false,"retention_posture":"zero_retention",` + + `"training_posture":"no_training","refresh_interval":"24h","request_timeout":"1m",` + + `"max_retries":5,"max_requests_per_run":20,"max_requests_per_day":100}`) + rejected := performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/person-enrichment/providers/new-provider", body, get.Header().Get("ETag"), "") + assertions.Equal(http.StatusUnprocessableEntity, rejected.Code, rejected.Body.String()) + assertions.Contains(rejected.Body.String(), "validation_failed") + } + written, err := os.ReadFile(path) + requirements.NoError(err) + assertions.NotContains(string(written), "new-provider") +} + +func TestSettingsReadsAndRepairsInvalidDisabledPersonEnrichmentProvider(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + srv, _ := newSettingsTestServer(t, `[people.enrichment] +enabled = false + +[[people.enrichment.providers]] +name = "exa-primary" +kind = "exa" +enabled = false +endpoint = "https://provider.example/search?token=example-credential" +api_key_env = "EXA_KEY" +allowed_identifiers = ["name"] +target_keys = ["attribute:bio"] +retention_posture = "zero_retention" +training_posture = "no_training" +refresh_interval = "24h" +max_requests_per_run = 10 +max_requests_per_day = 100 +`) + + get := performSettingsRequest(t, srv, http.MethodGet, settingsPath, nil, "", "") + requirements.Equal(http.StatusOK, get.Code, get.Body.String()) + assertions.NotContains(get.Body.String(), "token=example-credential") + var before SettingsResponse + requirements.NoError(json.Unmarshal(get.Body.Bytes(), &before)) + requirements.Len(before.PersonEnrichmentProviders, 1) + assertions.Empty(before.PersonEnrichmentProviders[0].Endpoint) + assertions.Nil(before.PersonEnrichmentProviders[0].Credential) + + repaired := performSettingsRequest(t, srv, http.MethodPut, + "/api/v1/settings/person-enrichment/providers/exa-primary", []byte(`{ +"kind":"exa", +"enabled":false, +"endpoint":"https://api.exa.example/search", +"mode":"people", +"allowed_identifiers":["name"], +"target_keys":["attribute:bio"], +"allow_sensitive_targets":false, +"retention_posture":"zero_retention", +"training_posture":"no_training", +"refresh_interval":"24h", +"request_timeout":"1m", +"max_retries":5, +"max_requests_per_run":10, +"max_requests_per_day":100 +}`), get.Header().Get("ETag"), "") + requirements.Equal(http.StatusOK, repaired.Code, repaired.Body.String()) + var after SettingsResponse + requirements.NoError(json.Unmarshal(repaired.Body.Bytes(), &after)) + requirements.Len(after.PersonEnrichmentProviders, 1) + assertions.Equal("https://api.exa.example/search", after.PersonEnrichmentProviders[0].Endpoint) + assertions.NotNil(after.PersonEnrichmentProviders[0].Credential) +} diff --git a/internal/carddav/conflict_projection.go b/internal/carddav/conflict_projection.go new file mode 100644 index 000000000..af1734686 --- /dev/null +++ b/internal/carddav/conflict_projection.go @@ -0,0 +1,165 @@ +package carddav + +import ( + "strings" + "time" + "unicode" + "unicode/utf8" + + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/vcard" +) + +const ( + maxPublicCardDAVValueBytes = 256 + maxPublicContactValues = 8 +) + +type ConflictSideState string + +const ( + ConflictSidePresent ConflictSideState = "present" + ConflictSideDeleted ConflictSideState = "deleted" + ConflictSideUnavailable ConflictSideState = "unavailable" +) + +type ContactSummary struct { + State ConflictSideState `json:"state"` + DisplayName string `json:"display_name,omitempty"` + Emails []string `json:"emails"` + Phones []string `json:"phones"` + Truncated bool `json:"truncated,omitempty"` +} + +type AddressBookIdentity struct { + ID int64 + Name string +} + +type ConflictListItem struct { + ID int64 + AddressBook AddressBookIdentity + Status store.CardDAVConflictStatus + LocalState ConflictSideState + RemoteState ConflictSideState + AllowedResolutions []ResolutionChoice + UpdatedAt time.Time +} + +type ConflictDetail struct { + ID int64 + AddressBook AddressBookIdentity + Status store.CardDAVConflictStatus + Resolution store.CardDAVConflictResolution + Base ContactSummary + Local ContactSummary + Remote ContactSummary + AllowedResolutions []ResolutionChoice + CreatedAt time.Time + UpdatedAt time.Time + ResolvedAt *time.Time +} + +func publicAddressBookIdentity(id int64, name string) AddressBookIdentity { + name = normalizePublicCardDAVText(name) + name, _ = capPublicCardDAVText(name) + return AddressBookIdentity{ID: id, Name: name} +} + +func emptyContactSummary(state ConflictSideState) ContactSummary { + return ContactSummary{State: state, Emails: []string{}, Phones: []string{}} +} + +func projectConflictContact(body []byte, tombstone bool) ContactSummary { + if tombstone { + return emptyContactSummary(ConflictSideDeleted) + } + envelope, err := vcard.ParseResourceEnvelope(body) + if err != nil { + return emptyContactSummary(ConflictSideUnavailable) + } + summary := emptyContactSummary(ConflictSidePresent) + emails := make(map[string]struct{}) + phones := make(map[string]struct{}) + for _, occurrence := range envelope.PropertyTree { + property := occurrence.Property + name := strings.ToUpper(property.Name) + if name != "FN" && name != "EMAIL" && name != "TEL" { + continue + } + value, err := cardDAVPropertyValue(envelope.RenderMetadata.StoredVersion, property) + if err != nil { + return emptyContactSummary(ConflictSideUnavailable) + } + switch name { + case "EMAIL": + value = trimPrefixFold(strings.TrimSpace(value), "mailto:") + case "TEL": + value = trimPrefixFold(strings.TrimSpace(value), "tel:") + } + value = normalizePublicCardDAVText(value) + if value == "" { + continue + } + if name == "FN" && summary.DisplayName != "" { + continue + } + dedupeKey := value + clipped, truncated := capPublicCardDAVText(value) + summary.Truncated = summary.Truncated || truncated + switch name { + case "FN": + if summary.DisplayName == "" { + summary.DisplayName = clipped + } + case "EMAIL": + if _, exists := emails[dedupeKey]; exists { + continue + } + if len(summary.Emails) == maxPublicContactValues { + summary.Truncated = true + continue + } + emails[dedupeKey] = struct{}{} + summary.Emails = append(summary.Emails, clipped) + case "TEL": + if _, exists := phones[dedupeKey]; exists { + continue + } + if len(summary.Phones) == maxPublicContactValues { + summary.Truncated = true + continue + } + phones[dedupeKey] = struct{}{} + summary.Phones = append(summary.Phones, clipped) + } + } + return summary +} + +func normalizePublicCardDAVText(value string) string { + var cleaned strings.Builder + cleaned.Grow(len(value)) + for _, r := range strings.ToValidUTF8(value, "") { + if unicode.IsSpace(r) { + cleaned.WriteByte(' ') + continue + } + if unicode.IsControl(r) { + continue + } + cleaned.WriteRune(r) + } + return strings.Join(strings.Fields(cleaned.String()), " ") +} + +func capPublicCardDAVText(value string) (string, bool) { + if len(value) <= maxPublicCardDAVValueBytes { + return value, false + } + end := maxPublicCardDAVValueBytes + for end > 0 && !utf8.RuneStart(value[end]) { + end-- + } + return value[:end], true +} diff --git a/internal/carddav/conflict_projection_test.go b/internal/carddav/conflict_projection_test.go new file mode 100644 index 000000000..9b981b182 --- /dev/null +++ b/internal/carddav/conflict_projection_test.go @@ -0,0 +1,81 @@ +package carddav + +import ( + "encoding/json" + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProjectConflictContactAllowListsNormalizesAndBoundsFields(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + long := strings.Repeat("界", 90) + raw := "BEGIN:VCARD\r\nVERSION:4.0\r\n" + + "UID:private-uid\r\nURL:https://dav.invalid/private\r\n" + + "NOTE:Authorization: Bearer private-token\r\nX-API-KEY:private-key\r\n" + + "FN:\t Alice\u0001 Example \r\nFN:Ignored Second Name\r\n" + + "EMAIL:MAILTO:alice@example.test\r\nEMAIL:alice@example.test\r\n" + + "EMAIL:" + long + "\r\n" + + "EMAIL:e2@example.test\r\nEMAIL:e3@example.test\r\nEMAIL:e4@example.test\r\n" + + "EMAIL:e5@example.test\r\nEMAIL:e6@example.test\r\nEMAIL:e7@example.test\r\nEMAIL:e8@example.test\r\n" + + "TEL:TEL:+1 555 0100\r\nTEL:+1 555 0100\r\nEND:VCARD\r\n" + + got := projectConflictContact([]byte(raw), false) + require.Equal(ConflictSidePresent, got.State) + assert.Equal("Alice Example", got.DisplayName) + require.Len(got.Emails, 8) + assert.Equal("alice@example.test", got.Emails[0]) + assert.Equal([]string{"+1 555 0100"}, got.Phones) + assert.True(got.Truncated) + assert.LessOrEqual(len(got.Emails[1]), 256) + assert.True(utf8.ValidString(got.Emails[1])) + + encoded, err := json.Marshal(got) + require.NoError(err) + text := string(encoded) + for _, forbidden := range []string{"private-uid", "dav.invalid", "Authorization", "private-token", "private-key", "X-API-KEY", "Ignored Second Name"} { + assert.NotContains(text, forbidden) + } +} + +func TestProjectConflictContactTombstoneAndMalformedAreFixed(t *testing.T) { + assert := assert.New(t) + deleted := projectConflictContact([]byte("credential=private-secret"), true) + assert.Equal(ContactSummary{State: ConflictSideDeleted, Emails: []string{}, Phones: []string{}}, deleted) + + unavailable := projectConflictContact([]byte("not a vcard private-parser-marker"), false) + assert.Equal(ContactSummary{State: ConflictSideUnavailable, Emails: []string{}, Phones: []string{}}, unavailable) + encoded, err := json.Marshal(unavailable) + require.NoError(t, err) + assert.NotContains(string(encoded), "private-parser-marker") +} + +func TestProjectConflictContactPreservesWhitespaceSeparatorsAndExactDeduplication(t *testing.T) { + assert := assert.New(t) + prefix := strings.Repeat("a", 256) + raw := "BEGIN:VCARD\r\nVERSION:4.0\r\n" + + "FN:Alice\\nExample\tPerson\r\n" + + "EMAIL:" + prefix + "x@example.test\r\n" + + "EMAIL:" + prefix + "y@example.test\r\n" + + "EMAIL:" + prefix + "x@example.test\r\n" + + "END:VCARD\r\n" + + got := projectConflictContact([]byte(raw), false) + assert.Equal("Alice Example Person", got.DisplayName) + require.Len(t, got.Emails, 2, "distinct normalized values sharing a clipped prefix are not duplicates") + assert.Equal(got.Emails[0], got.Emails[1]) + assert.Len(got.Emails[0], 256) + assert.True(got.Truncated) +} + +func TestProjectConflictContactIgnoresLaterFNWithoutReportingTruncation(t *testing.T) { + raw := "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:First Name\r\nFN:" + + strings.Repeat("private-later-name", 40) + "\r\nEND:VCARD\r\n" + got := projectConflictContact([]byte(raw), false) + assert.Equal(t, "First Name", got.DisplayName) + assert.False(t, got.Truncated) +} diff --git a/internal/carddav/conflicts.go b/internal/carddav/conflicts.go index 11dd7bf48..525c97138 100644 --- a/internal/carddav/conflicts.go +++ b/internal/carddav/conflicts.go @@ -31,6 +31,56 @@ func (e *ConflictError) Error() string { func (e *ConflictError) Unwrap() error { return ErrCardDAVConflictPending } +func (s *Service) ListConflictViews(ctx context.Context) ([]ConflictListItem, error) { + if s == nil || s.store == nil { + return nil, errors.New("CardDAV service is not configured") + } + headers, err := s.store.ListCardDAVConflictHeadersContext(ctx) + if err != nil { + return nil, err + } + items := make([]ConflictListItem, 0, len(headers)) + for _, header := range headers { + localState, remoteState := ConflictSidePresent, ConflictSidePresent + if header.LocalTombstone { + localState = ConflictSideDeleted + } + if header.RemoteTombstone { + remoteState = ConflictSideDeleted + } + items = append(items, ConflictListItem{ + ID: header.ID, AddressBook: publicAddressBookIdentity(header.AddressBookID, header.AddressBookName), + Status: header.Status, LocalState: localState, RemoteState: remoteState, + AllowedResolutions: allowedConflictResolutions(header.Status), UpdatedAt: header.UpdatedAt, + }) + } + return items, nil +} + +func (s *Service) GetConflictView(ctx context.Context, id int64) (*ConflictDetail, error) { + if s == nil || s.store == nil || id <= 0 { + return nil, errors.New("CardDAV service is not configured") + } + source, err := s.store.GetCardDAVConflictDetailSourceContext(ctx, id) + if err != nil { + return nil, err + } + base := emptyContactSummary(ConflictSideUnavailable) + if source.BaseAvailable { + base = projectConflictContact(source.BaseBody, false) + } + return &ConflictDetail{ + ID: source.ID, AddressBook: publicAddressBookIdentity(source.AddressBookID, source.AddressBookName), + Status: source.Status, Resolution: source.Resolution, Base: base, + Local: projectConflictContact(source.LocalBody, source.LocalTombstone), + Remote: projectConflictContact(source.RemoteBody, source.RemoteTombstone), + AllowedResolutions: allowedConflictResolutions(source.Status), + CreatedAt: source.CreatedAt, UpdatedAt: source.UpdatedAt, ResolvedAt: source.ResolvedAt, + }, nil +} + +// ListConflicts retains the internal mutation-evidence read used by CardDAV's +// own reconciliation tests and workflows. Browser APIs use ListConflictViews. func (s *Service) ListConflicts(ctx context.Context) ([]store.CardDAVConflict, error) { if s == nil || s.store == nil { return nil, errors.New("CardDAV service is not configured") @@ -45,6 +95,13 @@ func (s *Service) GetConflict(ctx context.Context, id int64) (*store.CardDAVConf return s.store.GetCardDAVConflictContext(ctx, id) } +func allowedConflictResolutions(status store.CardDAVConflictStatus) []ResolutionChoice { + if status == store.CardDAVConflictUnresolved { + return []ResolutionChoice{ResolutionKeepLocal, ResolutionKeepRemote} + } + return []ResolutionChoice{} +} + func (s *Service) ResolveConflict(ctx context.Context, id int64, choice ResolutionChoice) error { if choice != ResolutionKeepLocal && choice != ResolutionKeepRemote { return ErrInvalidResolutionChoice diff --git a/internal/carddav/conflicts_test.go b/internal/carddav/conflicts_test.go index 2ef123feb..ee489c1f7 100644 --- a/internal/carddav/conflicts_test.go +++ b/internal/carddav/conflicts_test.go @@ -101,6 +101,22 @@ func TestPullConflictBlocksOnlyMappingAndAdvancesBookFence(t *testing.T) { assert.Equal(alice.Href, conflicts[0].Href) assert.Contains(string(conflicts[0].LocalBody), "EMAIL:alice-local@example.test") assert.Equal(cards["alice"].body, conflicts[0].RemoteBody) + + views, err := service.ListConflictViews(t.Context()) + require.NoError(err) + require.Len(views, 1) + assert.Equal(book.ID, views[0].AddressBook.ID) + assert.NotContains(views[0].AddressBook.Name, "http") + assert.Equal(ConflictSidePresent, views[0].LocalState) + assert.Equal(ConflictSidePresent, views[0].RemoteState) + assert.Equal([]ResolutionChoice{ResolutionKeepLocal, ResolutionKeepRemote}, views[0].AllowedResolutions) + + detail, err := service.GetConflictView(t.Context(), conflicts[0].ID) + require.NoError(err) + assert.Equal(ConflictSidePresent, detail.Base.State) + assert.Equal("Alice Base", detail.Base.DisplayName) + assert.Equal("Alice Remote", detail.Remote.DisplayName) + assert.Contains(detail.Local.Emails, "alice-local@example.test") } func TestPullConflictCapturesLocalEditAgainstRemoteDelete(t *testing.T) { diff --git a/internal/carddav/credentials.go b/internal/carddav/credentials.go index b863a8d5a..7dbf47af4 100644 --- a/internal/carddav/credentials.go +++ b/internal/carddav/credentials.go @@ -1,6 +1,7 @@ package carddav import ( + "bytes" "encoding/json" "errors" "fmt" @@ -11,6 +12,8 @@ import ( const cardDAVTokenFilename = "carddav.json" // #nosec G101 -- This is a credential filename, not a credential value. +const maximumCredentialFileBytes = 1 << 20 + var ErrCredentialNotBound = errors.New("CardDAV credential is not bound to a connection") // Credential binds a password to the exact durable connection it may @@ -24,6 +27,14 @@ type Credential struct { ConnectionGeneration int64 `json:"connection_generation,omitempty"` } +// CredentialFileSnapshot retains the exact published credential bytes long +// enough for an account-save rollback. It deliberately does not decode or +// verify the file, so an explicit password can repair a malformed credential. +type CredentialFileSnapshot struct { + contents []byte + exists bool +} + type credentialPermissionBackend interface { secureDirectory(path string) error secureFile(file *os.File) error @@ -48,6 +59,37 @@ func SaveCredential(tokenDir string, credential Credential) error { return saveCredentialWithPermissions(tokenDir, credential, nativeCredentialPermissions{}) } +// CaptureCredentialFile snapshots the current credential without requiring it +// to be valid. A missing credential is a valid empty snapshot. +func CaptureCredentialFile(tokenDir string) (CredentialFileSnapshot, error) { + path := filepath.Join(tokenDir, cardDAVTokenFilename) + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return CredentialFileSnapshot{}, nil + } + if err != nil { + return CredentialFileSnapshot{}, fmt.Errorf("open CardDAV token file for rollback: %w", err) + } + defer file.Close() //nolint:errcheck // read-only file + contents, err := io.ReadAll(io.LimitReader(file, maximumCredentialFileBytes+1)) + if err != nil { + return CredentialFileSnapshot{}, fmt.Errorf("read CardDAV token file for rollback: %w", err) + } + if len(contents) > maximumCredentialFileBytes { + return CredentialFileSnapshot{}, errors.New("CardDAV token file exceeds rollback size limit") + } + return CredentialFileSnapshot{contents: contents, exists: true}, nil +} + +// Restore atomically restores the captured credential bytes, or removes a +// newly published credential when the snapshot represented a missing file. +func (s CredentialFileSnapshot) Restore(tokenDir string) error { + if !s.exists { + return RemoveCredential(tokenDir) + } + return saveCredentialBytesWithPermissions(tokenDir, s.contents, nativeCredentialPermissions{}) +} + // RemoveCredential removes a published CardDAV credential. Missing files are // already the desired state and therefore succeed. func RemoveCredential(tokenDir string) error { @@ -59,6 +101,15 @@ func RemoveCredential(tokenDir string) error { } func saveCredentialWithPermissions(tokenDir string, credential Credential, permissions credentialPermissionBackend) error { + var encoded bytes.Buffer + // #nosec G117 -- The credential is intentionally marshaled only into the private token-file buffer. + if err := json.NewEncoder(&encoded).Encode(credential); err != nil { + return fmt.Errorf("encode CardDAV token file: %w", err) + } + return saveCredentialBytesWithPermissions(tokenDir, encoded.Bytes(), permissions) +} + +func saveCredentialBytesWithPermissions(tokenDir string, contents []byte, permissions credentialPermissionBackend) error { if err := permissions.secureDirectory(tokenDir); err != nil { return fmt.Errorf("secure CardDAV token directory: %w", err) } @@ -78,9 +129,8 @@ func saveCredentialWithPermissions(tokenDir string, credential Credential, permi if err := permissions.secureFile(temporary); err != nil { return fmt.Errorf("secure CardDAV token file: %w", err) } - // #nosec G117 -- The credential is intentionally marshaled only into the already-hardened private token file. - if err := json.NewEncoder(temporary).Encode(credential); err != nil { - return fmt.Errorf("encode CardDAV token file: %w", err) + if _, err := temporary.Write(contents); err != nil { + return fmt.Errorf("write CardDAV token file: %w", err) } if err := temporary.Sync(); err != nil { return fmt.Errorf("sync CardDAV token file: %w", err) @@ -150,7 +200,7 @@ func loadCredentialWithPermissions(tokenDir string, permissions credentialPermis if err := permissions.verifyFile(file); err != nil { return Credential{}, fmt.Errorf("verify CardDAV token file permissions: %w", err) } - decoder := json.NewDecoder(io.LimitReader(file, 1<<20)) + decoder := json.NewDecoder(io.LimitReader(file, maximumCredentialFileBytes)) decoder.DisallowUnknownFields() var saved Credential if err := decoder.Decode(&saved); err != nil { diff --git a/internal/carddav/pull.go b/internal/carddav/pull.go index c7a8a83c6..e6f1fc864 100644 --- a/internal/carddav/pull.go +++ b/internal/carddav/pull.go @@ -39,7 +39,8 @@ func NewService(st *store.Store, client *Client) *Service { } type SyncOptions struct { - Full bool + Full bool + Trigger store.CardDAVSyncTrigger } type SyncResult struct { @@ -52,10 +53,43 @@ type SyncResult struct { // Sync fetches complete network plans before entering the store's fenced // apply transaction. A stale plan is re-fetched once; a second stale result is // returned rather than retried blindly. -func (s *Service) Sync(ctx context.Context, options SyncOptions) (SyncResult, error) { +// +// The run row is finished in a deferred call so that a panic escaping the +// pull still records a terminal state. Otherwise the row would stay running +// and every later sync would be refused as active until the daemon restarts. +func (s *Service) Sync(ctx context.Context, options SyncOptions) (result SyncResult, err error) { if s == nil || s.store == nil || s.client == nil { return SyncResult{}, errors.New("CardDAV service is not configured") } + trigger := options.Trigger + if trigger == "" { + trigger = store.CardDAVSyncTriggerManual + } + run, err := s.store.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{ + Trigger: trigger, + Full: options.Full, + }) + if err != nil { + return SyncResult{}, err + } + defer func() { + syncErr := err + recovered := recover() + if recovered != nil { + syncErr = fmt.Errorf("CardDAV sync panicked: %v", recovered) + } + _, finishErr := s.store.FinishCardDAVSyncRunContext( + context.WithoutCancel(ctx), run.ID, cardDAVSyncRunFinish(result, syncErr), + ) + if recovered != nil { + panic(recovered) + } + err = errors.Join(publicCardDAVSyncError(syncErr), finishErr) + }() + return s.sync(ctx, options) +} + +func (s *Service) sync(ctx context.Context, options SyncOptions) (SyncResult, error) { operationCtx, cancel := context.WithTimeout(ctx, s.client.operationTimeout) defer cancel() if err := s.store.CheckCardDAVRetryAfterContext(operationCtx); err != nil { @@ -108,6 +142,66 @@ func (s *Service) Sync(ctx context.Context, options SyncOptions) (SyncResult, er return total, errors.Join(failures...) } +type cardDAVSyncError struct { + cause error + message string +} + +func (e *cardDAVSyncError) Error() string { return e.message } +func (e *cardDAVSyncError) Unwrap() error { return e.cause } + +func publicCardDAVSyncError(err error) error { + if err == nil { + return nil + } + _, message := cardDAVSyncPublicFailure(err) + return &cardDAVSyncError{cause: err, message: message} +} + +func cardDAVSyncRunFinish(result SyncResult, err error) store.CardDAVSyncRunFinish { + finish := store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunSucceeded, + Books: int64(result.Books), + Created: int64(result.Created), + Updated: int64(result.Updated), + Removed: int64(result.Removed), + } + if err == nil { + return finish + } + finish.State = store.CardDAVSyncRunFailed + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + finish.State = store.CardDAVSyncRunCancelled + } else if result.Books > 0 || result.Created > 0 || result.Updated > 0 || result.Removed > 0 { + finish.State = store.CardDAVSyncRunPartial + } + finish.ErrorCode, finish.ErrorMessage = cardDAVSyncPublicFailure(err) + return finish +} + +func cardDAVSyncPublicFailure(err error) (string, string) { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return "cancelled", "CardDAV sync was cancelled." + } + if errors.Is(err, store.ErrCardDAVRetryAfter) { + return "retry_after", "CardDAV sync is temporarily paused." + } + if status, ok := errors.AsType[*StatusError](err); ok { + switch status.StatusCode { + case http.StatusUnauthorized: + return "authentication_failed", "CardDAV authentication failed." + case http.StatusTooManyRequests: + return "retry_after", "CardDAV sync is temporarily paused." + default: + return "upstream_failed", "CardDAV server request failed." + } + } + if errors.Is(err, ErrOperationLimit) || errors.Is(err, ErrResponseLimit) { + return "safety_limit", "CardDAV sync exceeded its safety limits." + } + return "sync_failed", "CardDAV sync failed." +} + func isGlobalSyncFailure(ctx context.Context, err error) bool { if err == nil { return false diff --git a/internal/carddav/pull_test.go b/internal/carddav/pull_test.go index a182d5e8f..ab8af643b 100644 --- a/internal/carddav/pull_test.go +++ b/internal/carddav/pull_test.go @@ -1,7 +1,9 @@ package carddav import ( + "context" "encoding/xml" + "errors" "fmt" "io" "net/http" @@ -212,6 +214,13 @@ func TestSyncContinuesAfterOneBookFailsAndReconcilesPublications(t *testing.T) { _, publicationErr := st.GetCardDAVPublicationContext(t.Context(), personID) require.ErrorIs(publicationErr, store.ErrCardDAVPublicationNotFound, "publication reconciliation must still run after an independent book failure") + runs, listErr := st.ListCardDAVSyncRunsContext(t.Context(), 10, nil) + require.NoError(listErr) + require.Len(runs, 1) + assert.Equal(store.CardDAVSyncRunPartial, runs[0].State) + assert.Equal(int64(result.Books), runs[0].Books) + assert.Equal("upstream_failed", runs[0].ErrorCode) + assert.Equal("CardDAV server request failed.", runs[0].ErrorMessage) } func newPullService(t *testing.T, server *httptest.Server, supportsSync bool) (*Service, *store.Store, store.CardDAVAddressBook) { @@ -310,6 +319,183 @@ func writeDAVXML(t *testing.T, w http.ResponseWriter, body string) { require.NoError(t, err) } +func TestSyncRecordsOneSucceededManualRunWithExactCounters(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := readRequestBody(t, r) + if strings.Contains(body, "sync-collection") { + writeDAVXML(t, w, syncResponse( + changedResponse("/books/personal/alice.vcf", `"one"`), "token-1", + )) + return + } + writeDAVXML(t, w, syncResponse( + cardResponse("/books/personal/alice.vcf", `"one"`, "alice"), "", + )) + })) + t.Cleanup(server.Close) + service, st, _ := newPullService(t, server, true) + + result, err := service.Sync(t.Context(), SyncOptions{Full: true}) + require.NoError(err) + assert.Equal(SyncResult{Books: 1, Created: 1}, result) + + runs, err := st.ListCardDAVSyncRunsContext(t.Context(), 10, nil) + require.NoError(err) + require.Len(runs, 1) + assert.Equal(store.CardDAVSyncTriggerManual, runs[0].Trigger) + assert.True(runs[0].Full) + assert.Equal(store.CardDAVSyncRunSucceeded, runs[0].State) + assert.NotNil(runs[0].FinishedAt) + assert.Equal(int64(result.Books), runs[0].Books) + assert.Equal(int64(result.Created), runs[0].Created) + assert.Equal(int64(result.Updated), runs[0].Updated) + assert.Equal(int64(result.Removed), runs[0].Removed) +} + +func TestSyncRecordsExplicitScheduledTrigger(t *testing.T) { + require := require.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeDAVXML(t, w, syncResponse("", "token-1")) + })) + t.Cleanup(server.Close) + service, st, _ := newPullService(t, server, true) + + _, err := service.Sync(t.Context(), SyncOptions{Trigger: store.CardDAVSyncTriggerScheduled}) + require.NoError(err) + runs, err := st.ListCardDAVSyncRunsContext(t.Context(), 10, nil) + require.NoError(err) + require.Len(runs, 1) + assert.Equal(t, store.CardDAVSyncTriggerScheduled, runs[0].Trigger) +} + +func TestSyncCancellationFinishesRunWithUncancelledCleanupContext(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + requestStarted := make(chan struct{}) + releaseRequest := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(requestStarted) + <-releaseRequest + })) + t.Cleanup(server.Close) + service, st, _ := newPullService(t, server, true) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + _, err := service.Sync(ctx, SyncOptions{}) + done <- err + }() + <-requestStarted + cancel() + + err := <-done + close(releaseRequest) + require.ErrorIs(err, context.Canceled) + runs, err := st.ListCardDAVSyncRunsContext(t.Context(), 10, nil) + require.NoError(err) + require.Len(runs, 1) + assert.Equal(store.CardDAVSyncRunCancelled, runs[0].State) + assert.Equal("cancelled", runs[0].ErrorCode) + assert.NotNil(runs[0].FinishedAt) +} + +func TestSyncActiveClaimPreventsNetworkAndSecondRun(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ })) + t.Cleanup(server.Close) + service, st, _ := newPullService(t, server, true) + _, err := st.StartCardDAVSyncRunContext(t.Context(), store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + require.NoError(err) + + _, err = service.Sync(t.Context(), SyncOptions{}) + require.ErrorIs(err, store.ErrCardDAVSyncActive) + assert.Zero(requests) + runs, err := st.ListCardDAVSyncRunsContext(t.Context(), 10, nil) + require.NoError(err) + assert.Len(runs, 1) +} + +func TestSyncTotalFailureRecordsSafeFailedTerminalState(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + service, st, _ := newPullService(t, server, true) + + result, err := service.Sync(t.Context(), SyncOptions{}) + require.Error(err) + assert.Equal(SyncResult{}, result) + assert.Equal("CardDAV server request failed.", err.Error()) + runs, listErr := st.ListCardDAVSyncRunsContext(t.Context(), 10, nil) + require.NoError(listErr) + require.Len(runs, 1) + assert.Equal(store.CardDAVSyncRunFailed, runs[0].State) + assert.Equal("upstream_failed", runs[0].ErrorCode) + assert.Equal("CardDAV server request failed.", runs[0].ErrorMessage) +} + +func TestSyncFailureProjectionRejectsPrivateMaterial(t *testing.T) { + assert := assert.New(t) + privateErr := errors.New("Authorization: Bearer synthetic-secret BEGIN:VCARD https://private.invalid/dav") + finish := cardDAVSyncRunFinish(SyncResult{}, privateErr) + returned := publicCardDAVSyncError(privateErr) + assert.Equal("sync_failed", finish.ErrorCode) + assert.Equal("CardDAV sync failed.", finish.ErrorMessage) + require.Error(t, returned) + assert.Equal("CardDAV sync failed.", returned.Error()) + for _, private := range []string{"synthetic-secret", "BEGIN:VCARD", "private.invalid"} { + assert.NotContains(finish.ErrorMessage, private) + assert.NotContains(returned.Error(), private) + } + assert.ErrorIs(returned, privateErr, "safe projection must preserve machine-readable cause semantics") +} + +func TestSyncJoinsExecutionAndFinishFailuresWithoutReplay(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + service, st, _ := newPullService(t, server, true) + var err error + if st.IsPostgreSQL() { + _, err = st.DB().Exec(`CREATE FUNCTION fail_carddav_run_finish_fn() RETURNS trigger AS $$ + BEGIN + IF OLD.state = 'running' THEN + RAISE EXCEPTION 'injected finish failure'; + END IF; + RETURN NEW; + END $$ LANGUAGE plpgsql`) + require.NoError(err) + _, err = st.DB().Exec(`CREATE TRIGGER fail_carddav_run_finish + BEFORE UPDATE OF state ON carddav_sync_runs + FOR EACH ROW EXECUTE FUNCTION fail_carddav_run_finish_fn()`) + } else { + _, err = st.DB().Exec(`CREATE TRIGGER fail_carddav_run_finish + BEFORE UPDATE OF state ON carddav_sync_runs + WHEN OLD.state = 'running' + BEGIN SELECT RAISE(ABORT, 'injected finish failure'); END`) + } + require.NoError(err) + + result, err := service.Sync(t.Context(), SyncOptions{}) + require.Error(err) + assert.Equal(SyncResult{}, result) + var statusErr *StatusError + require.ErrorAs(err, &statusErr, "execution error must remain inspectable") + require.ErrorContains(err, "injected finish failure") + assert.Equal(1, requests, "finish failure must not replay network work") +} + func TestSyncEmptyTokenReturnsMembersAndAdvancesToken(t *testing.T) { assert := assert.New(t) require := require.New(t) diff --git a/internal/carddav/roles.go b/internal/carddav/roles.go index 699222a53..8a6e0dd78 100644 --- a/internal/carddav/roles.go +++ b/internal/carddav/roles.go @@ -13,6 +13,24 @@ type BookRoles struct { LookupSource bool } +type PublicationState string + +const ( + PublicationUnpublished PublicationState = "unpublished" + PublicationPublished PublicationState = "published" + PublicationPending PublicationState = "pending" + PublicationConflict PublicationState = "conflict" +) + +type PublicationView struct { + PersonID int64 + State PublicationState + Desired bool + PendingOperation store.CardDAVMutationOperation + AddressBook *AddressBookIdentity + ConflictID *int64 +} + func (s *Service) ListBooks(ctx context.Context) ([]store.CardDAVAddressBook, error) { if s == nil || s.store == nil { return nil, errors.New("CardDAV service is not configured") @@ -31,6 +49,46 @@ func (s *Service) SetBookRoles(ctx context.Context, bookID int64, roles BookRole }) } +func (s *Service) PublicationView(ctx context.Context, personID int64) (*PublicationView, error) { + if s == nil || s.store == nil || personID <= 0 { + return nil, errors.New("CardDAV service is not configured") + } + source, err := s.store.GetCardDAVPublicationStateSourceContext(ctx, personID) + if err != nil { + return nil, err + } + return publicationViewFromSource(source), nil +} + +func publicationViewFromSource(source *store.CardDAVPublicationStateSource) *PublicationView { + personID := source.PersonID + view := &PublicationView{PersonID: personID, State: PublicationUnpublished} + if !source.HasPublication { + if source.ProspectiveBookID > 0 { + book := publicAddressBookIdentity(source.ProspectiveBookID, source.ProspectiveName) + view.AddressBook = &book + } + return view + } + view.Desired = source.Desired + view.PendingOperation = source.PendingOperation + book := publicAddressBookIdentity(source.AddressBookID, source.AddressBookName) + view.AddressBook = &book + switch { + case source.PendingOperation != "": + view.State = PublicationPending + case source.ConflictID > 0: + view.State = PublicationConflict + conflictID := source.ConflictID + view.ConflictID = &conflictID + case source.Desired: + view.State = PublicationPublished + default: + view.State = PublicationUnpublished + } + return view +} + func (s *Service) Publication(ctx context.Context, personID int64) (*store.CardDAVPublication, error) { if s == nil || s.store == nil || personID <= 0 { return nil, errors.New("CardDAV service is not configured") diff --git a/internal/carddav/roles_test.go b/internal/carddav/roles_test.go index f8a4c68f8..7d4618c56 100644 --- a/internal/carddav/roles_test.go +++ b/internal/carddav/roles_test.go @@ -29,6 +29,101 @@ func TestListBooksAndSetBookRolesUseServiceContract(t *testing.T) { })) } +func TestPublicationViewProjectsProspectivePublishedAndConflictState(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeDAVXML(t, w, syncResponse(cardResponse( + "/books/personal/alice.vcf", `"one"`, "alice", + ), "")) + })) + t.Cleanup(server.Close) + service, st, book := newPullService(t, server, false) + _, err := service.Sync(t.Context(), SyncOptions{}) + require.NoError(err) + mapping, err := st.GetCardDAVResourceContext(t.Context(), book.ID, book.CanonicalURL+"alice.vcf") + require.NoError(err) + require.NotNil(mapping.PersonID) + + view, err := service.PublicationView(t.Context(), *mapping.PersonID) + require.NoError(err) + assert.Equal(PublicationUnpublished, view.State) + assert.False(view.Desired) + require.NotNil(view.AddressBook) + assert.Equal(book.ID, view.AddressBook.ID) + assert.Equal("Personal", view.AddressBook.Name) + _, err = st.DB().Exec(st.Rebind(`UPDATE carddav_address_books + SET is_write_target = FALSE, is_subscribed = FALSE WHERE id = ?`), book.ID) + require.NoError(err) + view, err = service.PublicationView(t.Context(), *mapping.PersonID) + require.NoError(err) + assert.Nil(view.AddressBook, "an unpublished person has no prospective book when no target is eligible") + _, err = st.DB().Exec(st.Rebind(`UPDATE carddav_address_books + SET is_write_target = TRUE, is_subscribed = TRUE WHERE id = ?`), book.ID) + require.NoError(err) + _, err = service.PublicationView(t.Context(), *mapping.PersonID+10_000) + require.ErrorIs(err, store.ErrPersonNotFound) + + snapshot, err := st.LoadPersonVCardSnapshotContext(t.Context(), *mapping.PersonID) + require.NoError(err) + _, err = st.PrepareCardDAVPublicationContext(t.Context(), store.CardDAVPublicationPlan{ + PersonID: *mapping.PersonID, Desired: true, AddressBookID: book.ID, Href: mapping.Href, + OutgoingBody: mapping.RemoteBody, OutgoingSemanticHash: mapping.RemoteSemanticHash, + LocalHash: snapshot.Fingerprint, + }) + require.NoError(err) + view, err = service.PublicationView(t.Context(), *mapping.PersonID) + require.NoError(err) + assert.Equal(PublicationPublished, view.State) + + _, err = st.DB().Exec(st.Rebind(`UPDATE carddav_address_books + SET is_write_target = FALSE, is_subscribed = FALSE WHERE id = ?`), book.ID) + require.NoError(err) + view, err = service.PublicationView(t.Context(), *mapping.PersonID) + require.NoError(err) + require.NotNil(view.AddressBook, "the publication row remains authoritative after role changes") + assert.Equal(book.ID, view.AddressBook.ID) + + conflict, err := st.RecordCardDAVConflictContext(t.Context(), store.CardDAVConflictCapture{ + AddressBookID: book.ID, Href: mapping.Href, + ExpectedMappingRevision: mapping.MappingRevision, + BaseLocalHash: mapping.LocalHash, LocalHash: mapping.LocalHash, + BaseRemoteHash: mapping.RemoteSemanticHash, BaseRemoteETag: mapping.RemoteETag, + RemoteETag: `"two"`, LocalBody: mapping.RemoteBody, RemoteBody: conflictCard("alice", "Remote"), + }) + require.NoError(err) + view, err = service.PublicationView(t.Context(), *mapping.PersonID) + require.NoError(err) + assert.Equal(PublicationConflict, view.State) + require.NotNil(view.ConflictID) + assert.Equal(conflict.ID, *view.ConflictID) +} + +func TestPublicationViewStatePrecedence(t *testing.T) { + for _, operation := range []store.CardDAVMutationOperation{ + store.CardDAVMutationCreate, store.CardDAVMutationUpdate, store.CardDAVMutationDelete, + } { + t.Run(string(operation), func(t *testing.T) { + view := publicationViewFromSource(&store.CardDAVPublicationStateSource{ + PersonID: 7, HasPublication: true, + Desired: operation != store.CardDAVMutationDelete, PendingOperation: operation, + AddressBookID: 2, AddressBookName: "Personal", ConflictID: 9, + }) + assert.Equal(t, PublicationPending, view.State, "pending wins over a matching conflict") + assert.Equal(t, operation, view.PendingOperation) + assert.Nil(t, view.ConflictID) + }) + } + + conflict := publicationViewFromSource(&store.CardDAVPublicationStateSource{ + PersonID: 7, HasPublication: true, Desired: true, + AddressBookID: 2, AddressBookName: "Personal", ConflictID: 9, + }) + assert.Equal(t, PublicationConflict, conflict.State) + require.NotNil(t, conflict.ConflictID) + assert.Equal(t, int64(9), *conflict.ConflictID) +} + func TestNonWriteTargetPendingMutationNeverReachesServer(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/config/edit.go b/internal/config/edit.go index 82d9fc562..52e0fc411 100644 --- a/internal/config/edit.go +++ b/internal/config/edit.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "sort" "strconv" "strings" @@ -195,12 +196,31 @@ func EditConfigFile(path, ifMatch string, edits []Edit) (ConfigFile, error) { return editConfigFile(path, ifMatch, edits, defaultConfigFileOps()) } +// EditConfigFilePrivate performs the same conditional atomic transaction as +// EditConfigFile, but publishes the candidate owner-only. Remote settings +// surfaces use this boundary because config.toml may already contain secrets +// outside the particular keys being changed. +func EditConfigFilePrivate(path, ifMatch string, edits []Edit) (ConfigFile, error) { + return editConfigFileWithPrivacy(path, ifMatch, edits, defaultConfigFileOps(), true) +} + func editConfigFile(path, ifMatch string, edits []Edit, ops configFileOps) (ConfigFile, error) { return editConfigWithTransform(path, ifMatch, len(edits) > 0, func(content []byte) ([]byte, error) { return applyTargetedEdits(content, edits) }, ops) } +func editConfigFileWithPrivacy( + path, ifMatch string, + edits []Edit, + ops configFileOps, + forcePrivate bool, +) (ConfigFile, error) { + return editConfigWithMatch(path, ifMatch, nil, len(edits) > 0, func(content []byte) ([]byte, error) { + return applyTargetedEdits(content, edits) + }, ops, forcePrivate) +} + // EditConfigTables applies exact table insertions/removals through the same // ownership, concurrency, validation, durability, rollback, and recovery path // used by EditConfigFile. @@ -327,7 +347,7 @@ func editConfigWithTransform( transform func([]byte) ([]byte, error), ops configFileOps, ) (result ConfigFile, resultErr error) { - return editConfigWithMatch(path, ifMatch, nil, hasChanges, transform, ops) + return editConfigWithMatch(path, ifMatch, nil, hasChanges, transform, ops, false) } func editConfigWithExpectedTransform( @@ -337,7 +357,7 @@ func editConfigWithExpectedTransform( transform func([]byte) ([]byte, error), ops configFileOps, ) (result ConfigFile, resultErr error) { - return editConfigWithMatch(path, "", &published, hasChanges, transform, ops) + return editConfigWithMatch(path, "", &published, hasChanges, transform, ops, false) } func editConfigWithMatch( @@ -346,6 +366,7 @@ func editConfigWithMatch( hasChanges bool, transform func([]byte) ([]byte, error), ops configFileOps, + forcePrivate bool, ) (result ConfigFile, resultErr error) { var expected ConfigFile defer func() { @@ -430,7 +451,7 @@ func editConfigWithMatch( }() mode := before.Mode.Perm() - if !before.Exists { + if !before.Exists || forcePrivate { mode = 0o600 } expected = ConfigFile{ @@ -1032,6 +1053,17 @@ func applyTargetedEdits(content []byte, edits []Edit) ([]byte, error) { return nil, fmt.Errorf("%w: duplicate requested key %q", ErrAmbiguousConfigTarget, edit.Key) } seenEdits[edit.Key] = struct{}{} + if provider, ok := edit.Value.(personEnrichmentProviderEdit); ok { + if edit.Key != personEnrichmentProviderEditKey { + return nil, fmt.Errorf("%w: invalid dedicated provider target", ErrUnsafeConfigTarget) + } + var err error + lines, err = replacePersonEnrichmentProviderLines(lines, provider) + if err != nil { + return nil, err + } + continue + } section, key, ok := strings.Cut(edit.Key, ".") if !ok || section == "" || key == "" { return nil, fmt.Errorf("invalid config edit key %q", edit.Key) @@ -1938,15 +1970,7 @@ func pathPrefix(prefix, path []string) bool { } func equalPath(left, right []string) bool { - if len(left) != len(right) { - return false - } - for index := range left { - if left[index] != right[index] { - return false - } - } - return true + return slices.Equal(left, right) } func replaceAssignmentValue(line, value string) (string, error) { diff --git a/internal/config/edit_person_enrichment.go b/internal/config/edit_person_enrichment.go new file mode 100644 index 000000000..d26897dde --- /dev/null +++ b/internal/config/edit_person_enrichment.go @@ -0,0 +1,298 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/BurntSushi/toml" + "go.kenn.io/msgvault/internal/personenrichment" +) + +const personEnrichmentProviderEditKey = "people.enrichment.providers" + +type personEnrichmentProviderEdit struct { + name string + provider personenrichment.ProviderConfig +} + +// EditPersonEnrichmentProvider conditionally updates one provider table by its +// stable name. Unlike a collection round trip, it leaves comments, extension +// keys, host-owned fields, and unrelated provider tables untouched. +func EditPersonEnrichmentProvider( + path, ifMatch, name string, + provider personenrichment.ProviderConfig, +) (ConfigFile, error) { + if name == "" || provider.Name != name { + return ConfigFile{}, fmt.Errorf("%w: person-enrichment provider name does not match target", ErrAmbiguousConfigTarget) + } + return EditConfigFilePrivate(path, ifMatch, []Edit{{ + Key: personEnrichmentProviderEditKey, + Value: personEnrichmentProviderEdit{ + name: name, + provider: provider, + }, + }}) +} + +type providerLineRange struct { + start int + end int + name string +} + +func replacePersonEnrichmentProviderLines( + lines []tomlLine, + target personEnrichmentProviderEdit, +) ([]tomlLine, error) { + ranges, err := personEnrichmentProviderRanges(lines) + if err != nil { + return nil, err + } + seen := make(map[string]struct{}, len(ranges)) + targetIndex := -1 + for index, providerRange := range ranges { + if _, duplicate := seen[providerRange.name]; duplicate { + return nil, fmt.Errorf("%w: duplicate person-enrichment provider name %q", + ErrAmbiguousConfigTarget, providerRange.name) + } + seen[providerRange.name] = struct{}{} + if providerRange.name == target.name { + targetIndex = index + } + } + if targetIndex < 0 { + return appendPersonEnrichmentProvider(lines, target.provider) + } + + providerRange := ranges[targetIndex] + block := append([]tomlLine(nil), lines[providerRange.start:providerRange.end]...) + block, err = editPersonEnrichmentProviderBlock(block, target.provider) + if err != nil { + return nil, err + } + result := make([]tomlLine, 0, len(lines)-providerRange.end+providerRange.start+len(block)) + result = append(result, lines[:providerRange.start]...) + result = append(result, block...) + result = append(result, lines[providerRange.end:]...) + return result, nil +} + +func personEnrichmentProviderRanges(lines []tomlLine) ([]providerLineRange, error) { + structural := tomlStructuralLines(lines) + var starts []int + for index, line := range lines { + if !structural[index] { + continue + } + path, array, ok := parseTOMLTable(line.body) + if ok && array && equalPath(path, []string{"people", "enrichment", "providers"}) { + starts = append(starts, index) + } + } + ranges := make([]providerLineRange, 0, len(starts)) + for _, start := range starts { + end := len(lines) + for next := start + 1; next < len(lines); next++ { + if !structural[next] { + continue + } + if _, _, table := parseTOMLTable(lines[next].body); table { + end = next + break + } + } + name, err := personEnrichmentProviderName(lines[start:end]) + if err != nil { + return nil, err + } + ranges = append(ranges, providerLineRange{start: start, end: end, name: name}) + } + return ranges, nil +} + +func personEnrichmentProviderName(block []tomlLine) (string, error) { + structural := tomlStructuralLines(block) + matches := make([]int, 0, 1) + for index := 1; index < len(block); index++ { + if !structural[index] { + continue + } + key, ok := assignmentKey(block[index].body) + if ok && equalPath(key, []string{"name"}) { + matches = append(matches, index) + } + } + if len(matches) != 1 { + return "", fmt.Errorf("%w: every person-enrichment provider table must contain one name", + ErrAmbiguousConfigTarget) + } + end, _, _, err := assignmentSpan(block, matches[0]) + if err != nil { + return "", fmt.Errorf("%w: invalid person-enrichment provider name", ErrAmbiguousConfigTarget) + } + var decoded map[string]any + if _, err := toml.Decode(string(joinTOMLLines(block[matches[0]:end+1])), &decoded); err != nil { + return "", fmt.Errorf("%w: invalid person-enrichment provider name", ErrAmbiguousConfigTarget) + } + name, ok := decoded["name"].(string) + if !ok || strings.TrimSpace(name) == "" { + return "", fmt.Errorf("%w: person-enrichment provider name is missing", ErrAmbiguousConfigTarget) + } + return name, nil +} + +func editPersonEnrichmentProviderBlock( + block []tomlLine, + provider personenrichment.ProviderConfig, +) ([]tomlLine, error) { + identifiers := make([]string, len(provider.AllowedIdentifiers)) + for index, identifier := range provider.AllowedIdentifiers { + identifiers[index] = string(identifier) + } + assignments := personEnrichmentProviderAssignments(provider, identifiers, false) + var err error + for _, assignment := range assignments { + block, err = editProviderBlockAssignment(block, assignment.key, assignment.value) + if err != nil { + return nil, fmt.Errorf("edit person-enrichment provider %q %s: %w", + provider.Name, assignment.key, err) + } + } + return block, nil +} + +type providerAssignment struct { + key string + value any +} + +func personEnrichmentProviderAssignments( + provider personenrichment.ProviderConfig, + identifiers []string, + includeIdentity bool, +) []providerAssignment { + assignments := make([]providerAssignment, 0, 22) + if includeIdentity { + assignments = append(assignments, + providerAssignment{"name", provider.Name}, + providerAssignment{"kind", provider.Kind}, + providerAssignment{"api_key_env", provider.APIKeyEnv}, + ) + } + return append(assignments, + providerAssignment{"enabled", provider.Enabled}, + providerAssignment{"endpoint", provider.Endpoint}, + providerAssignment{"poll_endpoint", provider.PollEndpoint}, + providerAssignment{"mode", provider.Mode}, + providerAssignment{"tier", provider.Tier}, + providerAssignment{"num_results", provider.NumResults}, + providerAssignment{"allowed_identifiers", identifiers}, + providerAssignment{"target_keys", provider.TargetKeys}, + providerAssignment{"allow_sensitive_targets", provider.AllowSensitiveTargets}, + providerAssignment{"retention_posture", provider.RetentionPosture}, + providerAssignment{"training_posture", provider.TrainingPosture}, + providerAssignment{"refresh_interval", provider.RefreshInterval.String()}, + providerAssignment{"request_timeout", provider.RequestTimeout.String()}, + providerAssignment{"poll_interval", provider.PollInterval.String()}, + providerAssignment{"max_job_age", provider.MaxJobAge.String()}, + providerAssignment{"max_retries", provider.MaxRetries}, + providerAssignment{"max_requests_per_run", provider.MaxRequestsPerRun}, + providerAssignment{"max_requests_per_day", provider.MaxRequestsPerDay}, + ) +} + +func editProviderBlockAssignment(block []tomlLine, key string, rawValue any) ([]tomlLine, error) { + value, err := encodeTOMLValue(rawValue) + if err != nil { + return nil, err + } + value = strings.TrimSpace(value) + structural := tomlStructuralLines(block) + matches := make([]int, 0, 1) + insertAt := 1 + for index := 1; index < len(block); index++ { + if !structural[index] { + continue + } + assignment, ok := assignmentKey(block[index].body) + if !ok { + continue + } + end, _, _, spanErr := assignmentSpan(block, index) + if spanErr != nil { + return nil, spanErr + } + if end+1 > insertAt { + insertAt = end + 1 + } + if equalPath(assignment, []string{key}) { + matches = append(matches, index) + } + } + if len(matches) > 1 { + return nil, fmt.Errorf("%w: duplicate provider key %q", ErrAmbiguousConfigTarget, key) + } + if len(matches) == 1 { + index := matches[0] + end, suffix, multiline, err := assignmentSpan(block, index) + if err != nil { + return nil, err + } + replaced, err := replaceAssignmentValue(block[index].body, value) + if err != nil { + return nil, err + } + if multiline { + replaced += suffix + block[index].eol = block[end].eol + block = append(block[:index+1], block[end+1:]...) + } + block[index].body = replaced + return block, nil + } + + eol := preferredEOL(block) + if insertAt > len(block) { + insertAt = len(block) + } + if insertAt > 0 && block[insertAt-1].eol == "" { + block[insertAt-1].eol = eol + } + lineEOL := eol + if insertAt == len(block) && len(block) > 0 && block[len(block)-1].eol == "" { + lineEOL = "" + } + block = append(block, tomlLine{}) + copy(block[insertAt+1:], block[insertAt:]) + block[insertAt] = tomlLine{body: key + " = " + value, eol: lineEOL} + return block, nil +} + +func appendPersonEnrichmentProvider( + lines []tomlLine, + provider personenrichment.ProviderConfig, +) ([]tomlLine, error) { + eol := preferredEOL(lines) + if len(lines) > 0 { + if lines[len(lines)-1].eol == "" { + lines[len(lines)-1].eol = eol + } + if strings.TrimSpace(lines[len(lines)-1].body) != "" { + lines = append(lines, tomlLine{eol: eol}) + } + } + lines = append(lines, tomlLine{body: "[[people.enrichment.providers]]", eol: eol}) + identifiers := make([]string, len(provider.AllowedIdentifiers)) + for index, identifier := range provider.AllowedIdentifiers { + identifiers[index] = string(identifier) + } + for _, assignment := range personEnrichmentProviderAssignments(provider, identifiers, true) { + value, err := encodeTOMLValue(assignment.value) + if err != nil { + return nil, fmt.Errorf("encode person-enrichment provider %q %s: %w", + provider.Name, assignment.key, err) + } + lines = append(lines, tomlLine{body: assignment.key + " = " + strings.TrimSpace(value), eol: eol}) + } + return lines, nil +} diff --git a/internal/config/edit_person_enrichment_test.go b/internal/config/edit_person_enrichment_test.go new file mode 100644 index 000000000..9916314a6 --- /dev/null +++ b/internal/config/edit_person_enrichment_test.go @@ -0,0 +1,106 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personenrichment" +) + +func TestEditPersonEnrichmentProviderPreservesTargetExtensionsAndUnrelatedProviders(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + path := filepath.Join(t.TempDir(), "config.toml") + before := `[people.enrichment] +enabled = false + +[[people.enrichment.providers]] # target table comment +name = "exa-primary" # stable identity +kind = "exa" +enabled = false # old value +endpoint = "https://old.example.test/search" +api_key_env = "HOST_OWNED_EXA_KEY" # must remain host-owned +future_target_option = "keep-target" # unknown target field + +# unrelated provider comment +[[people.enrichment.providers]] +name = "exa-secondary" +kind = "exa" +enabled = false +endpoint = "https://secondary.example.test/search" +api_key_env = "SECONDARY_EXA_KEY" +future_unrelated_option = { nested = true } # unknown unrelated field +` + requirements.NoError(os.WriteFile(path, []byte(before), 0o600)) + snapshot, err := ReadConfigFile(path) + requirements.NoError(err) + + unrelated := `# unrelated provider comment +[[people.enrichment.providers]] +name = "exa-secondary" +kind = "exa" +enabled = false +endpoint = "https://secondary.example.test/search" +api_key_env = "SECONDARY_EXA_KEY" +future_unrelated_option = { nested = true } # unknown unrelated field +` + updated := personenrichment.ProviderConfig{ + Name: "exa-primary", + Kind: personenrichment.ProviderExa, + Enabled: false, + Endpoint: "https://new.example.test/search", + Mode: "people", + NumResults: 1, + AllowedIdentifiers: []personenrichment.IdentifierClass{personenrichment.IdentifierName}, + TargetKeys: []string{"attribute:bio"}, + RetentionPosture: "zero_retention", + TrainingPosture: "no_training", + RefreshInterval: 24 * time.Hour, + RequestTimeout: time.Minute, + PollInterval: 30 * time.Second, + MaxJobAge: 15 * time.Minute, + MaxRetries: 5, + MaxRequestsPerRun: 20, + MaxRequestsPerDay: 100, + } + + written, err := EditPersonEnrichmentProvider(path, snapshot.ETag, "exa-primary", updated) + requirements.NoError(err) + content := string(written.Content) + assertions.Contains(content, `[[people.enrichment.providers]] # target table comment`) + assertions.Contains(content, `name = "exa-primary" # stable identity`) + assertions.Contains(content, `endpoint = "https://new.example.test/search"`) + assertions.Contains(content, `api_key_env = "HOST_OWNED_EXA_KEY" # must remain host-owned`) + assertions.Contains(content, `future_target_option = "keep-target" # unknown target field`) + assertions.Contains(content, unrelated) +} + +func TestEditPersonEnrichmentProviderRejectsAmbiguousOrMissingStoredNames(t *testing.T) { + tests := map[string]string{ + "duplicate": `[[people.enrichment.providers]] +name = "exa-primary" +[[people.enrichment.providers]] +name = "exa-primary" +`, + "missing": `[[people.enrichment.providers]] +kind = "exa" +`, + } + for name, content := range tests { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + snapshot, err := ReadConfigFile(path) + require.NoError(t, err) + + _, err = EditPersonEnrichmentProvider(path, snapshot.ETag, "exa-primary", personenrichment.ProviderConfig{ + Name: "exa-primary", Kind: personenrichment.ProviderExa, + }) + assert.ErrorIs(t, err, ErrAmbiguousConfigTarget) + }) + } +} diff --git a/internal/operations/types.go b/internal/operations/types.go new file mode 100644 index 000000000..8c3ebe90e --- /dev/null +++ b/internal/operations/types.go @@ -0,0 +1,872 @@ +// Package operations defines the normalized, privacy-bounded read model used +// to project durable subsystem run ledgers. It deliberately owns no storage, +// transport, scheduling, or execution behavior. +package operations + +import ( + "cmp" + "context" + "errors" + "fmt" + "slices" + "strings" + "time" + "unicode/utf8" + + "go.kenn.io/msgvault/internal/peoplesweep" +) + +const ( + MaxTextStableIDBytes = 128 + MaxPublicErrorMessageBytes = 256 +) + +type Kind string + +const ( + KindSourceSync Kind = "source_sync" + KindPersonSweep Kind = "person_sweep" + KindCardDAVSync Kind = "carddav_sync" + KindMessageEmbedding Kind = "message_embedding" + KindPersonEmbedding Kind = "person_embedding" + KindDocumentExtraction Kind = "document_extraction" + KindDocumentEmbedding Kind = "document_embedding" + KindVisualEmbedding Kind = "visual_embedding" + KindPersonEnrichment Kind = "person_enrichment" +) + +func (k Kind) Validate() error { + switch k { + case KindSourceSync, KindPersonSweep, KindCardDAVSync, KindMessageEmbedding, + KindPersonEmbedding, KindDocumentExtraction, KindDocumentEmbedding, + KindVisualEmbedding, KindPersonEnrichment: + return nil + default: + return fmt.Errorf("invalid operation kind %q", k) + } +} + +type Lane string + +const ( + LaneMessages Lane = "messages" + LanePersonFacts Lane = "person_facts" + LaneContacts Lane = "contacts" + LaneDocuments Lane = "documents" + LaneVisualAttachments Lane = "visual_attachments" +) + +func (l Lane) Validate() error { + switch l { + case LaneMessages, LanePersonFacts, LaneContacts, LaneDocuments, LaneVisualAttachments: + return nil + default: + return fmt.Errorf("invalid operation lane %q", l) + } +} + +type State string + +const ( + StateQueued State = "queued" + StateRunning State = "running" + StateSucceeded State = "succeeded" + StatePartial State = "partial" + StateFailed State = "failed" + StateCancelled State = "cancelled" +) + +func (s State) Validate() error { + switch s { + case StateQueued, StateRunning, StateSucceeded, StatePartial, StateFailed, StateCancelled: + return nil + default: + return fmt.Errorf("invalid operation state %q", s) + } +} + +type Trigger string + +const ( + TriggerManual Trigger = "manual" + TriggerScheduled Trigger = "scheduled" +) + +func (t Trigger) Validate() error { + switch t { + case TriggerManual, TriggerScheduled: + return nil + default: + return fmt.Errorf("invalid operation trigger %q", t) + } +} + +type HistoryAvailability string + +const ( + HistoryAvailable HistoryAvailability = "available" + HistoryUnavailable HistoryAvailability = "unavailable" +) + +func (a HistoryAvailability) Validate() error { + switch a { + case HistoryAvailable, HistoryUnavailable: + return nil + default: + return fmt.Errorf("invalid operation history availability %q", a) + } +} + +type ActionID string + +const ( + ActionCardDAVSync ActionID = "carddav_sync" + ActionVisualBuild ActionID = "visual_build" + ActionVisualResume ActionID = "visual_resume" +) + +func (a ActionID) Validate() error { + switch a { + case ActionCardDAVSync, ActionVisualBuild, ActionVisualResume: + return nil + default: + return fmt.Errorf("invalid operation action %q", a) + } +} + +type RelatedStatusID string + +const ( + RelatedStatusSource RelatedStatusID = "listSourceStatus" + RelatedStatusDocumentIndex RelatedStatusID = "getDocumentIndexStatus" + RelatedStatusDocumentVector RelatedStatusID = "getDocumentVectorStatus" + RelatedStatusVisual RelatedStatusID = "getVisualAttachmentStatus" + RelatedStatusCardDAV RelatedStatusID = "getCardDAVStatus" +) + +func (r RelatedStatusID) Validate() error { + switch r { + case RelatedStatusSource, RelatedStatusDocumentIndex, RelatedStatusDocumentVector, + RelatedStatusVisual, RelatedStatusCardDAV: + return nil + default: + return fmt.Errorf("invalid operation related status %q", r) + } +} + +type LaneDefinition struct { + Kind Kind + Lane Lane + HistoryAvailability HistoryAvailability + UnavailableCode string +} + +var laneRegistry = []LaneDefinition{ + {Kind: KindCardDAVSync, Lane: LaneContacts, HistoryAvailability: HistoryAvailable}, + {Kind: KindDocumentEmbedding, Lane: LaneDocuments, HistoryAvailability: HistoryUnavailable, UnavailableCode: "document_embedding_history_unavailable"}, + {Kind: KindDocumentExtraction, Lane: LaneDocuments, HistoryAvailability: HistoryUnavailable, UnavailableCode: "document_extraction_history_unavailable"}, + {Kind: KindMessageEmbedding, Lane: LaneMessages, HistoryAvailability: HistoryUnavailable, UnavailableCode: "message_embedding_history_unavailable"}, + {Kind: KindPersonEmbedding, Lane: LanePersonFacts, HistoryAvailability: HistoryUnavailable, UnavailableCode: "person_embedding_history_unavailable"}, + {Kind: KindPersonEnrichment, Lane: LanePersonFacts, HistoryAvailability: HistoryUnavailable, UnavailableCode: "person_enrichment_history_unavailable"}, + {Kind: KindPersonSweep, Lane: LanePersonFacts, HistoryAvailability: HistoryAvailable}, + {Kind: KindSourceSync, Lane: LaneMessages, HistoryAvailability: HistoryAvailable}, + {Kind: KindVisualEmbedding, Lane: LaneVisualAttachments, HistoryAvailability: HistoryUnavailable, UnavailableCode: "visual_embedding_history_unavailable"}, +} + +func LaneRegistry() []LaneDefinition { + return slices.Clone(laneRegistry) +} + +func laneDefinition(kind Kind) (LaneDefinition, bool) { + index, found := slices.BinarySearchFunc(laneRegistry, kind, func(definition LaneDefinition, target Kind) int { + return cmp.Compare(definition.Kind, target) + }) + if !found { + return LaneDefinition{}, false + } + return laneRegistry[index], true +} + +type StableIDType string + +const ( + StableIDInt64 StableIDType = "int64" + StableIDText StableIDType = "text" +) + +func (t StableIDType) Validate() error { + switch t { + case StableIDInt64, StableIDText: + return nil + default: + return fmt.Errorf("invalid operation stable ID type %q", t) + } +} + +type StableID struct { + kind Kind + idType StableIDType + int64ID int64 + textID string +} + +func NewInt64ID(kind Kind, id int64) (StableID, error) { + stableID := StableID{kind: kind, idType: StableIDInt64, int64ID: id} + if err := stableID.Validate(); err != nil { + return StableID{}, err + } + return stableID, nil +} + +func NewTextID(kind Kind, id string) (StableID, error) { + stableID := StableID{kind: kind, idType: StableIDText, textID: id} + if err := stableID.Validate(); err != nil { + return StableID{}, err + } + return stableID, nil +} + +func (id StableID) Kind() Kind { return id.kind } + +func (id StableID) Type() StableIDType { return id.idType } + +func (id StableID) Int64() (int64, bool) { + return id.int64ID, id.idType == StableIDInt64 +} + +func (id StableID) Text() (string, bool) { + return id.textID, id.idType == StableIDText +} + +func (id StableID) Validate() error { + if err := id.kind.Validate(); err != nil { + return err + } + wantType, ok := stableIDTypeForKind(id.kind) + if !ok { + return fmt.Errorf("operation kind %q has no durable history ID", id.kind) + } + if id.idType != wantType { + return fmt.Errorf("operation kind %q requires %q stable IDs", id.kind, wantType) + } + switch id.idType { + case StableIDInt64: + if id.int64ID <= 0 || id.textID != "" { + return errors.New("operation numeric stable ID must be positive and exclusively numeric") + } + case StableIDText: + if id.int64ID != 0 || id.textID == "" || strings.TrimSpace(id.textID) != id.textID || + !utf8.ValidString(id.textID) || len(id.textID) > MaxTextStableIDBytes { + return errors.New("operation text stable ID must be canonical, valid UTF-8, and bounded") + } + default: + return fmt.Errorf("invalid operation stable ID type %q", id.idType) + } + return nil +} + +func stableIDTypeForKind(kind Kind) (StableIDType, bool) { + switch kind { + case KindSourceSync, KindCardDAVSync: + return StableIDInt64, true + case KindPersonSweep: + return StableIDText, true + default: + return "", false + } +} + +type CounterName string + +const ( + CounterProcessed CounterName = "processed" + CounterAdded CounterName = "added" + CounterUpdated CounterName = "updated" + CounterItemErrors CounterName = "item_errors" + CounterAttempted CounterName = "attempted" + CounterSucceeded CounterName = "succeeded" + CounterFailed CounterName = "failed" + CounterProjectedWrites CounterName = "projected_writes" + CounterBooks CounterName = "books" + CounterCreated CounterName = "created" + CounterRemoved CounterName = "removed" +) + +func (n CounterName) Validate() error { + switch n { + case CounterProcessed, CounterAdded, CounterUpdated, CounterItemErrors, + CounterAttempted, CounterSucceeded, CounterFailed, CounterProjectedWrites, + CounterBooks, CounterCreated, CounterRemoved: + return nil + default: + return fmt.Errorf("invalid operation counter name %q", n) + } +} + +type CounterUnit string + +const ( + CounterUnitMessages CounterUnit = "messages" + CounterUnitPeople CounterUnit = "people" + CounterUnitWrites CounterUnit = "writes" + CounterUnitBooks CounterUnit = "books" + CounterUnitContacts CounterUnit = "contacts" +) + +func (u CounterUnit) Validate() error { + switch u { + case CounterUnitMessages, CounterUnitPeople, CounterUnitWrites, CounterUnitBooks, CounterUnitContacts: + return nil + default: + return fmt.Errorf("invalid operation counter unit %q", u) + } +} + +type PublicCounter struct { + Name CounterName + Unit CounterUnit + Value int64 +} + +var counterUnitsByKind = map[Kind]map[CounterName]CounterUnit{ + KindSourceSync: { + CounterProcessed: CounterUnitMessages, + CounterAdded: CounterUnitMessages, + CounterUpdated: CounterUnitMessages, + CounterItemErrors: CounterUnitMessages, + }, + KindPersonSweep: { + CounterAttempted: CounterUnitPeople, + CounterSucceeded: CounterUnitPeople, + CounterFailed: CounterUnitPeople, + CounterProjectedWrites: CounterUnitWrites, + }, + KindCardDAVSync: { + CounterBooks: CounterUnitBooks, + CounterCreated: CounterUnitContacts, + CounterUpdated: CounterUnitContacts, + CounterRemoved: CounterUnitContacts, + }, +} + +func ValidateCounters(kind Kind, counters []PublicCounter) error { + if err := kind.Validate(); err != nil { + return err + } + allowed, ok := counterUnitsByKind[kind] + if !ok { + return fmt.Errorf("operation kind %q has no durable public counters", kind) + } + seen := make(map[CounterName]struct{}, len(counters)) + for _, counter := range counters { + if err := counter.Name.Validate(); err != nil { + return err + } + if counter.Value < 0 { + return fmt.Errorf("operation counter %q must be nonnegative", counter.Name) + } + wantUnit, ok := allowed[counter.Name] + if !ok { + return fmt.Errorf("operation counter %q is not allowed for kind %q", counter.Name, kind) + } + if counter.Unit != wantUnit { + return fmt.Errorf("operation counter %q requires unit %q", counter.Name, wantUnit) + } + if _, duplicate := seen[counter.Name]; duplicate { + return fmt.Errorf("duplicate operation counter %q", counter.Name) + } + seen[counter.Name] = struct{}{} + } + return nil +} + +type PublicErrorCode string + +const ( + PublicErrorSourceSyncFailed PublicErrorCode = "source_sync_failed" + PublicErrorPersonSweepFailed PublicErrorCode = "person_sweep_failed" + PublicErrorPolicy PublicErrorCode = "policy" + PublicErrorBudget PublicErrorCode = "budget" + PublicErrorLeaseLost PublicErrorCode = "lease_lost" + PublicErrorRateLimited PublicErrorCode = "rate_limited" + PublicErrorTimeout PublicErrorCode = "timeout" + PublicErrorProviderHTTP PublicErrorCode = "provider_http" + PublicErrorInvalidOutput PublicErrorCode = "invalid_output" + PublicErrorArchiveGap PublicErrorCode = "archive_gap" + PublicErrorInternal PublicErrorCode = "internal" + PublicErrorCancelled PublicErrorCode = "cancelled" + PublicErrorRetryAfter PublicErrorCode = "retry_after" + PublicErrorAuthenticationFailed PublicErrorCode = "authentication_failed" + PublicErrorUpstreamFailed PublicErrorCode = "upstream_failed" + PublicErrorSafetyLimit PublicErrorCode = "safety_limit" + PublicErrorSyncFailed PublicErrorCode = "sync_failed" + PublicErrorUnsafeErrorRedacted PublicErrorCode = "unsafe_error_redacted" + PublicErrorDaemonRestarted PublicErrorCode = "daemon_restarted" + PublicErrorCardDAVSyncFailed PublicErrorCode = "carddav_sync_failed" +) + +func (c PublicErrorCode) Validate() error { + if _, ok := fixedPublicErrorMessages[c]; !ok { + return fmt.Errorf("invalid operation public error code %q", c) + } + return nil +} + +type PublicError struct { + Code PublicErrorCode + Message string +} + +var fixedPublicErrorMessages = map[PublicErrorCode]string{ + PublicErrorSourceSyncFailed: "Source sync failed.", + PublicErrorPersonSweepFailed: "Person sweep failed.", + PublicErrorPolicy: "Person sweep was blocked by policy.", + PublicErrorBudget: "Person sweep budget was exhausted.", + PublicErrorLeaseLost: "Person sweep ownership expired.", + PublicErrorRateLimited: "Person sweep was rate limited.", + PublicErrorTimeout: "Person sweep timed out.", + PublicErrorProviderHTTP: "Person sweep provider request failed.", + PublicErrorInvalidOutput: "Person sweep provider output was invalid.", + PublicErrorArchiveGap: "Person sweep archive input changed.", + PublicErrorInternal: "Person sweep failed internally.", + PublicErrorCancelled: "CardDAV sync was cancelled.", + PublicErrorRetryAfter: "CardDAV sync is temporarily paused.", + PublicErrorAuthenticationFailed: "CardDAV authentication failed.", + PublicErrorUpstreamFailed: "CardDAV server request failed.", + PublicErrorSafetyLimit: "CardDAV sync exceeded its safety limits.", + PublicErrorSyncFailed: "CardDAV sync failed.", + PublicErrorUnsafeErrorRedacted: "CardDAV sync failed; sensitive details were removed.", + PublicErrorDaemonRestarted: "CardDAV sync stopped because the daemon restarted.", + PublicErrorCardDAVSyncFailed: "CardDAV sync failed.", +} + +func (e PublicError) Validate() error { + if err := e.Code.Validate(); err != nil { + return err + } + want := fixedPublicErrorMessages[e.Code] + if e.Message != want { + return fmt.Errorf("operation public error %q must use its fixed message", e.Code) + } + if e.Message == "" || !utf8.ValidString(e.Message) || len(e.Message) > MaxPublicErrorMessageBytes { + return errors.New("operation public error message must be nonempty, valid UTF-8, and bounded") + } + return nil +} + +func newPublicError(code PublicErrorCode) *PublicError { + return &PublicError{Code: code, Message: fixedPublicErrorMessages[code]} +} + +func ProjectSourceState( + durableState string, + itemErrors, messagesAdded, messagesUpdated int64, +) (State, *PublicError, error) { + if itemErrors < 0 || messagesAdded < 0 || messagesUpdated < 0 { + return "", nil, errors.New("source sync operation counters must be nonnegative") + } + switch durableState { + case "running": + return StateRunning, nil, nil + case "completed": + if itemErrors > 0 { + return StatePartial, nil, nil + } + return StateSucceeded, nil, nil + case "failed": + if messagesAdded > 0 || messagesUpdated > 0 { + return StatePartial, newPublicError(PublicErrorSourceSyncFailed), nil + } + return StateFailed, newPublicError(PublicErrorSourceSyncFailed), nil + case "cancelled": + return StateCancelled, nil, nil + default: + return "", nil, fmt.Errorf("unknown source sync operation state %q", durableState) + } +} + +func ProjectPersonSweepFailure(class peoplesweep.FailureClass) PublicError { + code := PublicErrorPersonSweepFailed + switch class { + case peoplesweep.FailurePolicy: + code = PublicErrorPolicy + case peoplesweep.FailureBudget: + code = PublicErrorBudget + case peoplesweep.FailureLeaseLost: + code = PublicErrorLeaseLost + case peoplesweep.FailureRateLimited: + code = PublicErrorRateLimited + case peoplesweep.FailureTimeout: + code = PublicErrorTimeout + case peoplesweep.FailureProviderHTTP: + code = PublicErrorProviderHTTP + case peoplesweep.FailureInvalidOutput: + code = PublicErrorInvalidOutput + case peoplesweep.FailureArchiveGap: + code = PublicErrorArchiveGap + case peoplesweep.FailureInternal: + code = PublicErrorInternal + } + return *newPublicError(code) +} + +func ProjectCardDAVFailure(durableCode string) *PublicError { + var code PublicErrorCode + switch durableCode { + case "": + return nil + case "cancelled": + code = PublicErrorCancelled + case "retry_after": + code = PublicErrorRetryAfter + case "authentication_failed": + code = PublicErrorAuthenticationFailed + case "upstream_failed": + code = PublicErrorUpstreamFailed + case "safety_limit": + code = PublicErrorSafetyLimit + case "sync_failed": + code = PublicErrorSyncFailed + case "unsafe_error_redacted": + code = PublicErrorUnsafeErrorRedacted + case "daemon_restarted": + code = PublicErrorDaemonRestarted + default: + code = PublicErrorCardDAVSyncFailed + } + return newPublicError(code) +} + +type Run struct { + ID StableID + Lane Lane + State State + Trigger *Trigger + StartedAt time.Time + FinishedAt *time.Time + Counters []PublicCounter + Error *PublicError +} + +func (r Run) Validate() error { + if err := r.ID.Validate(); err != nil { + return err + } + definition, ok := laneDefinition(r.ID.Kind()) + if !ok || r.Lane != definition.Lane { + return fmt.Errorf("operation kind %q cannot use lane %q", r.ID.Kind(), r.Lane) + } + if err := r.State.Validate(); err != nil { + return err + } + if r.State == StateQueued { + return fmt.Errorf("operation kind %q has no durable queued runs", r.ID.Kind()) + } + if r.Trigger != nil { + if err := r.Trigger.Validate(); err != nil { + return err + } + if r.ID.Kind() == KindSourceSync { + return errors.New("source sync history has no truthful trigger") + } + } + if r.StartedAt.IsZero() { + return errors.New("operation start time is required") + } + if r.StartedAt.Location() != time.UTC { + return errors.New("operation start time must be normalized to UTC") + } + if r.FinishedAt != nil && r.FinishedAt.Before(r.StartedAt) { + return errors.New("operation finish time cannot precede its start") + } + if r.FinishedAt != nil && r.FinishedAt.Location() != time.UTC { + return errors.New("operation finish time must be normalized to UTC") + } + if r.State == StateRunning && r.FinishedAt != nil { + return errors.New("running operation cannot have a finish time") + } + if r.State != StateRunning && r.FinishedAt == nil { + return fmt.Errorf("terminal operation state %q requires a finish time", r.State) + } + if err := ValidateCounters(r.ID.Kind(), r.Counters); err != nil { + return err + } + if r.Error != nil { + if err := r.Error.Validate(); err != nil { + return err + } + } + return validateRunStateAndError(r.ID.Kind(), r.State, r.Counters, r.Error) +} + +func validateRunStateAndError( + kind Kind, + state State, + counters []PublicCounter, + publicError *PublicError, +) error { + switch kind { + case KindSourceSync: + added := publicCounterValue(counters, CounterAdded) + updated := publicCounterValue(counters, CounterUpdated) + itemErrors := publicCounterValue(counters, CounterItemErrors) + switch state { + case StateRunning, StateCancelled: + if publicError != nil { + return fmt.Errorf("source sync state %q cannot carry a public error", state) + } + case StateSucceeded: + if publicError != nil { + return fmt.Errorf("source sync state %q cannot carry a public error", state) + } + if itemErrors != 0 { + return errors.New("succeeded source sync cannot have item errors") + } + case StatePartial: + if publicError == nil { + if itemErrors <= 0 { + return errors.New("completed-origin partial source sync requires item errors") + } + break + } + if publicError.Code != PublicErrorSourceSyncFailed { + return fmt.Errorf("source sync state %q has a cross-kind public error", state) + } + if added <= 0 && updated <= 0 { + return errors.New("failed-origin partial source sync requires an added or updated item") + } + case StateFailed: + if publicError == nil || publicError.Code != PublicErrorSourceSyncFailed { + return errors.New("failed source sync requires its fixed public error") + } + if added != 0 || updated != 0 { + return errors.New("failed source sync cannot have added or updated items") + } + default: + return fmt.Errorf("source sync does not support operation state %q", state) + } + case KindPersonSweep: + switch state { + case StateRunning, StateSucceeded: + if publicError != nil { + return fmt.Errorf("person sweep state %q cannot carry a public error", state) + } + case StatePartial, StateFailed: + if publicError == nil || !isPersonSweepError(publicError.Code) { + return fmt.Errorf("person sweep state %q requires a fixed people-sweep public error", state) + } + default: + return fmt.Errorf("person sweep does not support operation state %q", state) + } + case KindCardDAVSync: + switch state { + case StateRunning, StateSucceeded: + if publicError != nil { + return fmt.Errorf("CardDAV sync state %q cannot carry a public error", state) + } + case StatePartial, StateFailed, StateCancelled: + if publicError == nil || !isCardDAVError(publicError.Code) { + return fmt.Errorf("CardDAV sync state %q requires a fixed CardDAV public error", state) + } + default: + return fmt.Errorf("CardDAV sync does not support operation state %q", state) + } + default: + return fmt.Errorf("operation kind %q has no durable run validation", kind) + } + return nil +} + +func publicCounterValue(counters []PublicCounter, name CounterName) int64 { + for _, counter := range counters { + if counter.Name == name { + return counter.Value + } + } + return 0 +} + +func isPersonSweepError(code PublicErrorCode) bool { + switch code { + case PublicErrorPersonSweepFailed, PublicErrorPolicy, PublicErrorBudget, + PublicErrorLeaseLost, PublicErrorRateLimited, PublicErrorTimeout, + PublicErrorProviderHTTP, PublicErrorInvalidOutput, PublicErrorArchiveGap, + PublicErrorInternal: + return true + default: + return false + } +} + +func isCardDAVError(code PublicErrorCode) bool { + switch code { + case PublicErrorCancelled, PublicErrorRetryAfter, PublicErrorAuthenticationFailed, + PublicErrorUpstreamFailed, PublicErrorSafetyLimit, PublicErrorSyncFailed, + PublicErrorUnsafeErrorRedacted, PublicErrorDaemonRestarted, + PublicErrorCardDAVSyncFailed: + return true + default: + return false + } +} + +// CompareRuns returns a negative value when left precedes right in the public +// newest-first ordering, zero when their ordering keys match, and a positive +// value when right precedes left. +func CompareRuns(left, right Run) int { + if compared := left.StartedAt.UTC().Compare(right.StartedAt.UTC()); compared != 0 { + return -compared + } + if compared := cmp.Compare(left.ID.Kind(), right.ID.Kind()); compared != 0 { + return compared + } + return compareStableIDDescending(left.ID, right.ID) +} + +func compareStableIDDescending(left, right StableID) int { + if compared := cmp.Compare(left.Type(), right.Type()); compared != 0 { + return compared + } + switch left.Type() { + case StableIDInt64: + return -cmp.Compare(left.int64ID, right.int64ID) + case StableIDText: + return -cmp.Compare(left.textID, right.textID) + default: + return 0 + } +} + +func SortRuns(runs []Run) { + slices.SortFunc(runs, CompareRuns) +} + +type Position struct { + StartedAt time.Time + ID StableID +} + +func (p Position) Validate() error { + if p.StartedAt.IsZero() { + return errors.New("operation history position time is required") + } + if p.StartedAt.Location() != time.UTC { + return errors.New("operation history position time must be normalized to UTC") + } + return p.ID.Validate() +} + +type Query struct { + Kinds []Kind + States []State + Position *Position + Limit int +} + +func (q Query) Validate() error { + if q.Limit < 1 || q.Limit > 100 { + return errors.New("operation history limit must be between 1 and 100") + } + for index, kind := range q.Kinds { + if err := kind.Validate(); err != nil { + return err + } + if index > 0 && q.Kinds[index-1] >= kind { + return errors.New("operation history kinds must be sorted and unique") + } + } + for index, state := range q.States { + if err := state.Validate(); err != nil { + return err + } + if index > 0 && q.States[index-1] >= state { + return errors.New("operation history states must be sorted and unique") + } + } + if q.Position != nil { + if err := q.Position.Validate(); err != nil { + return err + } + if len(q.Kinds) > 0 && !slices.Contains(q.Kinds, q.Position.ID.Kind()) { + return errors.New("operation history position kind is not selected") + } + } + return nil +} + +type LaneHistoryStatus struct { + Kind Kind + Lane Lane + HistoryAvailability HistoryAvailability + UnavailableCode string + Active *Run + Latest *Run + LatestSuccessful *Run +} + +func (s LaneHistoryStatus) Validate() error { + if err := s.Kind.Validate(); err != nil { + return err + } + definition, ok := laneDefinition(s.Kind) + if !ok { + return fmt.Errorf("operation kind %q has no lane definition", s.Kind) + } + if s.Lane != definition.Lane { + return fmt.Errorf("operation kind %q requires lane %q", s.Kind, definition.Lane) + } + if s.HistoryAvailability != definition.HistoryAvailability { + return fmt.Errorf("operation kind %q requires history availability %q", + s.Kind, definition.HistoryAvailability) + } + if s.UnavailableCode != definition.UnavailableCode { + return fmt.Errorf("operation kind %q has an invalid history availability code", s.Kind) + } + if s.HistoryAvailability == HistoryUnavailable { + if s.Active != nil || s.Latest != nil || s.LatestSuccessful != nil { + return fmt.Errorf("unavailable operation history %q cannot contain runs", s.Kind) + } + return nil + } + if err := validateStatusRun(s, "active", s.Active, StateRunning); err != nil { + return err + } + if err := validateStatusRun(s, "latest", s.Latest, ""); err != nil { + return err + } + if err := validateStatusRun(s, "latest successful", s.LatestSuccessful, StateSucceeded); err != nil { + return err + } + if s.LatestSuccessful != nil { + if s.Latest == nil { + return errors.New("operation history latest successful run requires a latest run") + } + if CompareRuns(*s.LatestSuccessful, *s.Latest) < 0 { + return errors.New("operation history latest successful run cannot be newer than its latest run") + } + } + return nil +} + +func validateStatusRun(status LaneHistoryStatus, role string, run *Run, requiredState State) error { + if run == nil { + return nil + } + if err := run.Validate(); err != nil { + return fmt.Errorf("validate operation history %s run: %w", role, err) + } + if run.ID.Kind() != status.Kind || run.Lane != status.Lane { + return fmt.Errorf("operation history %s run does not belong to its lane status", role) + } + if requiredState != "" && run.State != requiredState { + return fmt.Errorf("operation history %s run must have state %q", role, requiredState) + } + return nil +} + +type HistoryReader interface { + Kinds() []Kind + ListRuns(ctx context.Context, query Query) ([]Run, error) + GetRun(ctx context.Context, id StableID) (Run, error) + LaneStatus(ctx context.Context, kind Kind) (LaneHistoryStatus, error) +} diff --git a/internal/operations/types_test.go b/internal/operations/types_test.go new file mode 100644 index 000000000..a92b7836b --- /dev/null +++ b/internal/operations/types_test.go @@ -0,0 +1,757 @@ +package operations + +import ( + "context" + "reflect" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/peoplesweep" +) + +func TestOperationEnumsRejectUnknownValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + valid []string + validate func(string) error + }{ + { + name: "kind", + valid: []string{ + "source_sync", "person_sweep", "carddav_sync", "message_embedding", + "person_embedding", "document_extraction", "document_embedding", + "visual_embedding", "person_enrichment", + }, + validate: func(value string) error { return Kind(value).Validate() }, + }, + { + name: "lane", + valid: []string{"messages", "person_facts", "contacts", "documents", "visual_attachments"}, + validate: func(value string) error { return Lane(value).Validate() }, + }, + { + name: "state", + valid: []string{"queued", "running", "succeeded", "partial", "failed", "cancelled"}, + validate: func(value string) error { return State(value).Validate() }, + }, + { + name: "trigger", + valid: []string{"manual", "scheduled"}, + validate: func(value string) error { return Trigger(value).Validate() }, + }, + { + name: "counter name", + valid: []string{ + "processed", "added", "updated", "item_errors", "attempted", "succeeded", + "failed", "projected_writes", "books", "created", "removed", + }, + validate: func(value string) error { return CounterName(value).Validate() }, + }, + { + name: "counter unit", + valid: []string{"messages", "people", "writes", "books", "contacts"}, + validate: func(value string) error { return CounterUnit(value).Validate() }, + }, + { + name: "history availability", + valid: []string{"available", "unavailable"}, + validate: func(value string) error { return HistoryAvailability(value).Validate() }, + }, + { + name: "action", + valid: []string{"carddav_sync", "visual_build", "visual_resume"}, + validate: func(value string) error { return ActionID(value).Validate() }, + }, + { + name: "related status", + valid: []string{ + "listSourceStatus", "getDocumentIndexStatus", "getDocumentVectorStatus", + "getVisualAttachmentStatus", "getCardDAVStatus", + }, + validate: func(value string) error { return RelatedStatusID(value).Validate() }, + }, + { + name: "stable ID type", + valid: []string{"int64", "text"}, + validate: func(value string) error { return StableIDType(value).Validate() }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + for _, value := range test.valid { + require.NoError(t, test.validate(value), value) + } + require.Error(t, test.validate("")) + require.Error(t, test.validate("unknown")) + }) + } +} + +func TestOperationLaneRegistryIsClosedAndSorted(t *testing.T) { + t.Parallel() + + want := []LaneDefinition{ + {Kind: KindCardDAVSync, Lane: LaneContacts, HistoryAvailability: HistoryAvailable}, + {Kind: KindDocumentEmbedding, Lane: LaneDocuments, HistoryAvailability: HistoryUnavailable, UnavailableCode: "document_embedding_history_unavailable"}, + {Kind: KindDocumentExtraction, Lane: LaneDocuments, HistoryAvailability: HistoryUnavailable, UnavailableCode: "document_extraction_history_unavailable"}, + {Kind: KindMessageEmbedding, Lane: LaneMessages, HistoryAvailability: HistoryUnavailable, UnavailableCode: "message_embedding_history_unavailable"}, + {Kind: KindPersonEmbedding, Lane: LanePersonFacts, HistoryAvailability: HistoryUnavailable, UnavailableCode: "person_embedding_history_unavailable"}, + {Kind: KindPersonEnrichment, Lane: LanePersonFacts, HistoryAvailability: HistoryUnavailable, UnavailableCode: "person_enrichment_history_unavailable"}, + {Kind: KindPersonSweep, Lane: LanePersonFacts, HistoryAvailability: HistoryAvailable}, + {Kind: KindSourceSync, Lane: LaneMessages, HistoryAvailability: HistoryAvailable}, + {Kind: KindVisualEmbedding, Lane: LaneVisualAttachments, HistoryAvailability: HistoryUnavailable, UnavailableCode: "visual_embedding_history_unavailable"}, + } + + got := LaneRegistry() + assert.Equal(t, want, got) + require.NotEmpty(t, got) + got[0].Lane = LaneMessages + assert.Equal(t, LaneContacts, LaneRegistry()[0].Lane, "callers must not mutate the registry") +} + +func TestStableIDEnforcesKindPairingAndBounds(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + t.Parallel() + + source, err := NewInt64ID(KindSourceSync, 10) + require.NoError(err) + assert.Equal(KindSourceSync, source.Kind()) + assert.Equal(StableIDInt64, source.Type()) + value, ok := source.Int64() + assert.True(ok) + assert.Equal(int64(10), value) + _, ok = source.Text() + assert.False(ok) + require.NoError(source.Validate()) + + people, err := NewTextID(KindPersonSweep, "run-0002") + require.NoError(err) + assert.Equal(StableIDText, people.Type()) + text, ok := people.Text() + assert.True(ok) + assert.Equal("run-0002", text) + _, ok = people.Int64() + assert.False(ok) + + _, err = NewInt64ID(KindSourceSync, 0) + require.Error(err) + _, err = NewInt64ID(KindSourceSync, -1) + require.Error(err) + _, err = NewTextID(KindPersonSweep, "") + require.Error(err) + _, err = NewTextID(KindPersonSweep, " run-0002") + require.Error(err) + _, err = NewTextID(KindPersonSweep, strings.Repeat("x", MaxTextStableIDBytes+1)) + require.Error(err) + _, err = NewTextID(KindPersonSweep, string([]byte{0xff})) + require.Error(err) + + _, err = NewTextID(KindSourceSync, "10") + require.Error(err) + _, err = NewInt64ID(KindPersonSweep, 10) + require.Error(err) + _, err = NewInt64ID(KindMessageEmbedding, 10) + require.Error(err, "unavailable lanes must not gain a run representation") + require.Error((StableID{}).Validate()) +} + +func TestRunOrderingUsesTimeThenKindThenTypedID(t *testing.T) { + t.Parallel() + + instant := time.Date(2026, 8, 28, 12, 34, 56, 123456000, time.UTC) + source10 := mustInt64ID(t, KindSourceSync, 10) + source9 := mustInt64ID(t, KindSourceSync, 9) + cardDAV10 := mustInt64ID(t, KindCardDAVSync, 10) + peopleB := mustTextID(t, KindPersonSweep, "run-b") + peopleA := mustTextID(t, KindPersonSweep, "run-a") + + runs := []Run{ + {ID: source9, Lane: LaneMessages, StartedAt: instant}, + {ID: peopleA, Lane: LanePersonFacts, StartedAt: instant}, + {ID: source10, Lane: LaneMessages, StartedAt: instant}, + {ID: peopleB, Lane: LanePersonFacts, StartedAt: instant}, + {ID: cardDAV10, Lane: LaneContacts, StartedAt: instant}, + {ID: source10, Lane: LaneMessages, StartedAt: instant.Add(time.Second)}, + } + + SortRuns(runs) + assert.Equal(t, []StableID{ + source10, + cardDAV10, + peopleB, + peopleA, + source10, + source9, + }, runIDs(runs)) + + sameInstantDifferentZones := []Run{ + {ID: source9, Lane: LaneMessages, StartedAt: instant.In(time.FixedZone("synthetic", -7*60*60))}, + {ID: source10, Lane: LaneMessages, StartedAt: instant}, + } + SortRuns(sameInstantDifferentZones) + assert.Equal(t, []StableID{source10, source9}, runIDs(sameInstantDifferentZones)) +} + +func TestRunValidationEnforcesCounterAllowLists(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + kind Kind + counters []PublicCounter + wantErr bool + }{ + { + name: "source counters", + kind: KindSourceSync, + counters: []PublicCounter{ + {Name: CounterProcessed, Unit: CounterUnitMessages, Value: 12}, + {Name: CounterAdded, Unit: CounterUnitMessages, Value: 3}, + {Name: CounterUpdated, Unit: CounterUnitMessages, Value: 2}, + {Name: CounterItemErrors, Unit: CounterUnitMessages, Value: 1}, + }, + }, + { + name: "people counters", + kind: KindPersonSweep, + counters: []PublicCounter{ + {Name: CounterAttempted, Unit: CounterUnitPeople, Value: 5}, + {Name: CounterSucceeded, Unit: CounterUnitPeople, Value: 3}, + {Name: CounterFailed, Unit: CounterUnitPeople, Value: 2}, + {Name: CounterProjectedWrites, Unit: CounterUnitWrites, Value: 7}, + }, + }, + { + name: "CardDAV counters", + kind: KindCardDAVSync, + counters: []PublicCounter{ + {Name: CounterBooks, Unit: CounterUnitBooks, Value: 2}, + {Name: CounterCreated, Unit: CounterUnitContacts, Value: 3}, + {Name: CounterUpdated, Unit: CounterUnitContacts, Value: 4}, + {Name: CounterRemoved, Unit: CounterUnitContacts, Value: 1}, + }, + }, + { + name: "negative", + kind: KindSourceSync, + counters: []PublicCounter{{Name: CounterProcessed, Unit: CounterUnitMessages, Value: -1}}, + wantErr: true, + }, + { + name: "duplicate", + kind: KindSourceSync, + counters: []PublicCounter{ + {Name: CounterProcessed, Unit: CounterUnitMessages, Value: 1}, + {Name: CounterProcessed, Unit: CounterUnitMessages, Value: 2}, + }, + wantErr: true, + }, + { + name: "wrong unit", + kind: KindSourceSync, + counters: []PublicCounter{{Name: CounterProcessed, Unit: CounterUnitPeople, Value: 1}}, + wantErr: true, + }, + { + name: "wrong kind", + kind: KindSourceSync, + counters: []PublicCounter{{Name: CounterAttempted, Unit: CounterUnitPeople, Value: 1}}, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := ValidateCounters(test.kind, test.counters) + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestOperationSourceStateProjection(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + durable string + itemErrors int64 + added int64 + updated int64 + wantState State + wantError *PublicError + wantErr bool + }{ + {name: "running", durable: "running", wantState: StateRunning}, + {name: "completed clean", durable: "completed", wantState: StateSucceeded}, + {name: "completed with item errors", durable: "completed", itemErrors: 1, wantState: StatePartial}, + {name: "failed after add", durable: "failed", added: 1, wantState: StatePartial, wantError: sourceSyncFailedError()}, + {name: "failed after update", durable: "failed", updated: 1, wantState: StatePartial, wantError: sourceSyncFailedError()}, + {name: "failed without mutation", durable: "failed", wantState: StateFailed, wantError: sourceSyncFailedError()}, + {name: "legacy cancelled", durable: "cancelled", wantState: StateCancelled}, + {name: "unknown", durable: "paused", wantErr: true}, + {name: "negative errors", durable: "completed", itemErrors: -1, wantErr: true}, + {name: "negative added", durable: "failed", added: -1, wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + t.Parallel() + state, publicError, err := ProjectSourceState(test.durable, test.itemErrors, test.added, test.updated) + if test.wantErr { + require.Error(err) + return + } + require.NoError(err) + assert.Equal(test.wantState, state) + assert.Equal(test.wantError, publicError) + }) + } +} + +func TestOperationPublicFailureProjectionIsFixedAndBounded(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + t.Parallel() + + people := []struct { + class peoplesweep.FailureClass + code PublicErrorCode + }{ + {peoplesweep.FailurePolicy, PublicErrorPolicy}, + {peoplesweep.FailureBudget, PublicErrorBudget}, + {peoplesweep.FailureLeaseLost, PublicErrorLeaseLost}, + {peoplesweep.FailureRateLimited, PublicErrorRateLimited}, + {peoplesweep.FailureTimeout, PublicErrorTimeout}, + {peoplesweep.FailureProviderHTTP, PublicErrorProviderHTTP}, + {peoplesweep.FailureInvalidOutput, PublicErrorInvalidOutput}, + {peoplesweep.FailureArchiveGap, PublicErrorArchiveGap}, + {peoplesweep.FailureInternal, PublicErrorInternal}, + {"", PublicErrorPersonSweepFailed}, + {"synthetic_unknown", PublicErrorPersonSweepFailed}, + } + for _, test := range people { + projected := ProjectPersonSweepFailure(test.class) + assert.Equal(test.code, projected.Code) + require.NoError(projected.Validate()) + assert.NotEmpty(projected.Message) + assert.LessOrEqual(len(projected.Message), MaxPublicErrorMessageBytes) + } + + cardDAV := []struct { + durable string + code PublicErrorCode + }{ + {"cancelled", PublicErrorCancelled}, + {"retry_after", PublicErrorRetryAfter}, + {"authentication_failed", PublicErrorAuthenticationFailed}, + {"upstream_failed", PublicErrorUpstreamFailed}, + {"safety_limit", PublicErrorSafetyLimit}, + {"sync_failed", PublicErrorSyncFailed}, + {"unsafe_error_redacted", PublicErrorUnsafeErrorRedacted}, + {"daemon_restarted", PublicErrorDaemonRestarted}, + {"synthetic_unknown", PublicErrorCardDAVSyncFailed}, + } + for _, test := range cardDAV { + projected := ProjectCardDAVFailure(test.durable) + require.NotNil(t, projected) + assert.Equal(test.code, projected.Code) + require.NoError(projected.Validate()) + assert.LessOrEqual(len(projected.Message), MaxPublicErrorMessageBytes) + } + assert.Nil(ProjectCardDAVFailure("")) + require.Error((PublicError{Code: PublicErrorSourceSyncFailed, Message: "arbitrary database text"}).Validate()) + require.Error((PublicError{Code: "unknown", Message: "fixed-looking"}).Validate()) +} + +func TestOperationQueryIsNormalizedAndPrivacyBounded(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + t.Parallel() + + position := &Position{ + StartedAt: time.Date(2026, 8, 28, 12, 34, 56, 0, time.UTC), + ID: mustInt64ID(t, KindSourceSync, 12), + } + valid := Query{ + Kinds: []Kind{KindCardDAVSync, KindSourceSync}, + States: []State{StateFailed, StatePartial}, + Position: position, + Limit: 25, + } + require.NoError(valid.Validate()) + require.Error((Query{Kinds: []Kind{KindSourceSync, KindCardDAVSync}, Limit: 25}).Validate(), "kinds must be normalized") + require.Error((Query{Kinds: []Kind{KindSourceSync, KindSourceSync}, Limit: 25}).Validate(), "duplicate kinds must reject") + require.Error((Query{States: []State{StatePartial, StateFailed}, Limit: 25}).Validate(), "states must be normalized") + require.Error((Query{States: []State{StateFailed, StateFailed}, Limit: 25}).Validate(), "duplicate states must reject") + require.Error((Query{Kinds: []Kind{"unknown"}, Limit: 25}).Validate()) + require.Error((Query{States: []State{"unknown"}, Limit: 25}).Validate()) + require.Error((Query{Limit: 0}).Validate()) + require.Error((Query{Limit: 101}).Validate()) + require.Error((Query{Position: &Position{}, Limit: 25}).Validate()) + require.Error((Query{ + Kinds: []Kind{KindCardDAVSync}, Position: position, Limit: 25, + }).Validate(), "the position kind must be selected") + nonUTCPosition := *position + nonUTCPosition.StartedAt = position.StartedAt.In(time.FixedZone("synthetic", 2*60*60)) + require.Error((Query{Position: &nonUTCPosition, Limit: 25}).Validate(), "positions must be normalized to UTC") + + queryType := reflect.TypeFor[Query]() + fields := make([]string, 0, queryType.NumField()) + for field := range queryType.Fields() { + fields = append(fields, field.Name) + } + assert.Equal([]string{"Kinds", "States", "Position", "Limit"}, fields) +} + +func TestOperationRunValidationRejectsCrossLaneAndArbitraryFailure(t *testing.T) { + require := require.New(t) + t.Parallel() + + started := time.Date(2026, 8, 28, 12, 34, 56, 0, time.UTC) + finished := started.Add(time.Minute) + trigger := TriggerManual + run := Run{ + ID: mustInt64ID(t, KindCardDAVSync, 1), + Lane: LaneContacts, + State: StateSucceeded, + Trigger: &trigger, + StartedAt: started, + FinishedAt: &finished, + Counters: []PublicCounter{ + {Name: CounterBooks, Unit: CounterUnitBooks, Value: 1}, + }, + } + require.NoError(run.Validate()) + + wrongLane := run + wrongLane.Lane = LaneMessages + require.Error(wrongLane.Validate()) + + badTime := run + before := started.Add(-time.Second) + badTime.FinishedAt = &before + require.Error(badTime.Validate()) + + badTrigger := run + unknownTrigger := Trigger("unknown") + badTrigger.Trigger = &unknownTrigger + require.Error(badTrigger.Validate()) + + sourceTrigger := run + sourceTrigger.ID = mustInt64ID(t, KindSourceSync, 1) + sourceTrigger.Lane = LaneMessages + sourceTrigger.Counters = []PublicCounter{ + {Name: CounterProcessed, Unit: CounterUnitMessages, Value: 1}, + } + require.Error(sourceTrigger.Validate(), "source history has no truthful trigger") + + queued := run + queued.State = StateQueued + require.Error(queued.Validate(), "the first-slice ledgers must not synthesize queued runs") + + nonUTC := run + nonUTC.StartedAt = started.In(time.FixedZone("synthetic", 2*60*60)) + require.Error(nonUTC.Validate()) + + badError := run + badError.Error = &PublicError{Code: PublicErrorCardDAVSyncFailed, Message: "stored private marker"} + require.Error(badError.Validate()) + + succeededWithFixedFailure := run + succeededWithFixedFailure.Error = ProjectCardDAVFailure("sync_failed") + require.Error(succeededWithFixedFailure.Validate()) +} + +func TestOperationRunValidationEnforcesKindStateErrorMatrix(t *testing.T) { + t.Parallel() + + sourceError := sourceSyncFailedError() + personErrorValue := ProjectPersonSweepFailure(peoplesweep.FailureTimeout) + personError := &personErrorValue + cardDAVError := ProjectCardDAVFailure("upstream_failed") + runningWithFinish := operationRunFixture(t, KindCardDAVSync, StateRunning, nil) + runningFinishedAt := runningWithFinish.StartedAt.Add(time.Minute) + runningWithFinish.FinishedAt = &runningFinishedAt + failedWithoutFinish := operationRunFixture(t, KindCardDAVSync, StateFailed, cardDAVError) + failedWithoutFinish.FinishedAt = nil + sourceItemErrorPartial := operationRunFixture(t, KindSourceSync, StatePartial, nil) + sourceItemErrorPartial.Counters = append(sourceItemErrorPartial.Counters, PublicCounter{ + Name: CounterItemErrors, Unit: CounterUnitMessages, Value: 1, + }) + sourceAddedPartial := operationRunFixture(t, KindSourceSync, StatePartial, sourceError) + sourceAddedPartial.Counters = append(sourceAddedPartial.Counters, PublicCounter{ + Name: CounterAdded, Unit: CounterUnitMessages, Value: 1, + }) + sourceUpdatedPartial := operationRunFixture(t, KindSourceSync, StatePartial, sourceError) + sourceUpdatedPartial.Counters = append(sourceUpdatedPartial.Counters, PublicCounter{ + Name: CounterUpdated, Unit: CounterUnitMessages, Value: 1, + }) + sourceSucceededWithItemErrors := operationRunFixture(t, KindSourceSync, StateSucceeded, nil) + sourceSucceededWithItemErrors.Counters = append(sourceSucceededWithItemErrors.Counters, PublicCounter{ + Name: CounterItemErrors, Unit: CounterUnitMessages, Value: 1, + }) + sourceFailedAfterAdd := operationRunFixture(t, KindSourceSync, StateFailed, sourceError) + sourceFailedAfterAdd.Counters = append(sourceFailedAfterAdd.Counters, PublicCounter{ + Name: CounterAdded, Unit: CounterUnitMessages, Value: 1, + }) + sourceFailedAfterUpdate := operationRunFixture(t, KindSourceSync, StateFailed, sourceError) + sourceFailedAfterUpdate.Counters = append(sourceFailedAfterUpdate.Counters, PublicCounter{ + Name: CounterUpdated, Unit: CounterUnitMessages, Value: 1, + }) + + tests := []struct { + name string + run Run + wantErr bool + }{ + {name: "source running", run: operationRunFixture(t, KindSourceSync, StateRunning, nil)}, + {name: "source succeeded", run: operationRunFixture(t, KindSourceSync, StateSucceeded, nil)}, + {name: "source partial from completed item errors", run: sourceItemErrorPartial}, + {name: "source partial from failed add", run: sourceAddedPartial}, + {name: "source partial from failed update", run: sourceUpdatedPartial}, + {name: "source failed", run: operationRunFixture(t, KindSourceSync, StateFailed, sourceError)}, + {name: "source legacy cancelled", run: operationRunFixture(t, KindSourceSync, StateCancelled, nil)}, + {name: "source succeeded with item errors", run: sourceSucceededWithItemErrors, wantErr: true}, + {name: "source failed after add", run: sourceFailedAfterAdd, wantErr: true}, + {name: "source failed after update", run: sourceFailedAfterUpdate, wantErr: true}, + {name: "source failed missing error", run: operationRunFixture(t, KindSourceSync, StateFailed, nil), wantErr: true}, + {name: "source completed-origin partial with processed only", run: operationRunFixture(t, KindSourceSync, StatePartial, nil), wantErr: true}, + {name: "source failed-origin partial with processed only", run: operationRunFixture(t, KindSourceSync, StatePartial, sourceError), wantErr: true}, + {name: "source failed with people error", run: operationRunFixture(t, KindSourceSync, StateFailed, personError), wantErr: true}, + {name: "source partial with CardDAV error", run: operationRunFixture(t, KindSourceSync, StatePartial, cardDAVError), wantErr: true}, + {name: "source cancelled with failure", run: operationRunFixture(t, KindSourceSync, StateCancelled, sourceError), wantErr: true}, + + {name: "person running", run: operationRunFixture(t, KindPersonSweep, StateRunning, nil)}, + {name: "person succeeded", run: operationRunFixture(t, KindPersonSweep, StateSucceeded, nil)}, + {name: "person partial", run: operationRunFixture(t, KindPersonSweep, StatePartial, personError)}, + {name: "person failed", run: operationRunFixture(t, KindPersonSweep, StateFailed, personError)}, + {name: "person cancelled unsupported", run: operationRunFixture(t, KindPersonSweep, StateCancelled, personError), wantErr: true}, + {name: "person partial missing error", run: operationRunFixture(t, KindPersonSweep, StatePartial, nil), wantErr: true}, + {name: "person failed missing error", run: operationRunFixture(t, KindPersonSweep, StateFailed, nil), wantErr: true}, + {name: "person failed with source error", run: operationRunFixture(t, KindPersonSweep, StateFailed, sourceError), wantErr: true}, + {name: "person failed with CardDAV error", run: operationRunFixture(t, KindPersonSweep, StateFailed, cardDAVError), wantErr: true}, + + {name: "CardDAV running", run: operationRunFixture(t, KindCardDAVSync, StateRunning, nil)}, + {name: "CardDAV succeeded", run: operationRunFixture(t, KindCardDAVSync, StateSucceeded, nil)}, + {name: "CardDAV partial", run: operationRunFixture(t, KindCardDAVSync, StatePartial, cardDAVError)}, + {name: "CardDAV failed", run: operationRunFixture(t, KindCardDAVSync, StateFailed, cardDAVError)}, + {name: "CardDAV cancelled", run: operationRunFixture(t, KindCardDAVSync, StateCancelled, ProjectCardDAVFailure("cancelled"))}, + {name: "CardDAV partial missing error", run: operationRunFixture(t, KindCardDAVSync, StatePartial, nil), wantErr: true}, + {name: "CardDAV failed missing error", run: operationRunFixture(t, KindCardDAVSync, StateFailed, nil), wantErr: true}, + {name: "CardDAV cancelled missing error", run: operationRunFixture(t, KindCardDAVSync, StateCancelled, nil), wantErr: true}, + {name: "CardDAV failed with people error", run: operationRunFixture(t, KindCardDAVSync, StateFailed, personError), wantErr: true}, + {name: "CardDAV failed with source error", run: operationRunFixture(t, KindCardDAVSync, StateFailed, sourceError), wantErr: true}, + {name: "running with finish", run: runningWithFinish, wantErr: true}, + {name: "terminal without finish", run: failedWithoutFinish, wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := test.run.Validate() + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestLaneHistoryStatusValidationEnforcesRegistryAndRunRoles(t *testing.T) { + newRequirements := require.New + require := require.New(t) + t.Parallel() + + active := operationRunFixture(t, KindCardDAVSync, StateRunning, nil) + active.ID = mustInt64ID(t, KindCardDAVSync, 3) + latest := operationRunFixture(t, KindCardDAVSync, StateFailed, ProjectCardDAVFailure("upstream_failed")) + latest.ID = mustInt64ID(t, KindCardDAVSync, 4) + latestSuccessful := operationRunFixture(t, KindCardDAVSync, StateSucceeded, nil) + latestSuccessful.ID = mustInt64ID(t, KindCardDAVSync, 2) + newerSuccessful := latestSuccessful + newerSuccessful.ID = mustInt64ID(t, KindCardDAVSync, 5) + valid := LaneHistoryStatus{ + Kind: KindCardDAVSync, Lane: LaneContacts, HistoryAvailability: HistoryAvailable, + Active: &active, Latest: &active, LatestSuccessful: &latestSuccessful, + } + require.NoError(valid.Validate()) + require.NoError((LaneHistoryStatus{ + Kind: KindCardDAVSync, Lane: LaneContacts, HistoryAvailability: HistoryAvailable, + Latest: &latest, LatestSuccessful: &latestSuccessful, + }).Validate(), "the latest run may be terminal") + require.NoError((LaneHistoryStatus{ + Kind: KindSourceSync, Lane: LaneMessages, HistoryAvailability: HistoryAvailable, + }).Validate(), "available history with no runs is valid") + require.NoError((LaneHistoryStatus{ + Kind: KindVisualEmbedding, Lane: LaneVisualAttachments, + HistoryAvailability: HistoryUnavailable, + UnavailableCode: "visual_embedding_history_unavailable", + }).Validate()) + + tests := []struct { + name string + mutate func(*testing.T, *LaneHistoryStatus) + }{ + {name: "unknown kind", mutate: func(_ *testing.T, status *LaneHistoryStatus) { status.Kind = "unknown" }}, + {name: "wrong lane", mutate: func(_ *testing.T, status *LaneHistoryStatus) { status.Lane = LaneMessages }}, + {name: "wrong availability", mutate: func(_ *testing.T, status *LaneHistoryStatus) { status.HistoryAvailability = HistoryUnavailable }}, + {name: "available with unavailable code", mutate: func(_ *testing.T, status *LaneHistoryStatus) { status.UnavailableCode = "synthetic_unavailable" }}, + {name: "active terminal", mutate: func(_ *testing.T, status *LaneHistoryStatus) { status.Active = &latestSuccessful }}, + {name: "active wrong kind", mutate: func(t *testing.T, status *LaneHistoryStatus) { + t.Helper() + wrong := operationRunFixture(t, KindSourceSync, StateRunning, nil) + status.Active = &wrong + }}, + {name: "latest wrong kind", mutate: func(t *testing.T, status *LaneHistoryStatus) { + t.Helper() + wrong := operationRunFixture(t, KindPersonSweep, StateFailed, personFailureForTest()) + status.Latest = &wrong + }}, + {name: "latest successful partial", mutate: func(t *testing.T, status *LaneHistoryStatus) { + t.Helper() + partial := operationRunFixture(t, KindCardDAVSync, StatePartial, ProjectCardDAVFailure("sync_failed")) + status.LatestSuccessful = &partial + }}, + {name: "latest successful wrong kind", mutate: func(t *testing.T, status *LaneHistoryStatus) { + t.Helper() + wrong := operationRunFixture(t, KindSourceSync, StateSucceeded, nil) + status.LatestSuccessful = &wrong + }}, + {name: "latest successful without latest", mutate: func(_ *testing.T, status *LaneHistoryStatus) { + status.Active = nil + status.Latest = nil + }}, + {name: "latest successful newer than latest", mutate: func(_ *testing.T, status *LaneHistoryStatus) { + status.Active = nil + status.Latest = &latest + status.LatestSuccessful = &newerSuccessful + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := newRequirements(t) + t.Parallel() + status := valid + test.mutate(t, &status) + require.Error(status.Validate()) + }) + } + + unavailable := LaneHistoryStatus{ + Kind: KindVisualEmbedding, Lane: LaneVisualAttachments, + HistoryAvailability: HistoryUnavailable, + UnavailableCode: "visual_embedding_history_unavailable", + } + t.Run("unavailable wrong code", func(t *testing.T) { + require := newRequirements(t) + status := unavailable + status.UnavailableCode = "generic_unavailable" + require.Error(status.Validate()) + }) + t.Run("unavailable carries run", func(t *testing.T) { + require := newRequirements(t) + status := unavailable + status.Latest = &latest + require.Error(status.Validate()) + }) +} + +func TestHistoryReaderContractUsesNormalizedTypes(t *testing.T) { + t.Parallel() + + var _ HistoryReader = (*readerContractStub)(nil) +} + +type readerContractStub struct{} + +func (*readerContractStub) Kinds() []Kind { return nil } + +func (*readerContractStub) ListRuns(context.Context, Query) ([]Run, error) { return nil, nil } + +func (*readerContractStub) GetRun(context.Context, StableID) (Run, error) { return Run{}, nil } + +func (*readerContractStub) LaneStatus(context.Context, Kind) (LaneHistoryStatus, error) { + return LaneHistoryStatus{}, nil +} + +func mustInt64ID(t *testing.T, kind Kind, id int64) StableID { + t.Helper() + stableID, err := NewInt64ID(kind, id) + require.NoError(t, err) + return stableID +} + +func mustTextID(t *testing.T, kind Kind, id string) StableID { + t.Helper() + stableID, err := NewTextID(kind, id) + require.NoError(t, err) + return stableID +} + +func runIDs(runs []Run) []StableID { + ids := make([]StableID, 0, len(runs)) + for _, run := range runs { + ids = append(ids, run.ID) + } + return ids +} + +func sourceSyncFailedError() *PublicError { + return &PublicError{ + Code: PublicErrorSourceSyncFailed, + Message: "Source sync failed.", + } +} + +func operationRunFixture(t *testing.T, kind Kind, state State, publicError *PublicError) Run { + t.Helper() + + started := time.Date(2026, 8, 28, 12, 34, 56, 0, time.UTC) + run := Run{State: state, StartedAt: started, Error: publicError} + switch kind { + case KindSourceSync: + run.ID = mustInt64ID(t, kind, 1) + run.Lane = LaneMessages + run.Counters = []PublicCounter{{Name: CounterProcessed, Unit: CounterUnitMessages, Value: 1}} + case KindPersonSweep: + run.ID = mustTextID(t, kind, "run-fixture") + run.Lane = LanePersonFacts + run.Counters = []PublicCounter{{Name: CounterAttempted, Unit: CounterUnitPeople, Value: 1}} + trigger := TriggerManual + run.Trigger = &trigger + case KindCardDAVSync: + run.ID = mustInt64ID(t, kind, 1) + run.Lane = LaneContacts + run.Counters = []PublicCounter{{Name: CounterBooks, Unit: CounterUnitBooks, Value: 1}} + trigger := TriggerScheduled + run.Trigger = &trigger + default: + require.FailNow(t, "unsupported operation run fixture kind", string(kind)) + } + if state != StateRunning { + finished := started.Add(time.Minute) + run.FinishedAt = &finished + } + return run +} + +func personFailureForTest() *PublicError { + value := ProjectPersonSweepFailure(peoplesweep.FailureInternal) + return &value +} diff --git a/internal/personenrichment/config.go b/internal/personenrichment/config.go index a29615735..e3a011a07 100644 --- a/internal/personenrichment/config.go +++ b/internal/personenrichment/config.go @@ -177,6 +177,34 @@ func (c ProviderConfig) Validate() error { return c.validatePolicy(true) } +// CredentialEndpoint returns the endpoint origin that may receive this +// provider's credential. Asynchronous providers must keep every credential- +// bearing endpoint on that same origin. +func (c ProviderConfig) CredentialEndpoint() (string, error) { + endpoint, err := validateHTTPSEndpoint("endpoint", c.Endpoint) + if err != nil { + return "", err + } + switch c.Kind { + case ProviderExa: + if c.PollEndpoint != "" { + return "", errors.New("exa poll_endpoint must be empty") + } + case ProviderSixtyfour: + pollEndpoint, err := validateHTTPSEndpoint("poll_endpoint", c.PollEndpoint) + if err != nil { + return "", err + } + if !strings.EqualFold(endpoint.Scheme, pollEndpoint.Scheme) || + !strings.EqualFold(endpoint.Host, pollEndpoint.Host) { + return "", errors.New("sixtyfour endpoint and poll_endpoint must use the same origin") + } + default: + return "", fmt.Errorf("kind must be %q or %q", ProviderExa, ProviderSixtyfour) + } + return c.Endpoint, nil +} + func (c ProviderConfig) validatePolicy(enforceGuaranteedCost bool) error { if c.Name == "" || c.Name != strings.TrimSpace(c.Name) || !providerNamePattern.MatchString(c.Name) { return errors.New("name must be a CLI-safe token using only letters, digits, '.', '_', ':', or '-'") @@ -184,7 +212,7 @@ func (c ProviderConfig) validatePolicy(enforceGuaranteedCost bool) error { if c.Kind != ProviderExa && c.Kind != ProviderSixtyfour { return fmt.Errorf("kind must be %q or %q", ProviderExa, ProviderSixtyfour) } - if _, err := validateHTTPSEndpoint("endpoint", c.Endpoint); err != nil { + if _, err := c.CredentialEndpoint(); err != nil { return err } if c.APIKeyEnv == "" || !environmentNamePattern.MatchString(c.APIKeyEnv) { @@ -237,9 +265,6 @@ func (c ProviderConfig) validatePolicy(enforceGuaranteedCost bool) error { if c.Mode != "people" && c.Mode != "deep" && c.Mode != "deep-reasoning" { return fmt.Errorf("invalid Exa mode %q", c.Mode) } - if c.PollEndpoint != "" { - return errors.New("exa poll_endpoint must be empty") - } if c.Tier != "" { return errors.New("exa tier must be empty") } @@ -247,9 +272,6 @@ func (c ProviderConfig) validatePolicy(enforceGuaranteedCost bool) error { return errors.New("exa num_results must be exactly 1") } case ProviderSixtyfour: - if _, err := validateHTTPSEndpoint("poll_endpoint", c.PollEndpoint); err != nil { - return err - } if strings.TrimSpace(c.Tier) == "" { return errors.New("sixtyfour tier is required") } diff --git a/internal/personenrichment/egress_test.go b/internal/personenrichment/egress_test.go index e53b68d4c..e3c4c5ff7 100644 --- a/internal/personenrichment/egress_test.go +++ b/internal/personenrichment/egress_test.go @@ -358,6 +358,80 @@ func TestEgressGateRejectsMissingCredentialAfterSuppressionChecks(t *testing.T) assert.Equal(t, []string{"consent", "key_ids", "suppression", "credential"}, events) } +func TestEgressGateResolvesCredentialsByStableProviderProfile(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + hasher, err := personenrichment.NewSuppressionHasher(bytes.Repeat([]byte{0x79}, 32)) + requirements.NoError(err) + profiles := make([]personenrichment.ProviderProfile, 0, 2) + for _, name := range []string{"exa-primary", "exa-secondary"} { + provider := validProviderConfig(personenrichment.ProviderExa) + provider.Name = name + provider.APIKeyEnv = "SHARED_EXA_KEY" + provider.AllowedIdentifiers = []personenrichment.IdentifierClass{personenrichment.IdentifierEmail} + profile, profileErr := provider.Profile(profileCatalog()) + requirements.NoError(profileErr) + profiles = append(profiles, profile) + } + events := []string{} + probe := hasher.Digest(profiles[0].ProviderNamespace, personenrichment.SuppressionEmail, + personenrichment.EmailNormalizationV1, "person@example.com") + gate, err := personenrichment.NewProviderBoundEgressGate( + recordingConsentChecker{events: &events, active: true}, + &recordingSuppressionChecker{events: &events, keyIDs: []string{probe.KeyID}, suppressed: map[string]bool{}}, + hasher, + func(profile personenrichment.ProviderProfile) (string, bool, error) { + events = append(events, "credential:"+profile.Name) + return "stored-" + profile.Name, true, nil + }, + ) + requirements.NoError(err) + + for _, profile := range profiles { + authorization, authorizeErr := gate.Authorize(t.Context(), personenrichment.EgressInput{ + Request: personenrichment.Request{Identity: personenrichment.Identity{Email: "person@example.com"}}, + Profile: profile, + }) + requirements.NoError(authorizeErr) + assertions.Equal("stored-"+profile.Name, authorization.Credential) + } + assertions.Contains(events, "credential:exa-primary") + assertions.Contains(events, "credential:exa-secondary") +} + +func TestEgressGateProviderCredentialResolutionFailureDoesNotUseEnvironmentFallback(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + hasher, err := personenrichment.NewSuppressionHasher(bytes.Repeat([]byte{0x7a}, 32)) + requirements.NoError(err) + profile := egressProfile(t, []personenrichment.IdentifierClass{personenrichment.IdentifierEmail}) + events := []string{} + probe := hasher.Digest(profile.ProviderNamespace, personenrichment.SuppressionEmail, + personenrichment.EmailNormalizationV1, "person@example.com") + resolutionErr := errors.New("credential store binding mismatch") + gate, err := personenrichment.NewProviderBoundEgressGate( + recordingConsentChecker{events: &events, active: true}, + &recordingSuppressionChecker{events: &events, keyIDs: []string{probe.KeyID}, suppressed: map[string]bool{}}, + hasher, + func(personenrichment.ProviderProfile) (string, bool, error) { + events = append(events, "provider_credential") + return "", false, resolutionErr + }, + ) + requirements.NoError(err) + gate.LookupCredential = func(string) (string, bool) { + events = append(events, "environment") + return "environment-secret", true + } + + _, err = gate.Authorize(t.Context(), personenrichment.EgressInput{ + Request: personenrichment.Request{Identity: personenrichment.Identity{Email: "person@example.com"}}, + Profile: profile, + }) + requirements.ErrorIs(err, resolutionErr) + assertions.NotContains(events, "environment") +} + func TestEgressGateRejectsInvalidConstruction(t *testing.T) { hasher, err := personenrichment.NewSuppressionHasher(bytes.Repeat([]byte{0x81}, 32)) require.NoError(t, err) diff --git a/internal/personenrichment/sixtyfour_test.go b/internal/personenrichment/sixtyfour_test.go index be7b588b9..e98e5290d 100644 --- a/internal/personenrichment/sixtyfour_test.go +++ b/internal/personenrichment/sixtyfour_test.go @@ -145,6 +145,17 @@ func TestSixtyfourAsyncLifecycleUsesExactWireAndSurvivesRestart(t *testing.T) { checks.NotEqual(attempt.ProgramFingerprint, changedAttempt.ProgramFingerprint) } +func TestSixtyfourProviderRejectsCredentialDestinationsOnDifferentOrigins(t *testing.T) { + config := sixtyfourConfig( + "https://start.example.test/people-intelligence-async", + "https://poll.example.test/job-status", + ) + + provider, err := personenrichment.NewSixtyfourProvider(config, "test-key", http.DefaultClient) + require.ErrorContains(t, err, "same origin") + assert.Nil(t, provider) +} + func TestSixtyfourRejectsUndocumentedRequestID(t *testing.T) { t.Run("start", func(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/personenrichment/suppression.go b/internal/personenrichment/suppression.go index d45185236..ae38099b6 100644 --- a/internal/personenrichment/suppression.go +++ b/internal/personenrichment/suppression.go @@ -361,6 +361,11 @@ func cloneTimePointer(value *time.Time) *time.Time { type CredentialLookup func(string) (string, bool) +// ProviderCredentialLookup resolves a credential from the complete immutable +// provider profile. Stable names and canonical endpoints remain available to +// credential stores that bind secrets more narrowly than an environment name. +type ProviderCredentialLookup func(ProviderProfile) (string, bool, error) + type EgressInput struct { Request Request Profile ProviderProfile @@ -374,10 +379,30 @@ type Authorization struct { } type EgressGate struct { - Consent ConsentChecker - Suppressions SuppressionChecker - Hasher *SuppressionHasher - LookupCredential CredentialLookup + Consent ConsentChecker + Suppressions SuppressionChecker + Hasher *SuppressionHasher + LookupCredential CredentialLookup + LookupProviderCredential ProviderCredentialLookup +} + +// NewProviderBoundEgressGate constructs a gate whose late credential lookup +// receives the consented provider profile. Resolution still happens only +// after consent and every suppression check succeeds. +func NewProviderBoundEgressGate( + consent ConsentChecker, + suppressions SuppressionChecker, + hasher *SuppressionHasher, + lookupProviderCredential ProviderCredentialLookup, +) (*EgressGate, error) { + gate := &EgressGate{ + Consent: consent, Suppressions: suppressions, + Hasher: hasher, LookupProviderCredential: lookupProviderCredential, + } + if err := gate.validate(); err != nil { + return nil, err + } + return gate, nil } func NewEgressGate( @@ -409,7 +434,7 @@ func (g EgressGate) validate() error { if err := g.Hasher.validate(); err != nil { return err } - if g.LookupCredential == nil { + if g.LookupCredential == nil && g.LookupProviderCredential == nil { return errors.New("credential lookup is required") } return nil @@ -453,7 +478,16 @@ func (g EgressGate) Authorize(ctx context.Context, input EgressInput) (Authoriza } } - credential, ok := g.LookupCredential(input.Profile.APIKeyEnv) + var credential string + var ok bool + if g.LookupProviderCredential != nil { + credential, ok, err = g.LookupProviderCredential(input.Profile) + if err != nil { + return Authorization{}, fmt.Errorf("resolve person enrichment provider credential: %w", err) + } + } else { + credential, ok = g.LookupCredential(input.Profile.APIKeyEnv) + } if !ok || credential == "" { return Authorization{}, ErrCredentialUnavailable } diff --git a/internal/providercredentials/permissions_unix.go b/internal/providercredentials/permissions_unix.go new file mode 100644 index 000000000..094bf0333 --- /dev/null +++ b/internal/providercredentials/permissions_unix.go @@ -0,0 +1,120 @@ +//go:build !windows + +package providercredentials + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "syscall" + + "golang.org/x/sys/unix" +) + +type nativePermissions struct{} + +func (nativePermissions) secureDirectory(path string) error { + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("provider credential directory must be a real directory") + } + if err := os.Chmod(path, 0o700); err != nil { // #nosec G302 -- exact private directory mode. + return err + } + return nativePermissions{}.verifyDirectory(path) +} + +func (nativePermissions) verifyDirectory(path string) error { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0) + if err != nil { + return fmt.Errorf("open provider credential directory: %w", err) + } + file := os.NewFile(uintptr(fd), path) + defer file.Close() //nolint:errcheck // read-only directory descriptor + info, err := file.Stat() + if err != nil { + return err + } + if !info.IsDir() || info.Mode().Perm() != 0o700 { + return errors.New("provider credential directory permissions must be 0700") + } + return verifyCurrentOwner(info) +} + +func (nativePermissions) secureFile(file *os.File) error { + if err := file.Chmod(0o600); err != nil { + return err + } + return nativePermissions{}.verifyFile(file) +} + +func (nativePermissions) verifyFile(file *os.File) error { + info, err := file.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + return errors.New("provider credential file permissions must be 0600") + } + return verifyCurrentOwner(info) +} + +func verifyCurrentOwner(info os.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("provider credential owner is unavailable") + } + if stat.Uid != uint32(os.Geteuid()) { //nolint:gosec // OS effective UIDs are non-negative and fit the platform uid_t. + return errors.New("provider credential object is not owned by the current user") + } + return nil +} + +func openStoreFile(path string) (*os.File, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, fmt.Errorf("open provider credential store: %w", err) + } + return os.NewFile(uintptr(fd), path), nil +} + +func withStoreLock(tokenDir string, fn func() error) error { + path := filepath.Join(tokenDir, ".provider-credentials.lock") + fd, err := unix.Open(path, unix.O_RDWR|unix.O_CLOEXEC|unix.O_CREAT|unix.O_NOFOLLOW, 0o600) + if err != nil { + return fmt.Errorf("open provider credential lock: %w", err) + } + file := os.NewFile(uintptr(fd), path) + defer file.Close() //nolint:errcheck // lock result is returned by fn + if err := file.Chmod(0o600); err != nil { + return fmt.Errorf("secure provider credential lock: %w", err) + } + if err := (nativePermissions{}).verifyFile(file); err != nil { + return fmt.Errorf("verify provider credential lock: %w", err) + } + if err := unix.Flock(fd, unix.LOCK_EX); err != nil { + return fmt.Errorf("lock provider credential store: %w", err) + } + defer unix.Flock(fd, unix.LOCK_UN) //nolint:errcheck // closing also releases the lock + return fn() +} + +func replaceStoreFile(source, target string) error { + return os.Rename(source, target) +} + +func syncStoreDirectory(path string) error { + directory, err := os.Open(path) + if err != nil { + return err + } + defer directory.Close() //nolint:errcheck // Sync result is authoritative + return directory.Sync() +} diff --git a/internal/providercredentials/permissions_windows.go b/internal/providercredentials/permissions_windows.go new file mode 100644 index 000000000..da678bf77 --- /dev/null +++ b/internal/providercredentials/permissions_windows.go @@ -0,0 +1,227 @@ +//go:build windows + +package providercredentials + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" +) + +type nativePermissions struct{} + +const providerCredentialFileAllAccess = windows.STANDARD_RIGHTS_REQUIRED | windows.SYNCHRONIZE | 0x1FF + +func (nativePermissions) secureDirectory(path string) error { + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + handle, err := openSecurityHandle(path, true, windows.READ_CONTROL|windows.WRITE_DAC) + if err != nil { + return err + } + defer windows.CloseHandle(handle) //nolint:errcheck // security result is authoritative + return secureOwnerOnlyHandle(handle) +} + +func (nativePermissions) verifyDirectory(path string) error { + handle, err := openSecurityHandle(path, true, windows.READ_CONTROL) + if err != nil { + return err + } + defer windows.CloseHandle(handle) //nolint:errcheck // read-only handle + return verifyOwnerOnlyHandle(handle) +} + +func (nativePermissions) secureFile(file *os.File) error { + if err := file.Chmod(0o600); err != nil { + return err + } + // os.OpenFile and os.CreateTemp do not request WRITE_DAC on Windows, so + // their handles cannot publish the owner-only ACL. Reopen the file through + // the already secured token directory with the exact security rights needed. + handle, err := openSecurityHandle(file.Name(), false, windows.READ_CONTROL|windows.WRITE_DAC) + if err != nil { + return err + } + defer windows.CloseHandle(handle) //nolint:errcheck // security result is authoritative + return secureOwnerOnlyHandle(handle) +} + +func (nativePermissions) verifyFile(file *os.File) error { + return verifyOwnerOnlyHandle(windows.Handle(file.Fd())) +} + +func openSecurityHandle(path string, directory bool, access uint32) (windows.Handle, error) { + path16, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + flags := uint32(windows.FILE_ATTRIBUTE_NORMAL | windows.FILE_FLAG_OPEN_REPARSE_POINT) + if directory { + flags |= windows.FILE_FLAG_BACKUP_SEMANTICS + } + handle, err := windows.CreateFile(path16, access, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, flags, 0) + if err != nil { + return 0, err + } + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + windows.CloseHandle(handle) //nolint:errcheck // original error returned + return 0, err + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + windows.CloseHandle(handle) //nolint:errcheck // rejection is authoritative + return 0, errors.New("provider credential path must not be a reparse point") + } + return handle, nil +} + +func secureOwnerOnlyHandle(handle windows.Handle) error { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return fmt.Errorf("get current user SID: %w", err) + } + entries := []windows.EXPLICIT_ACCESS{{ + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.SET_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, TrusteeType: windows.TRUSTEE_IS_USER, + TrusteeValue: windows.TrusteeValueFromSID(user.User.Sid), + }, + }} + acl, err := windows.ACLFromEntries(entries, nil) + if err != nil { + return fmt.Errorf("build owner-only provider credential DACL: %w", err) + } + securityInfo := windows.DACL_SECURITY_INFORMATION | windows.PROTECTED_DACL_SECURITY_INFORMATION + if err := windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, + windows.SECURITY_INFORMATION(securityInfo), nil, nil, acl, nil); err != nil { + return fmt.Errorf("set owner-only provider credential DACL: %w", err) + } + return verifyOwnerOnlyHandleForUser(handle, user.User.Sid) +} + +func verifyOwnerOnlyHandle(handle windows.Handle) error { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return fmt.Errorf("get current user SID: %w", err) + } + return verifyOwnerOnlyHandleForUser(handle, user.User.Sid) +} + +func verifyOwnerOnlyHandleForUser(handle windows.Handle, user *windows.SID) error { + descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return fmt.Errorf("read provider credential DACL: %w", err) + } + owner, _, err := descriptor.Owner() + if err != nil || owner == nil { + return errors.New("provider credential owner is unavailable") + } + if err := verifyWindowsOwner(owner, user); err != nil { + return err + } + control, _, err := descriptor.Control() + if err != nil { + return err + } + if control&windows.SE_DACL_PROTECTED == 0 { + return errors.New("provider credential DACL permits inherited access") + } + dacl, _, err := descriptor.DACL() + if err != nil || dacl == nil { + return errors.New("provider credential DACL is unavailable") + } + type aclHeader struct { + Revision byte + Sbz1 byte + Size uint16 + AceCount uint16 + Sbz2 uint16 + } + if (*aclHeader)(unsafe.Pointer(dacl)).AceCount != 1 { + return errors.New("provider credential DACL must contain exactly one access entry") + } + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, 0, &ace); err != nil { + return err + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE || + (ace.Mask != windows.GENERIC_ALL && ace.Mask != providerCredentialFileAllAccess) || + ace.Header.AceFlags&windows.INHERITED_ACE != 0 { + return errors.New("provider credential DACL does not grant exactly owner full control") + } + aceSID := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if !aceSID.Equals(user) { + return errors.New("provider credential DACL grants another principal") + } + return nil +} + +func verifyWindowsOwner(owner, user *windows.SID) error { + if owner.Equals(user) { + return nil + } + if owner.IsWellKnown(windows.WinBuiltinAdministratorsSid) { + member, err := windows.Token(0).IsMember(owner) + if err != nil { + return err + } + if member { + return nil + } + } + return errors.New("provider credential owner is not the current user") +} + +func openStoreFile(path string) (*os.File, error) { + handle, err := openSecurityHandle(path, false, windows.GENERIC_READ|windows.READ_CONTROL) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(handle), path), nil +} + +func withStoreLock(tokenDir string, fn func() error) error { + path := filepath.Join(tokenDir, ".provider-credentials.lock") + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return fmt.Errorf("open provider credential lock: %w", err) + } + defer file.Close() //nolint:errcheck // lock result is authoritative + if err := (nativePermissions{}).secureFile(file); err != nil { + return fmt.Errorf("secure provider credential lock: %w", err) + } + var overlapped windows.Overlapped + if err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, + 0, 1, 0, &overlapped); err != nil { + return fmt.Errorf("lock provider credential store: %w", err) + } + defer windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped) //nolint:errcheck + return fn() +} + +func replaceStoreFile(source, target string) error { + from, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(target) + if err != nil { + return err + } + return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} + +// MoveFileEx with WRITE_THROUGH is the Windows namespace durability boundary; +// Windows does not support flushing directory handles. +func syncStoreDirectory(string) error { return nil } diff --git a/internal/providercredentials/store.go b/internal/providercredentials/store.go new file mode 100644 index 000000000..a59e8680f --- /dev/null +++ b/internal/providercredentials/store.go @@ -0,0 +1,448 @@ +// Package providercredentials stores browser-managed provider credentials in +// an owner-only file separate from config.toml. Values are write-only at the +// HTTP boundary and bound to the origin that may receive them. +package providercredentials + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net" + "net/url" + "os" + "path/filepath" + "regexp" + "slices" + "strings" +) + +const ( + Filename = "provider-credentials.json" // #nosec G101 -- filename, not a credential. + VectorEmbeddingsID = "vector.embeddings" + VectorMultimodalID = "vector.multimodal" + PeopleSweepID = "people.sweep" + PersonEnrichmentSuppressionID = "people.enrichment/suppression" + StoredSuppressionEnvironment = "MSGVAULT_STORED_PERSON_ENRICHMENT_SUPPRESSION_KEY" + personEnrichmentCredentialIDPrefix = "people.enrichment/" + credentialStoreVersion = 1 + maximumCredentialStoreBytes int64 = 1 << 20 +) + +var ( + ErrConflict = errors.New("provider credential store changed") + ErrUnavailable = errors.New("provider credential store unavailable") + ErrOriginMismatch = errors.New("stored provider credential is bound to a different endpoint origin") + providerNameRE = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`) +) + +type Source string + +const ( + SourceNone Source = "none" + SourceStored Source = "stored" + SourceEnvironment Source = "environment" +) + +type State struct { + Configured bool `json:"configured"` + Source Source `json:"source"` +} + +type record struct { + ID string `json:"id"` + Kind string `json:"kind"` + Value string `json:"value"` + Origin string `json:"origin,omitempty"` + Revision int64 `json:"revision"` +} + +type storeFile struct { + Version int `json:"version"` + Credentials map[string]record `json:"credentials"` +} + +// Snapshot is an immutable credential-store read and its independent strong +// ETag. Credentials remain private to this package. +type Snapshot struct { + ETag string + credentials map[string]record + loadErr error +} + +type permissionBackend interface { + secureDirectory(path string) error + verifyDirectory(path string) error + secureFile(file *os.File) error + verifyFile(file *os.File) error +} + +func PersonEnrichmentID(name string) string { + return personEnrichmentCredentialIDPrefix + name +} + +func ValidateID(id string) error { + switch id { + case VectorEmbeddingsID, VectorMultimodalID, PeopleSweepID: + return nil + } + if !strings.HasPrefix(id, personEnrichmentCredentialIDPrefix) { + return errors.New("unsupported provider credential ID") + } + name := strings.TrimPrefix(id, personEnrichmentCredentialIDPrefix) + if name == "" || name == "suppression" || !providerNameRE.MatchString(name) { + return errors.New("invalid person-enrichment provider credential ID") + } + return nil +} + +// EndpointOrigin returns the only destination identity stored with a secret. +// It rejects URL components commonly abused to smuggle credentials. +func EndpointOrigin(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed == nil { + return "", errors.New("provider endpoint must be an http or https URL with a host") + } + scheme := strings.ToLower(parsed.Scheme) + if parsed.Host == "" || (scheme != "http" && scheme != "https") { + return "", errors.New("provider endpoint must be an http or https URL with a host") + } + if parsed.User != nil { + return "", errors.New("provider endpoint must not contain credentials") + } + if parsed.RawQuery != "" { + return "", errors.New("provider endpoint must not contain a query") + } + if parsed.Fragment != "" { + return "", errors.New("provider endpoint must not contain a fragment") + } + host := strings.ToLower(parsed.Hostname()) + port := parsed.Port() + if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") { + port = "" + } + if port != "" { + host = net.JoinHostPort(host, port) + } else if strings.Contains(host, ":") { + host = "[" + host + "]" + } + return scheme + "://" + host, nil +} + +func emptyStore() storeFile { + return storeFile{Version: credentialStoreVersion, Credentials: map[string]record{}} +} + +func Read(tokenDir string) (Snapshot, error) { + return readWithPermissions(tokenDir, nativePermissions{}) +} + +func readWithPermissions(tokenDir string, permissions permissionBackend) (Snapshot, error) { + path := filepath.Join(tokenDir, Filename) + file, err := openStoreFile(path) + if errors.Is(err, os.ErrNotExist) { + empty := emptyStore() + encoded, marshalErr := json.Marshal(empty) + if marshalErr != nil { + return unavailableSnapshot(marshalErr) + } + return snapshotFrom(empty, encoded), nil + } + if err != nil { + return unavailableSnapshot(fmt.Errorf("open credential store: %w", err)) + } + defer file.Close() //nolint:errcheck // read-only file + if err := permissions.verifyDirectory(tokenDir); err != nil { + return unavailableSnapshot(fmt.Errorf("verify credential directory: %w", err)) + } + if err := permissions.verifyFile(file); err != nil { + return unavailableSnapshot(fmt.Errorf("verify credential store permissions: %w", err)) + } + raw, err := io.ReadAll(io.LimitReader(file, maximumCredentialStoreBytes+1)) + if err != nil { + return unavailableSnapshot(fmt.Errorf("read credential store: %w", err)) + } + if int64(len(raw)) > maximumCredentialStoreBytes { + return unavailableSnapshot(errors.New("credential store exceeds size limit")) + } + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.DisallowUnknownFields() + var saved storeFile + if err := decoder.Decode(&saved); err != nil { + return unavailableSnapshot(fmt.Errorf("decode credential store: %w", err)) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return unavailableSnapshot(errors.New("credential store contains trailing data")) + } + if err := validateStore(saved); err != nil { + return unavailableSnapshot(err) + } + return snapshotFrom(saved, raw), nil +} + +func unavailableSnapshot(err error) (Snapshot, error) { + wrapped := fmt.Errorf("%w: %w", ErrUnavailable, err) + return Snapshot{loadErr: wrapped}, wrapped +} + +func validateStore(saved storeFile) error { + if saved.Version != credentialStoreVersion || saved.Credentials == nil { + return errors.New("credential store has an unsupported format") + } + for id, credential := range saved.Credentials { + if id != PersonEnrichmentSuppressionID { + if err := ValidateID(id); err != nil { + return errors.New("credential store contains an invalid credential ID") + } + origin, err := EndpointOrigin(credential.Origin) + if err != nil || origin != credential.Origin { + return errors.New("credential store contains an invalid endpoint binding") + } + } else if credential.Origin != "" { + return errors.New("credential store suppression key must not have an endpoint binding") + } + if credential.ID != id || credential.Kind != recordKind(id) || credential.Revision <= 0 { + return errors.New("credential store contains invalid identity metadata") + } + if credential.Value == "" { + return errors.New("credential store contains an empty credential") + } + } + return nil +} + +func snapshotFrom(saved storeFile, raw []byte) Snapshot { + credentials := make(map[string]record, len(saved.Credentials)) + maps.Copy(credentials, saved.Credentials) + digest := sha256.Sum256(raw) + return Snapshot{ETag: `"sha256-` + hex.EncodeToString(digest[:]) + `"`, credentials: credentials} +} + +func (s Snapshot) Resolve( + id, endpoint, environmentName string, + lookup func(string) (string, bool), +) (string, State, error) { + if s.loadErr != nil { + return "", State{}, s.loadErr + } + if err := ValidateID(id); err != nil { + return "", State{}, err + } + if stored, ok := s.credentials[id]; ok { + origin, err := EndpointOrigin(endpoint) + if err != nil || stored.Origin != origin { + return "", State{Configured: false, Source: SourceNone}, ErrOriginMismatch + } + return stored.Value, State{Configured: true, Source: SourceStored}, nil + } + if lookup != nil && environmentName != "" { + if value, ok := lookup(environmentName); ok && value != "" { + return value, State{Configured: true, Source: SourceEnvironment}, nil + } + } + return "", State{Configured: false, Source: SourceNone}, nil +} + +// Stored reports whether the snapshot holds a credential for id, regardless +// of which endpoint origin it is bound to. +func (s Snapshot) Stored(id string) bool { + _, ok := s.credentials[id] + return ok +} + +// StoredPersonEnrichmentIDs returns the named enrichment credential IDs in a +// stable order without exposing their values. +func (s Snapshot) StoredPersonEnrichmentIDs() []string { + ids := make([]string, 0) + for id := range s.credentials { + if id != PersonEnrichmentSuppressionID && strings.HasPrefix(id, personEnrichmentCredentialIDPrefix) { + ids = append(ids, id) + } + } + slices.Sort(ids) + return ids +} + +func (s Snapshot) ResolveSuppression() (string, bool, error) { + if s.loadErr != nil { + return "", false, s.loadErr + } + credential, ok := s.credentials[PersonEnrichmentSuppressionID] + if !ok { + return "", false, nil + } + return credential.Value, true, nil +} + +func Put(tokenDir, ifMatch, id, endpoint, value string) (Snapshot, error) { + if err := ValidateID(id); err != nil { + return Snapshot{}, err + } + if value == "" { + return Snapshot{}, errors.New("provider credential cannot be empty") + } + origin, err := EndpointOrigin(endpoint) + if err != nil { + return Snapshot{}, err + } + return mutate(tokenDir, ifMatch, func(credentials map[string]record) { + revision := int64(1) + if current, ok := credentials[id]; ok { + revision = current.Revision + 1 + } + credentials[id] = record{ID: id, Kind: recordKind(id), Value: value, Origin: origin, Revision: revision} + }) +} + +func PutSuppression(tokenDir, ifMatch, value string) (Snapshot, error) { + if value == "" { + return Snapshot{}, errors.New("suppression key cannot be empty") + } + return mutate(tokenDir, ifMatch, func(credentials map[string]record) { + revision := int64(1) + if current, ok := credentials[PersonEnrichmentSuppressionID]; ok { + revision = current.Revision + 1 + } + credentials[PersonEnrichmentSuppressionID] = record{ + ID: PersonEnrichmentSuppressionID, Kind: recordKind(PersonEnrichmentSuppressionID), + Value: value, Revision: revision, + } + }) +} + +// DeleteSuppressionIfValue removes a just-generated suppression key during a +// failed cross-store settings transaction. It preserves unrelated concurrent +// credential changes and never removes a key another writer replaced. +func DeleteSuppressionIfValue(tokenDir, value string) (Snapshot, error) { + if value == "" { + return Snapshot{}, errors.New("suppression key cannot be empty") + } + permissions := nativePermissions{} + if err := permissions.secureDirectory(tokenDir); err != nil { + return Snapshot{}, fmt.Errorf("secure credential directory: %w", err) + } + var result Snapshot + err := withStoreLock(tokenDir, func() error { + current, err := readWithPermissions(tokenDir, permissions) + if err != nil { + return err + } + stored, ok := current.credentials[PersonEnrichmentSuppressionID] + if !ok { + result = current + return nil + } + if subtle.ConstantTimeCompare([]byte(stored.Value), []byte(value)) != 1 { + return ErrConflict + } + credentials := make(map[string]record, len(current.credentials)) + maps.Copy(credentials, current.credentials) + delete(credentials, PersonEnrichmentSuppressionID) + result, err = persist(tokenDir, permissions, credentials) + return err + }) + return result, err +} + +func Delete(tokenDir, ifMatch, id string) (Snapshot, error) { + if err := ValidateID(id); err != nil { + return Snapshot{}, err + } + return mutate(tokenDir, ifMatch, func(credentials map[string]record) { + delete(credentials, id) + }) +} + +func mutate(tokenDir, ifMatch string, mutation func(map[string]record)) (Snapshot, error) { + permissions := nativePermissions{} + if err := permissions.secureDirectory(tokenDir); err != nil { + return Snapshot{}, fmt.Errorf("secure credential directory: %w", err) + } + var result Snapshot + err := withStoreLock(tokenDir, func() error { + current, err := readWithPermissions(tokenDir, permissions) + if err != nil { + return err + } + if ifMatch == "" || ifMatch != current.ETag { + return ErrConflict + } + credentials := make(map[string]record, len(current.credentials)+1) + maps.Copy(credentials, current.credentials) + mutation(credentials) + result, err = persist(tokenDir, permissions, credentials) + if err != nil { + return err + } + return nil + }) + return result, err +} + +func persist(tokenDir string, permissions permissionBackend, credentials map[string]record) (Snapshot, error) { + saved := storeFile{Version: credentialStoreVersion, Credentials: credentials} + encoded, err := json.Marshal(saved) + if err != nil { + return Snapshot{}, fmt.Errorf("encode credential store: %w", err) + } + encoded = append(encoded, '\n') + published, err := publish(tokenDir, permissions, encoded) + if err != nil { + return Snapshot{}, err + } + return snapshotFrom(saved, published), nil +} + +func publish(tokenDir string, permissions permissionBackend, encoded []byte) ([]byte, error) { + temporary, err := os.CreateTemp(tokenDir, ".provider-credentials-*.json") + if err != nil { + return nil, fmt.Errorf("create credential candidate: %w", err) + } + temporaryPath := temporary.Name() + published := false + defer func() { + _ = temporary.Close() + if !published { + _ = os.Remove(temporaryPath) + } + }() + if err := permissions.secureFile(temporary); err != nil { + return nil, fmt.Errorf("secure credential candidate: %w", err) + } + if _, err := temporary.Write(encoded); err != nil { + return nil, fmt.Errorf("write credential candidate: %w", err) + } + if err := temporary.Sync(); err != nil { + return nil, fmt.Errorf("sync credential candidate: %w", err) + } + if err := temporary.Close(); err != nil { + return nil, fmt.Errorf("close credential candidate: %w", err) + } + if err := replaceStoreFile(temporaryPath, filepath.Join(tokenDir, Filename)); err != nil { + return nil, fmt.Errorf("publish credential store: %w", err) + } + published = true + if err := syncStoreDirectory(tokenDir); err != nil { + return nil, fmt.Errorf("sync credential store directory: %w", err) + } + return encoded, nil +} + +func recordKind(id string) string { + switch id { + case VectorEmbeddingsID: + return "vector_embeddings" + case VectorMultimodalID: + return "vector_multimodal" + case PeopleSweepID: + return "people_sweep" + case PersonEnrichmentSuppressionID: + return "person_enrichment_suppression" + default: + return "person_enrichment" + } +} diff --git a/internal/providercredentials/store_test.go b/internal/providercredentials/store_test.go new file mode 100644 index 000000000..7acae8f76 --- /dev/null +++ b/internal/providercredentials/store_test.go @@ -0,0 +1,236 @@ +package providercredentials + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStoreUsesOwnerOnlyAtomicPublicationAndSeparateETag(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + dir := filepath.Join(t.TempDir(), "tokens") + + empty, err := Read(dir) + requirements.NoError(err) + requirements.NotEmpty(empty.ETag) + + written, err := Put(dir, empty.ETag, VectorEmbeddingsID, + "https://embeddings.example.test/v1", "stored-secret") + requirements.NoError(err) + assertions.NotEqual(empty.ETag, written.ETag) + if runtime.GOOS != "windows" { + dirInfo, statErr := os.Stat(dir) + requirements.NoError(statErr) + assertions.Equal(os.FileMode(0o700), dirInfo.Mode().Perm()) + fileInfo, statErr := os.Stat(filepath.Join(dir, Filename)) + requirements.NoError(statErr) + assertions.Equal(os.FileMode(0o600), fileInfo.Mode().Perm()) + } + + loaded, err := Read(dir) + requirements.NoError(err) + assertions.Equal(written.ETag, loaded.ETag) + value, state, err := loaded.Resolve(VectorEmbeddingsID, + "https://embeddings.example.test/v2", "TEXT_KEY", func(string) (string, bool) { + return "environment-secret", true + }) + requirements.NoError(err) + assertions.Equal("stored-secret", value) + assertions.Equal(State{Configured: true, Source: SourceStored}, state) + + _, err = Delete(dir, empty.ETag, VectorEmbeddingsID) + requirements.ErrorIs(err, ErrConflict) + cleared, err := Delete(dir, loaded.ETag, VectorEmbeddingsID) + requirements.NoError(err) + value, state, err = cleared.Resolve(VectorEmbeddingsID, + "https://embeddings.example.test/v1", "TEXT_KEY", func(string) (string, bool) { + return "environment-secret", true + }) + requirements.NoError(err) + assertions.Equal("environment-secret", value) + assertions.Equal(State{Configured: true, Source: SourceEnvironment}, state) +} + +func TestDeleteSuppressionIfValuePreservesConcurrentCredentialsAndReplacementKeys(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + dir := filepath.Join(t.TempDir(), "tokens") + empty, err := Read(dir) + requirements.NoError(err) + withSuppression, err := PutSuppression(dir, empty.ETag, "generated-suppression-key") + requirements.NoError(err) + _, err = Put(dir, withSuppression.ETag, VectorEmbeddingsID, + "https://embeddings.example.test/v1", "stored-provider-key") + requirements.NoError(err) + + rolledBack, err := DeleteSuppressionIfValue(dir, "generated-suppression-key") + requirements.NoError(err) + _, configured, err := rolledBack.ResolveSuppression() + requirements.NoError(err) + assertions.False(configured) + value, state, err := rolledBack.Resolve(VectorEmbeddingsID, + "https://embeddings.example.test/v1", "", nil) + requirements.NoError(err) + assertions.Equal("stored-provider-key", value) + assertions.Equal(SourceStored, state.Source) + + replacement, err := PutSuppression(dir, rolledBack.ETag, "replacement-suppression-key") + requirements.NoError(err) + _, err = DeleteSuppressionIfValue(dir, "generated-suppression-key") + requirements.ErrorIs(err, ErrConflict) + stored, configured, err := replacement.ResolveSuppression() + requirements.NoError(err) + assertions.True(configured) + assertions.Equal("replacement-suppression-key", stored) +} + +func TestStoreBindsCredentialsToEndpointOrigin(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + dir := filepath.Join(t.TempDir(), "tokens") + empty, err := Read(dir) + requirements.NoError(err) + written, err := Put(dir, empty.ETag, VectorMultimodalID, + "HTTPS://API.VOYAGEAI.COM:443/v1", "voyage-secret") + requirements.NoError(err) + + value, state, err := written.Resolve(VectorMultimodalID, + "https://api.voyageai.com/v2", "VOYAGE_API_KEY", nil) + requirements.NoError(err) + assertions.Equal("voyage-secret", value, "paths on the same origin may share the credential") + assertions.Equal(SourceStored, state.Source) + value, state, err = written.Resolve(VectorMultimodalID, + "https://api.voyageai.com/v3", "VOYAGE_API_KEY", nil) + requirements.NoError(err) + assertions.Equal("voyage-secret", value, "case, default ports, and paths must normalize to one origin") + assertions.Equal(SourceStored, state.Source) + + value, state, err = written.Resolve(VectorMultimodalID, + "https://other.example.test/v1", "VOYAGE_API_KEY", func(string) (string, bool) { + return "environment-must-not-be-used", true + }) + requirements.ErrorIs(err, ErrOriginMismatch) + assertions.Empty(value) + assertions.Equal(State{Configured: false, Source: SourceNone}, state) +} + +func TestEndpointOriginRejectsMalformedURLWithoutPanicking(t *testing.T) { + assertions := assert.New(t) + + origin, err := EndpointOrigin("%") + + assertions.Empty(origin) + assertions.EqualError(err, "provider endpoint must be an http or https URL with a host") +} + +func TestStoreRejectsCorruptOrUnsafePublicationWithoutEnvironmentFallback(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows DACL rejection is covered by the native permission backend tests") + } + tests := []struct { + name string + content string + mode os.FileMode + }{ + {name: "corrupt", content: `{"version":1,"credentials":`, mode: 0o600}, + {name: "unsafe mode", content: `{"version":1,"credentials":{}}`, mode: 0o644}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requirements := require.New(t) + dir := filepath.Join(t.TempDir(), "tokens") + requirements.NoError(os.MkdirAll(dir, 0o700)) + requirements.NoError(os.WriteFile(filepath.Join(dir, Filename), []byte(tt.content), tt.mode)) + snapshot, err := Read(dir) + requirements.Error(err) + _, _, resolveErr := snapshot.Resolve(VectorEmbeddingsID, + "https://embeddings.example.test/v1", "TEXT_KEY", func(string) (string, bool) { + return "must-not-fallback", true + }) + assert.ErrorIs(t, resolveErr, ErrUnavailable) + }) + } +} + +func TestStoreRejectsSymlinkAndWrongOwner(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows reparse-point and owner checks use the native DACL backend") + } + t.Run("symlink", func(t *testing.T) { + requirements := require.New(t) + dir := filepath.Join(t.TempDir(), "tokens") + requirements.NoError(os.MkdirAll(dir, 0o700)) + target := filepath.Join(t.TempDir(), "private.json") + requirements.NoError(os.WriteFile(target, []byte(`{"version":1,"credentials":{}}`), 0o600)) + requirements.NoError(os.Symlink(target, filepath.Join(dir, Filename))) + _, err := Read(dir) + assert.ErrorIs(t, err, ErrUnavailable) + }) + + if os.Geteuid() != 0 { + return + } + t.Run("wrong owner", func(t *testing.T) { + requirements := require.New(t) + dir := filepath.Join(t.TempDir(), "tokens") + requirements.NoError(os.MkdirAll(dir, 0o700)) + path := filepath.Join(dir, Filename) + requirements.NoError(os.WriteFile(path, []byte(`{"version":1,"credentials":{}}`), 0o600)) + requirements.NoError(os.Chown(path, 65534, 65534)) + _, err := Read(dir) + assert.ErrorIs(t, err, ErrUnavailable) + }) +} + +func TestStoreSerializesSameETagWriters(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + dir := filepath.Join(t.TempDir(), "tokens") + empty, err := Read(dir) + requirements.NoError(err) + type outcome struct{ err error } + start := make(chan struct{}) + results := make(chan outcome, 2) + for _, value := range []string{"first", "second"} { + go func(value string) { + <-start + _, writeErr := Put(dir, empty.ETag, VectorEmbeddingsID, + "https://embeddings.example.test/v1", value) + results <- outcome{err: writeErr} + }(value) + } + close(start) + first, second := <-results, <-results + assertions.NotEqual(first.err == nil, second.err == nil, "exactly one same-ETag writer must publish") + if first.err != nil { + requirements.ErrorIs(first.err, ErrConflict) + } + if second.err != nil { + requirements.ErrorIs(second.err, ErrConflict) + } + + raw, err := os.ReadFile(filepath.Join(dir, Filename)) + requirements.NoError(err) + assertions.Contains(string(raw), `"id":"vector.embeddings"`) + assertions.Contains(string(raw), `"kind":"vector_embeddings"`) + assertions.Contains(string(raw), `"revision":1`) +} + +func TestCredentialIDsAreStableAndValidated(t *testing.T) { + for _, id := range []string{ + VectorEmbeddingsID, + VectorMultimodalID, + PeopleSweepID, + PersonEnrichmentID("exa-primary"), + } { + require.NoError(t, ValidateID(id), id) + } + for _, id := range []string{"vector.text", "people.enrichment/", "people.enrichment/../secret", "unknown"} { + assert.Error(t, ValidateID(id), id) + } +} diff --git a/internal/store/attribute_definitions_test.go b/internal/store/attribute_definitions_test.go index 4b5414755..98a9693ba 100644 --- a/internal/store/attribute_definitions_test.go +++ b/internal/store/attribute_definitions_test.go @@ -319,6 +319,9 @@ func TestCreateAttributeDefinitionCanonicalizesChoicesForValueType(t *testing.T) {name: "text", valueType: store.AttributeValueText, raw: " synthetic ", want: "synthetic"}, {name: "integer", valueType: store.AttributeValueInteger, raw: "+007", want: "7"}, {name: "real", valueType: store.AttributeValueReal, raw: "1.500", want: "1.5"}, + {name: "real decimal exponent", valueType: store.AttributeValueReal, raw: "1e20", want: "1e+20"}, + {name: "real small exponent", valueType: store.AttributeValueReal, raw: "1e-5", want: "1e-05"}, + {name: "real hexadecimal exponent", valueType: store.AttributeValueReal, raw: "0x1p2", want: "4"}, {name: "boolean", valueType: store.AttributeValueBoolean, raw: "TRUE", want: "true"}, {name: "date", valueType: store.AttributeValueDate, raw: " 2026-07-30 ", want: "2026-07-30"}, { @@ -327,13 +330,32 @@ func TestCreateAttributeDefinitionCanonicalizesChoicesForValueType(t *testing.T) raw: "2026-07-30T10:00:00+02:00", want: "2026-07-30T08:00:00Z", }, + { + name: "timestamp before year zero", + valueType: store.AttributeValueTimestamp, + raw: "0000-01-01T00:00:00+01:00", + want: "-0001-12-31T23:00:00Z", + }, + { + name: "timestamp after year 9999", + valueType: store.AttributeValueTimestamp, + raw: "9999-12-31T23:59:59-01:00", + want: "10000-01-01T00:59:59Z", + }, + { + name: "timestamp sub nanosecond fraction", + valueType: store.AttributeValueTimestamp, + raw: "2026-01-01T00:00:00.1234567890Z", + want: "2026-01-01T00:00:00.123456789Z", + }, } for i, test := range tests { t.Run(test.name, func(t *testing.T) { assert := assert.New(t) require := require.New(t) - input := personTextDefinition("canonical_" + test.name) + testSlug := strings.ReplaceAll(test.name, " ", "_") + input := personTextDefinition("canonical_" + testSlug) input.UniversalID = "test-canonical-" + test.name input.ValueType = test.valueType input.FieldType = store.AttributeFieldSelect diff --git a/internal/store/carddav_conflicts.go b/internal/store/carddav_conflicts.go index 7114394ba..5d138db0f 100644 --- a/internal/store/carddav_conflicts.go +++ b/internal/store/carddav_conflicts.go @@ -59,6 +59,38 @@ type CardDAVConflict struct { UpdatedAt time.Time } +// CardDAVConflictHeader is the compact, body-free read model used by public +// conflict lists. Sensitive mutation evidence deliberately remains absent. +type CardDAVConflictHeader struct { + ID int64 + AddressBookID int64 + AddressBookName string + Status CardDAVConflictStatus + LocalTombstone bool + RemoteTombstone bool + UpdatedAt time.Time +} + +// CardDAVConflictDetailSource contains the internal evidence needed to build +// one bounded public projection. It must not cross the CardDAV service boundary. +type CardDAVConflictDetailSource struct { + ID int64 + AddressBookID int64 + AddressBookName string + MappingRevision int64 + LocalBody []byte + RemoteBody []byte + BaseBody []byte + LocalTombstone bool + RemoteTombstone bool + BaseAvailable bool + Status CardDAVConflictStatus + Resolution CardDAVConflictResolution + CreatedAt time.Time + UpdatedAt time.Time + ResolvedAt *time.Time +} + type CardDAVConflictCapture struct { AddressBookID int64 Href string @@ -1051,6 +1083,72 @@ func (s *Store) ListCardDAVConflictsContext( return conflicts, rows.Err() } +func (s *Store) ListCardDAVConflictHeadersContext(ctx context.Context) ([]CardDAVConflictHeader, error) { + rows, err := s.db.QueryContext(ctx, `SELECT c.id, c.address_book_id, b.display_name, + c.status, c.local_tombstone, c.remote_tombstone, c.updated_at + FROM carddav_conflicts c + JOIN carddav_address_books b ON b.id = c.address_book_id + WHERE c.status = 'unresolved' + ORDER BY c.updated_at DESC, c.id`) + if err != nil { + return nil, fmt.Errorf("list CardDAV conflict headers: %w", err) + } + defer func() { _ = rows.Close() }() + result := []CardDAVConflictHeader{} + for rows.Next() { + var header CardDAVConflictHeader + if err := rows.Scan(&header.ID, &header.AddressBookID, &header.AddressBookName, + &header.Status, &header.LocalTombstone, &header.RemoteTombstone, &header.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan CardDAV conflict header: %w", err) + } + result = append(result, header) + } + return result, rows.Err() +} + +func (s *Store) GetCardDAVConflictDetailSourceContext( + ctx context.Context, id int64, +) (*CardDAVConflictDetailSource, error) { + var source CardDAVConflictDetailSource + var resolution sql.NullString + var resolvedAt sql.NullTime + var baseBody []byte + err := s.db.QueryRowContext(ctx, `SELECT c.id, c.address_book_id, b.display_name, + c.mapping_revision, c.local_body, c.remote_body, + c.local_tombstone, c.remote_tombstone, c.status, c.resolution, + c.created_at, c.updated_at, c.resolved_at, r.remote_body + FROM carddav_conflicts c + JOIN carddav_address_books b ON b.id = c.address_book_id + LEFT JOIN carddav_resources r ON r.address_book_id = c.address_book_id + AND r.href = c.href + AND r.mapping_revision = c.mapping_revision + AND r.remote_semantic_hash = c.base_remote_hash + AND r.remote_etag = c.base_remote_etag + WHERE c.id = ?`, id).Scan( + &source.ID, &source.AddressBookID, &source.AddressBookName, + &source.MappingRevision, &source.LocalBody, &source.RemoteBody, + &source.LocalTombstone, &source.RemoteTombstone, &source.Status, &resolution, + &source.CreatedAt, &source.UpdatedAt, &resolvedAt, &baseBody) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrCardDAVConflictNotFound + } + if err != nil { + return nil, fmt.Errorf("get CardDAV conflict detail source: %w", err) + } + source.Resolution = CardDAVConflictResolution(resolution.String) + if resolvedAt.Valid { + value := resolvedAt.Time.UTC() + source.ResolvedAt = &value + } + if baseBody != nil { + source.BaseAvailable = true + source.BaseBody = append([]byte(nil), baseBody...) + } + source.LocalBody = append([]byte(nil), source.LocalBody...) + source.RemoteBody = append([]byte(nil), source.RemoteBody...) + return &source, nil +} + func (s *Store) ResolveCardDAVConflictRemoteContext( ctx context.Context, input CardDAVConflictRemoteResolution, ) (*CardDAVConflict, error) { diff --git a/internal/store/carddav_conflicts_test.go b/internal/store/carddav_conflicts_test.go index 4c26142e8..66655c15c 100644 --- a/internal/store/carddav_conflicts_test.go +++ b/internal/store/carddav_conflicts_test.go @@ -42,6 +42,75 @@ func conflictCapture(mapping *store.CardDAVResource) store.CardDAVConflictCaptur } } +func TestCardDAVConflictSafeReadModelsUseCompactHeadersAndExactBaseFence(t *testing.T) { + tests := []struct { + name string + mutate func(*testing.T, *store.Store, *store.CardDAVResource, *store.CardDAVConflict) + baseAvailable bool + }{ + {name: "exact tuple", baseAvailable: true}, + {name: "wrong revision", mutate: func(t *testing.T, st *store.Store, mapping *store.CardDAVResource, _ *store.CardDAVConflict) { + t.Helper() + _, err := st.DB().Exec(st.Rebind(`UPDATE carddav_resources SET mapping_revision = mapping_revision + 1 WHERE id = ?`), mapping.ID) + require.NoError(t, err) + }}, + {name: "wrong semantic hash", mutate: func(t *testing.T, st *store.Store, mapping *store.CardDAVResource, _ *store.CardDAVConflict) { + t.Helper() + _, err := st.DB().Exec(st.Rebind(`UPDATE carddav_resources SET remote_semantic_hash = ? WHERE id = ?`), "different", mapping.ID) + require.NoError(t, err) + }}, + {name: "wrong etag", mutate: func(t *testing.T, st *store.Store, mapping *store.CardDAVResource, _ *store.CardDAVConflict) { + t.Helper() + _, err := st.DB().Exec(st.Rebind(`UPDATE carddav_resources SET remote_etag = ? WHERE id = ?`), `"different"`, mapping.ID) + require.NoError(t, err) + }}, + {name: "missing mapping", mutate: func(t *testing.T, st *store.Store, mapping *store.CardDAVResource, _ *store.CardDAVConflict) { + t.Helper() + _, err := st.DB().Exec(st.Rebind(`DELETE FROM carddav_resources WHERE id = ?`), mapping.ID) + require.NoError(t, err) + }}, + {name: "resolved and remapped", mutate: func(t *testing.T, st *store.Store, mapping *store.CardDAVResource, conflict *store.CardDAVConflict) { + t.Helper() + _, err := st.DB().Exec(st.Rebind(`UPDATE carddav_conflicts SET status = 'resolved', resolution = 'keep_local', resolved_at = CURRENT_TIMESTAMP WHERE id = ?`), conflict.ID) + require.NoError(t, err) + _, err = st.DB().Exec(st.Rebind(`UPDATE carddav_resources SET mapping_revision = mapping_revision + 1 WHERE id = ?`), mapping.ID) + require.NoError(t, err) + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st, _, book, mapping := seededCardDAVConflictMapping(t) + capture := conflictCapture(mapping) + capture.LocalBody = append(capture.LocalBody, bytes.Repeat([]byte("private-local-body"), 1024)...) + capture.RemoteBody = append(capture.RemoteBody, bytes.Repeat([]byte("private-remote-body"), 1024)...) + conflict, err := st.RecordCardDAVConflictContext(t.Context(), capture) + require.NoError(err) + + headers, err := st.ListCardDAVConflictHeadersContext(t.Context()) + require.NoError(err) + require.Len(headers, 1) + assert.Equal(conflict.ID, headers[0].ID) + assert.Equal(book.DisplayName, headers[0].AddressBookName) + + if tt.mutate != nil { + tt.mutate(t, st, mapping, conflict) + } + detail, err := st.GetCardDAVConflictDetailSourceContext(t.Context(), conflict.ID) + require.NoError(err) + assert.Equal(capture.LocalBody, detail.LocalBody) + assert.Equal(capture.RemoteBody, detail.RemoteBody) + assert.Equal(tt.baseAvailable, detail.BaseAvailable) + if tt.baseAvailable { + assert.Equal(mapping.RemoteBody, detail.BaseBody) + } else { + assert.Empty(detail.BaseBody) + } + }) + } +} + func seededCardDAVTombstoneConflict( t *testing.T, mappedPerson bool, ) (*store.Store, store.CardDAVAccount, store.CardDAVAddressBook, *store.CardDAVResource, *store.CardDAVConflict, store.CardDAVRemoteResource) { diff --git a/internal/store/carddav_publication.go b/internal/store/carddav_publication.go index 0ebeeb1a3..b34bd4e75 100644 --- a/internal/store/carddav_publication.go +++ b/internal/store/carddav_publication.go @@ -58,6 +58,21 @@ type CardDAVPublication struct { ResolutionConflictID int64 } +// CardDAVPublicationStateSource contains only the safe fields needed to derive +// the public publication state. Mutation evidence remains in the publication +// table and never gets copied into this read model. +type CardDAVPublicationStateSource struct { + PersonID int64 + HasPublication bool + Desired bool + PendingOperation CardDAVMutationOperation + AddressBookID int64 + AddressBookName string + ConflictID int64 + ProspectiveBookID int64 + ProspectiveName string +} + type CardDAVCanonicalMutation struct { Publication CardDAVPublication Remote CardDAVRemoteResource @@ -259,6 +274,64 @@ func (s *Store) GetCardDAVPublicationContext(ctx context.Context, personID int64 return getCardDAVPublicationFrom(ctx, s.db, personID, "") } +func (s *Store) GetCardDAVPublicationStateSourceContext( + ctx context.Context, personID int64, +) (*CardDAVPublicationStateSource, error) { + if personID <= 0 { + return nil, ErrPersonNotFound + } + var source *CardDAVPublicationStateSource + err := s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + if _, err := s.getPersonTx(ctx, tx, personID); err != nil { + return err + } + if s.cardDAVPublicationStateReadHook != nil { + s.cardDAVPublicationStateReadHook() + } + current := &CardDAVPublicationStateSource{PersonID: personID} + var publicationHref string + var pendingOperation sql.NullString + err := tx.QueryRowContext(ctx, `SELECT desired, address_book_id, href, pending_operation + FROM carddav_publications WHERE person_id = ?`, personID).Scan( + ¤t.Desired, ¤t.AddressBookID, &publicationHref, &pendingOperation) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("get CardDAV publication state: %w", err) + } + if err == nil { + current.HasPublication = true + current.PendingOperation = CardDAVMutationOperation(pendingOperation.String) + err = tx.QueryRowContext(ctx, `SELECT id, display_name + FROM carddav_address_books WHERE id = ?`, current.AddressBookID).Scan( + ¤t.AddressBookID, ¤t.AddressBookName) + if errors.Is(err, sql.ErrNoRows) { + return ErrCardDAVAddressBookNotFound + } + if err != nil { + return fmt.Errorf("get CardDAV publication address book: %w", err) + } + err = tx.QueryRowContext(ctx, `SELECT id FROM carddav_conflicts + WHERE address_book_id = ? AND href = ? AND status = 'unresolved'`, + current.AddressBookID, publicationHref).Scan(¤t.ConflictID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("get CardDAV publication conflict: %w", err) + } + source = current + return nil + } + err = tx.QueryRowContext(ctx, `SELECT id, display_name + FROM carddav_address_books + WHERE is_write_target = TRUE AND is_subscribed = TRUE + ORDER BY discovery_index, id LIMIT 1`).Scan( + ¤t.ProspectiveBookID, ¤t.ProspectiveName) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("get prospective CardDAV publication book: %w", err) + } + source = current + return nil + }) + return source, err +} + func (s *Store) RefreshCardDAVPublicationFenceContext( ctx context.Context, personID int64, ) (*CardDAVPublication, error) { diff --git a/internal/store/carddav_publication_internal_test.go b/internal/store/carddav_publication_internal_test.go new file mode 100644 index 000000000..7aceee40f --- /dev/null +++ b/internal/store/carddav_publication_internal_test.go @@ -0,0 +1,51 @@ +package store + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCardDAVPublicationStateSourceUsesOneReadSnapshot(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + dbPath := filepath.Join(t.TempDir(), "publication-state.db") + reader, err := OpenForTest(dbPath) + require.NoError(err) + t.Cleanup(func() { _ = reader.Close() }) + require.NoError(reader.InitSchema()) + participantID, err := reader.EnsureParticipant("alice@example.test", "Alice", "example.test") + require.NoError(err) + person, _, err := reader.CreatePersonFromParticipant(participantID) + require.NoError(err) + allowed := true + _, books, err := reader.ReplaceCardDAVDiscoveryContext(t.Context(), CardDAVDiscoveryInput{ + BaseURL: "https://carddav.example.test", Username: "alice", + PrincipalURL: "https://carddav.example.test/principal/", + HomeURL: "https://carddav.example.test/books/", + Books: []CardDAVDiscoveredBook{{ + CanonicalURL: "https://carddav.example.test/books/personal/", DisplayName: "Personal", + CanCreate: &allowed, + }}, + }) + require.NoError(err) + require.Len(books, 1) + + writer, err := OpenForTest(dbPath) + require.NoError(err) + t.Cleanup(func() { _ = writer.Close() }) + reader.cardDAVPublicationStateReadHook = func() { + reader.cardDAVPublicationStateReadHook = nil + require.NoError(writer.DeletePersonContext(t.Context(), person.ID, person.Revision)) + } + + source, err := reader.GetCardDAVPublicationStateSourceContext(t.Context(), person.ID) + require.NoError(err) + assert.Equal(person.ID, source.PersonID) + assert.Equal(books[0].ID, source.ProspectiveBookID, + "the remaining reads must come from the same pre-delete snapshot") + _, err = reader.GetPersonContext(t.Context(), person.ID) + assert.ErrorIs(err, ErrPersonNotFound) +} diff --git a/internal/store/carddav_publication_test.go b/internal/store/carddav_publication_test.go index 8d2cbe1d4..74f8045ef 100644 --- a/internal/store/carddav_publication_test.go +++ b/internal/store/carddav_publication_test.go @@ -1,6 +1,7 @@ package store_test import ( + "encoding/json" "fmt" "testing" "time" @@ -10,6 +11,32 @@ import ( "go.kenn.io/msgvault/internal/store" ) +func TestCardDAVPublicationStateSourceDoesNotCopyMutationEvidence(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st, _, book := newCardDAVResourceStore(t) + var personID int64 + require.NoError(st.DB().QueryRow(`INSERT INTO persons (vcard_uid, display_name) + VALUES ('safe-source-person', 'Safe Source Person') RETURNING id`).Scan(&personID)) + snapshot, err := st.LoadPersonVCardSnapshotContext(t.Context(), personID) + require.NoError(err) + privateMarker := "private-mutation-evidence" + _, err = st.PrepareCardDAVPublicationContext(t.Context(), store.CardDAVPublicationPlan{ + PersonID: personID, Desired: true, AddressBookID: book.ID, + Href: book.CanonicalURL + "safe-source-person.vcf", + OutgoingBody: []byte("BEGIN:VCARD\r\nVERSION:4.0\r\nFN:" + privateMarker + "\r\nEND:VCARD\r\n"), + OutgoingSemanticHash: privateMarker, LocalHash: snapshot.Fingerprint, + }) + require.NoError(err) + + source, err := st.GetCardDAVPublicationStateSourceContext(t.Context(), personID) + require.NoError(err) + encoded, err := json.Marshal(source) //nolint:musttag // Marshal the internal projection to prove private fields are absent. + require.NoError(err) + assert.NotContains(string(encoded), privateMarker) + assert.NotContains(string(encoded), "OutgoingBody") +} + func TestCardDAVPublicationPreparePersistsExactCreateIntent(t *testing.T) { assert := assert.New(t) require := require.New(t) diff --git a/internal/store/carddav_sync_runs.go b/internal/store/carddav_sync_runs.go new file mode 100644 index 000000000..8245f0b1d --- /dev/null +++ b/internal/store/carddav_sync_runs.go @@ -0,0 +1,343 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +const ( + cardDAVSyncRunDefaultLimit = 25 + cardDAVSyncRunMaxLimit = 100 + cardDAVSyncRunRetention = 100 + cardDAVSyncRunErrorMessageMax = 2000 + cardDAVSyncRunRedactedErrorCode = "unsafe_error_redacted" + cardDAVSyncRunRedactedError = "CardDAV sync failed; sensitive details were removed." + cardDAVSyncRunRestartedErrorCode = "daemon_restarted" + cardDAVSyncRunRestartedError = "CardDAV sync stopped because the daemon restarted." +) + +type CardDAVSyncTrigger string + +const ( + CardDAVSyncTriggerManual CardDAVSyncTrigger = "manual" + CardDAVSyncTriggerScheduled CardDAVSyncTrigger = "scheduled" +) + +type CardDAVSyncRunState string + +const ( + CardDAVSyncRunRunning CardDAVSyncRunState = "running" + CardDAVSyncRunSucceeded CardDAVSyncRunState = "succeeded" + CardDAVSyncRunFailed CardDAVSyncRunState = "failed" + CardDAVSyncRunCancelled CardDAVSyncRunState = "cancelled" + CardDAVSyncRunPartial CardDAVSyncRunState = "partial" +) + +type CardDAVSyncRunStart struct { + Trigger CardDAVSyncTrigger + Full bool +} + +type CardDAVSyncRunFinish struct { + State CardDAVSyncRunState + Books int64 + Created int64 + Updated int64 + Removed int64 + ErrorCode string + ErrorMessage string +} + +type CardDAVSyncRun struct { + ID int64 + Trigger CardDAVSyncTrigger + Full bool + State CardDAVSyncRunState + StartedAt time.Time + FinishedAt *time.Time + Books int64 + Created int64 + Updated int64 + Removed int64 + ErrorCode string + ErrorMessage string +} + +type CardDAVSyncStatus struct { + Active *CardDAVSyncRun + Latest *CardDAVSyncRun + LatestSuccessful *CardDAVSyncRun +} + +var ( + ErrCardDAVSyncActive = errors.New("CardDAV sync already active") + ErrCardDAVSyncRunInvalid = errors.New("invalid CardDAV sync run") + ErrCardDAVSyncRunNotFound = errors.New("CardDAV sync run not found") + ErrCardDAVSyncRunTransition = errors.New("invalid CardDAV sync run transition") +) + +var ( + cardDAVSyncRunErrorCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + cardDAVSyncRunCredentialMarkerPattern = regexp.MustCompile( + `(?i)(api[[:space:]_-]*key|access[[:space:]_-]*token|refresh[[:space:]_-]*token|credential)`, + ) +) + +const cardDAVSyncRunColumns = `id, trigger, full_sync, state, started_at, finished_at, + books, created, updated, removed, error_code, error_message` + +func (s *Store) StartCardDAVSyncRunContext( + ctx context.Context, input CardDAVSyncRunStart, +) (*CardDAVSyncRun, error) { + if input.Trigger != CardDAVSyncTriggerManual && input.Trigger != CardDAVSyncTriggerScheduled { + return nil, fmt.Errorf("%w: trigger must be manual or scheduled", ErrCardDAVSyncRunInvalid) + } + var run *CardDAVSyncRun + err := s.withTxContext(ctx, func(tx *loggedTx) error { + row := tx.QueryRowContext(ctx, `INSERT INTO carddav_sync_runs (trigger, full_sync, state) + VALUES (?, ?, 'running') RETURNING `+cardDAVSyncRunColumns, input.Trigger, input.Full) + var err error + run, err = scanCardDAVSyncRun(row) + if err != nil { + if s.dialect.IsConflictError(err) { + return ErrCardDAVSyncActive + } + return fmt.Errorf("insert CardDAV sync run: %w", err) + } + return nil + }) + if err != nil { + return nil, err + } + return run, nil +} + +func (s *Store) FinishCardDAVSyncRunContext( + ctx context.Context, runID int64, input CardDAVSyncRunFinish, +) (*CardDAVSyncRun, error) { + if runID <= 0 { + return nil, fmt.Errorf("%w: run ID must be positive", ErrCardDAVSyncRunInvalid) + } + code, message, err := validateCardDAVSyncRunFinish(input) + if err != nil { + return nil, err + } + var run *CardDAVSyncRun + err = s.withTxContext(ctx, func(tx *loggedTx) error { + row := tx.QueryRowContext(ctx, fmt.Sprintf(`UPDATE carddav_sync_runs SET + state = ?, finished_at = %s, books = ?, created = ?, updated = ?, removed = ?, + error_code = ?, error_message = ? + WHERE id = ? AND state = 'running' RETURNING %s`, s.dialect.Now(), cardDAVSyncRunColumns), + input.State, input.Books, input.Created, input.Updated, input.Removed, code, message, runID) + run, err = scanCardDAVSyncRun(row) + if errors.Is(err, sql.ErrNoRows) { + var state CardDAVSyncRunState + lookupErr := tx.QueryRowContext(ctx, `SELECT state FROM carddav_sync_runs WHERE id = ?`, runID).Scan(&state) + if errors.Is(lookupErr, sql.ErrNoRows) { + return ErrCardDAVSyncRunNotFound + } + if lookupErr != nil { + return fmt.Errorf("load CardDAV sync run transition: %w", lookupErr) + } + return ErrCardDAVSyncRunTransition + } + if err != nil { + return fmt.Errorf("finish CardDAV sync run: %w", err) + } + return nil + }) + if err != nil { + return nil, err + } + s.pruneCardDAVSyncRunsBestEffort(ctx) + return run, nil +} + +func (s *Store) CardDAVSyncStatusContext(ctx context.Context) (CardDAVSyncStatus, error) { + var status CardDAVSyncStatus + err := s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + var err error + status.Active, err = getCardDAVSyncRun(ctx, tx, `state = 'running'`, nil) + if err != nil { + return err + } + status.Latest, err = getCardDAVSyncRun(ctx, tx, `1 = 1`, nil) + if err != nil { + return err + } + status.LatestSuccessful, err = getCardDAVSyncRun(ctx, tx, `state = 'succeeded'`, nil) + return err + }) + if err != nil { + return CardDAVSyncStatus{}, fmt.Errorf("read CardDAV sync status: %w", err) + } + return status, nil +} + +func (s *Store) ListCardDAVSyncRunsContext( + ctx context.Context, limit int, beforeID *int64, +) ([]CardDAVSyncRun, error) { + if limit == 0 { + limit = cardDAVSyncRunDefaultLimit + } + if limit < 1 || limit > cardDAVSyncRunMaxLimit || (beforeID != nil && *beforeID <= 0) { + return nil, fmt.Errorf("%w: limit must be 1-100 and before ID positive", ErrCardDAVSyncRunInvalid) + } + query := `SELECT ` + cardDAVSyncRunColumns + ` FROM carddav_sync_runs` + args := make([]any, 0, 2) + if beforeID != nil { + query += ` WHERE id < ?` + args = append(args, *beforeID) + } + query += ` ORDER BY id DESC LIMIT ?` + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, s.Rebind(query), args...) + if err != nil { + return nil, fmt.Errorf("list CardDAV sync runs: %w", err) + } + defer func() { _ = rows.Close() }() + runs := make([]CardDAVSyncRun, 0, limit) + for rows.Next() { + run, scanErr := scanCardDAVSyncRun(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan CardDAV sync run: %w", scanErr) + } + runs = append(runs, *run) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate CardDAV sync runs: %w", err) + } + return runs, nil +} + +// RecoverCardDAVSyncRunsContext terminalizes runs whose owning process ended +// before recording a terminal outcome. Call once during daemon startup after +// schema initialization and before accepting new CardDAV work. +func (s *Store) RecoverCardDAVSyncRunsContext(ctx context.Context) (int64, error) { + var recovered int64 + err := s.withTxContext(ctx, func(tx *loggedTx) error { + result, err := tx.ExecContext(ctx, fmt.Sprintf(`UPDATE carddav_sync_runs SET + state = 'failed', finished_at = %s, error_code = ?, error_message = ? + WHERE state = 'running'`, s.dialect.Now()), + cardDAVSyncRunRestartedErrorCode, cardDAVSyncRunRestartedError) + if err != nil { + return fmt.Errorf("recover CardDAV sync runs: %w", err) + } + recovered, err = result.RowsAffected() + if err != nil { + return fmt.Errorf("count recovered CardDAV sync runs: %w", err) + } + return nil + }) + if err != nil { + return recovered, err + } + s.pruneCardDAVSyncRunsBestEffort(ctx) + return recovered, nil +} + +func validateCardDAVSyncRunFinish(input CardDAVSyncRunFinish) (string, string, error) { + if input.State != CardDAVSyncRunSucceeded && input.State != CardDAVSyncRunFailed && + input.State != CardDAVSyncRunCancelled && input.State != CardDAVSyncRunPartial { + return "", "", fmt.Errorf("%w: state must be terminal", ErrCardDAVSyncRunInvalid) + } + if input.Books < 0 || input.Created < 0 || input.Updated < 0 || input.Removed < 0 { + return "", "", fmt.Errorf("%w: counters must be nonnegative", ErrCardDAVSyncRunInvalid) + } + code := strings.TrimSpace(input.ErrorCode) + message := strings.TrimSpace(strings.ToValidUTF8(input.ErrorMessage, "�")) + if input.State == CardDAVSyncRunSucceeded { + if code != "" || message != "" { + return "", "", fmt.Errorf("%w: successful runs cannot have errors", ErrCardDAVSyncRunInvalid) + } + return "", "", nil + } + if !cardDAVSyncRunErrorCodePattern.MatchString(code) { + return "", "", fmt.Errorf("%w: terminal failure code is invalid", ErrCardDAVSyncRunInvalid) + } + if cardDAVSyncRunMessageUnsafe(code) || cardDAVSyncRunMessageUnsafe(message) { + return cardDAVSyncRunRedactedErrorCode, cardDAVSyncRunRedactedError, nil + } + return code, truncateCardDAVSyncRunMessage(message), nil +} + +func cardDAVSyncRunMessageUnsafe(message string) bool { + if cardDAVSyncRunCredentialMarkerPattern.MatchString(message) { + return true + } + lower := strings.ToLower(message) + for _, marker := range []string{ + "authorization", "bearer ", "basic ", "password", "passwd", "secret", "cookie", + "begin:vcard", "http://", "https://", "href", "cursor", "request body", + } { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +func truncateCardDAVSyncRunMessage(message string) string { + if len(message) <= cardDAVSyncRunErrorMessageMax { + return message + } + end := cardDAVSyncRunErrorMessageMax + for end > 0 && !utf8.ValidString(message[:end]) { + end-- + } + return message[:end] +} + +func (s *Store) pruneCardDAVSyncRunsBestEffort(ctx context.Context) { + if err := pruneCardDAVSyncRuns(ctx, s.db); err != nil { + slog.Warn("CardDAV sync run history prune failed", "error", err) + } +} + +func pruneCardDAVSyncRuns(ctx context.Context, db contextQuerier) error { + _, err := db.ExecContext(ctx, `DELETE FROM carddav_sync_runs + WHERE state <> 'running' AND id NOT IN ( + SELECT id FROM carddav_sync_runs WHERE state <> 'running' ORDER BY id DESC LIMIT ? + )`, cardDAVSyncRunRetention) + if err != nil { + return fmt.Errorf("prune CardDAV sync runs: %w", err) + } + return nil +} + +func getCardDAVSyncRun( + ctx context.Context, tx *loggedTx, condition string, args []any, +) (*CardDAVSyncRun, error) { + query := `SELECT ` + cardDAVSyncRunColumns + ` FROM carddav_sync_runs WHERE ` + condition + ` ORDER BY id DESC LIMIT 1` + run, err := scanCardDAVSyncRun(tx.QueryRowContext(ctx, query, args...)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil //nolint:nilnil // No matching CardDAV run is a valid empty status. + } + if err != nil { + return nil, err + } + return run, nil +} + +func scanCardDAVSyncRun(sc scanner) (*CardDAVSyncRun, error) { + var run CardDAVSyncRun + var started requiredTimestamp + var finished nullableTimestamp + if err := sc.Scan(&run.ID, &run.Trigger, &run.Full, &run.State, &started, &finished, + &run.Books, &run.Created, &run.Updated, &run.Removed, &run.ErrorCode, &run.ErrorMessage); err != nil { + return nil, err + } + run.StartedAt = started.Time.UTC() + if finished.Valid { + value := finished.Time.UTC() + run.FinishedAt = &value + } + return &run, nil +} diff --git a/internal/store/carddav_sync_runs_test.go b/internal/store/carddav_sync_runs_test.go new file mode 100644 index 000000000..fbf60db23 --- /dev/null +++ b/internal/store/carddav_sync_runs_test.go @@ -0,0 +1,454 @@ +package store_test + +import ( + "errors" + "path/filepath" + "strings" + "sync" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +func TestCardDAVSyncRunLifecycle(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + ctx := t.Context() + + started, err := st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{ + Trigger: store.CardDAVSyncTriggerManual, + Full: true, + }) + require.NoError(err) + assert.Equal(store.CardDAVSyncRunRunning, started.State) + assert.Equal(store.CardDAVSyncTriggerManual, started.Trigger) + assert.True(started.Full) + assert.Nil(started.FinishedAt) + + _, err = st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{ + Trigger: store.CardDAVSyncTriggerScheduled, + }) + require.ErrorIs(err, store.ErrCardDAVSyncActive) + + status, err := st.CardDAVSyncStatusContext(ctx) + require.NoError(err) + require.NotNil(status.Active) + assert.Equal(started.ID, status.Active.ID) + + finished, err := st.FinishCardDAVSyncRunContext(ctx, started.ID, store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunSucceeded, + Books: 3, Created: 4, Updated: 5, Removed: 6, + }) + require.NoError(err) + assert.Equal(int64(3), finished.Books) + assert.Equal(int64(4), finished.Created) + assert.Equal(int64(5), finished.Updated) + assert.Equal(int64(6), finished.Removed) + assert.NotNil(finished.FinishedAt) + assert.True(finished.FinishedAt.After(finished.StartedAt) || finished.FinishedAt.Equal(finished.StartedAt)) + + status, err = st.CardDAVSyncStatusContext(ctx) + require.NoError(err) + assert.Nil(status.Active) + require.NotNil(status.Latest) + assert.Equal(finished.ID, status.Latest.ID) + require.NotNil(status.LatestSuccessful) + assert.Equal(finished.ID, status.LatestSuccessful.ID) + + _, err = st.FinishCardDAVSyncRunContext(ctx, started.ID, store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunSucceeded, + }) + require.ErrorIs(err, store.ErrCardDAVSyncRunTransition) +} + +func TestCardDAVSyncRunConcurrentActiveClaim(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + ctx := t.Context() + start := make(chan struct{}) + results := make(chan error, 2) + var workers sync.WaitGroup + for range 2 { + workers.Go(func() { + <-start + _, err := st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + results <- err + }) + } + close(start) + workers.Wait() + close(results) + + var successes, activeConflicts int + for err := range results { + switch { + case err == nil: + successes++ + case errors.Is(err, store.ErrCardDAVSyncActive): + activeConflicts++ + default: + require.NoError(err) + } + } + assert.Equal(1, successes) + assert.Equal(1, activeConflicts) + var activeRows int + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM carddav_sync_runs WHERE state = 'running'`).Scan(&activeRows)) + assert.Equal(1, activeRows) +} + +func TestCardDAVSyncRunTerminalStatesRetainCounters(t *testing.T) { + st := testutil.NewTestStore(t) + ctx := t.Context() + + for _, tc := range []struct { + name string + state store.CardDAVSyncRunState + }{ + {name: "failed", state: store.CardDAVSyncRunFailed}, + {name: "cancelled", state: store.CardDAVSyncRunCancelled}, + {name: "partial", state: store.CardDAVSyncRunPartial}, + } { + t.Run(tc.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + run, err := st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerScheduled}) + require.NoError(err) + finished, err := st.FinishCardDAVSyncRunContext(ctx, run.ID, store.CardDAVSyncRunFinish{ + State: tc.state, Books: 7, Created: 8, Updated: 9, Removed: 10, + ErrorCode: "remote_failure", ErrorMessage: "Safe public failure", + }) + require.NoError(err) + assert.Equal(tc.state, finished.State) + assert.Equal(int64(7), finished.Books) + assert.Equal(int64(8), finished.Created) + assert.Equal(int64(9), finished.Updated) + assert.Equal(int64(10), finished.Removed) + assert.Equal("remote_failure", finished.ErrorCode) + }) + } +} + +func TestCardDAVSyncRunRejectsInvalidInputAndTransitions(t *testing.T) { + require := require.New(t) + st := testutil.NewTestStore(t) + ctx := t.Context() + + _, err := st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: "startup"}) + require.ErrorIs(err, store.ErrCardDAVSyncRunInvalid) + run, err := st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + require.NoError(err) + + invalid := []store.CardDAVSyncRunFinish{ + {State: store.CardDAVSyncRunRunning}, + {State: store.CardDAVSyncRunFailed, Books: -1, ErrorCode: "remote_failure"}, + {State: store.CardDAVSyncRunFailed, ErrorCode: "Bad-Code"}, + {State: store.CardDAVSyncRunFailed}, + {State: store.CardDAVSyncRunSucceeded, ErrorCode: "remote_failure"}, + } + for _, finish := range invalid { + _, finishErr := st.FinishCardDAVSyncRunContext(ctx, run.ID, finish) + require.ErrorIs(finishErr, store.ErrCardDAVSyncRunInvalid) + } + _, err = st.FinishCardDAVSyncRunContext(ctx, run.ID+99, store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunFailed, ErrorCode: "remote_failure", + }) + require.ErrorIs(err, store.ErrCardDAVSyncRunNotFound) + + for _, input := range []struct { + limit int + before *int64 + }{ + {limit: -1}, {limit: 101}, {limit: 1, before: new(int64(0))}, {limit: 1, before: new(int64(-1))}, + } { + _, listErr := st.ListCardDAVSyncRunsContext(ctx, input.limit, input.before) + require.ErrorIs(listErr, store.ErrCardDAVSyncRunInvalid) + } +} + +func TestCardDAVSyncRunPaginationAndRetention(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + ctx := t.Context() + ids := make([]int64, 0, 103) + for range 103 { + run, err := st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + require.NoError(err) + ids = append(ids, run.ID) + _, err = st.FinishCardDAVSyncRunContext(ctx, run.ID, store.CardDAVSyncRunFinish{State: store.CardDAVSyncRunSucceeded}) + require.NoError(err) + } + active, err := st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerScheduled}) + require.NoError(err) + + var terminalCount, activeCount int + require.NoError(st.DB().QueryRow(st.Rebind(`SELECT COUNT(*) FROM carddav_sync_runs WHERE state <> 'running'`)).Scan(&terminalCount)) + require.NoError(st.DB().QueryRow(st.Rebind(`SELECT COUNT(*) FROM carddav_sync_runs WHERE state = 'running'`)).Scan(&activeCount)) + assert.Equal(100, terminalCount) + assert.Equal(1, activeCount) + + page1, err := st.ListCardDAVSyncRunsContext(ctx, 2, nil) + require.NoError(err) + require.Len(page1, 2) + assert.Equal(active.ID, page1[0].ID) + before := page1[1].ID + page2, err := st.ListCardDAVSyncRunsContext(ctx, 2, &before) + require.NoError(err) + require.Len(page2, 2) + assert.Less(page2[0].ID, before) + assert.NotEqual(page1[1].ID, page2[0].ID) + assert.Equal(ids[len(ids)-1], page1[1].ID) + + wantIDs := []int64{active.ID} + for i := len(ids) - 1; i >= 3; i-- { + wantIDs = append(wantIDs, ids[i]) + } + gotIDs := make([]int64, 0, len(wantIDs)) + var cursor *int64 + for { + page, pageErr := st.ListCardDAVSyncRunsContext(ctx, 17, cursor) + require.NoError(pageErr) + if len(page) == 0 { + break + } + for _, run := range page { + gotIDs = append(gotIDs, run.ID) + } + next := page[len(page)-1].ID + cursor = &next + } + assert.Equal(wantIDs, gotIDs, "exclusive pagination must neither duplicate nor omit retained rows") +} + +func TestCardDAVSyncRunTerminalTransitionsSurvivePruneFailure(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + if st.IsPostgreSQL() { + t.Skip("SQLite trigger supplies the deterministic DELETE failure") + } + ctx := t.Context() + + for range 101 { + _, err := st.DB().Exec(`INSERT INTO carddav_sync_runs + (trigger, full_sync, state, finished_at) VALUES ('manual', FALSE, 'succeeded', CURRENT_TIMESTAMP)`) + require.NoError(err) + } + _, err := st.DB().Exec(`CREATE TRIGGER reject_carddav_history_prune + BEFORE DELETE ON carddav_sync_runs WHEN OLD.state <> 'running' + BEGIN SELECT RAISE(ABORT, 'forced prune failure'); END`) + require.NoError(err) + + run, err := st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + require.NoError(err) + finished, err := st.FinishCardDAVSyncRunContext(ctx, run.ID, store.CardDAVSyncRunFinish{State: store.CardDAVSyncRunSucceeded}) + require.NoError(err) + assert.Equal(store.CardDAVSyncRunSucceeded, finished.State) + + orphan, err := st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerScheduled}) + require.NoError(err, "the committed finish must release the active-run constraint") + recovered, err := st.RecoverCardDAVSyncRunsContext(ctx) + require.NoError(err) + assert.Equal(int64(1), recovered) + + var state store.CardDAVSyncRunState + require.NoError(st.DB().QueryRow(`SELECT state FROM carddav_sync_runs WHERE id = ?`, orphan.ID).Scan(&state)) + assert.Equal(store.CardDAVSyncRunFailed, state) + _, err = st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + require.NoError(err, "the committed recovery must release the active-run constraint") +} + +func TestCardDAVSyncRunRecoveryAndSafePublicErrors(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + ctx := t.Context() + + orphan, err := st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + require.NoError(err) + recovered, err := st.RecoverCardDAVSyncRunsContext(ctx) + require.NoError(err) + assert.Equal(int64(1), recovered) + runs, err := st.ListCardDAVSyncRunsContext(ctx, 0, nil) + require.NoError(err) + require.NotEmpty(runs) + assert.Equal(orphan.ID, runs[0].ID) + assert.Equal(store.CardDAVSyncRunFailed, runs[0].State) + assert.Equal("daemon_restarted", runs[0].ErrorCode) + assert.NotEmpty(runs[0].ErrorMessage) + _, err = st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerScheduled}) + require.NoError(err) + + _, err = st.FinishCardDAVSyncRunContext(ctx, runs[0].ID, store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunFailed, ErrorCode: "remote_failure", + }) + require.ErrorIs(err, store.ErrCardDAVSyncRunTransition) +} + +func TestCardDAVSyncRunSchemaIndexesAndSQLiteReopenRecovery(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + var indexCount int + if st.IsPostgreSQL() { + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM pg_indexes + WHERE schemaname = current_schema() + AND indexname IN ('idx_carddav_sync_runs_one_active', 'idx_carddav_sync_runs_state_id')`).Scan(&indexCount)) + } else { + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM sqlite_master + WHERE type = 'index' + AND name IN ('idx_carddav_sync_runs_one_active', 'idx_carddav_sync_runs_state_id')`).Scan(&indexCount)) + } + assert.Equal(2, indexCount) + _, err := st.DB().Exec(st.Rebind(`INSERT INTO carddav_sync_runs + (trigger, full_sync, state, finished_at, error_code, error_message) + VALUES (?, ?, 'failed', CURRENT_TIMESTAMP, 'remote_failure', ?)`), + store.CardDAVSyncTriggerManual, false, strings.Repeat("x", 2001)) + require.Error(err, "schema must reject oversized public errors") + _, err = st.DB().Exec(st.Rebind(`INSERT INTO carddav_sync_runs + (trigger, full_sync, state, finished_at, error_code, error_message) + VALUES (?, ?, 'succeeded', CURRENT_TIMESTAMP, 'remote_failure', 'failure')`), + store.CardDAVSyncTriggerManual, false) + require.Error(err, "schema must reject errors on succeeded runs") + + if st.IsPostgreSQL() { + return + } + path := filepath.Join(t.TempDir(), "reopen.db") + first, err := store.OpenForTest(path) + require.NoError(err) + require.NoError(first.InitSchema()) + orphan, err := first.StartCardDAVSyncRunContext(t.Context(), store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + require.NoError(err) + require.NoError(first.Close()) + + reopened, err := store.OpenForTest(path) + require.NoError(err) + t.Cleanup(func() { _ = reopened.Close() }) + require.NoError(reopened.InitSchema()) + recovered, err := reopened.RecoverCardDAVSyncRunsContext(t.Context()) + require.NoError(err) + assert.Equal(int64(1), recovered) + runs, err := reopened.ListCardDAVSyncRunsContext(t.Context(), 1, nil) + require.NoError(err) + require.Len(runs, 1) + assert.Equal(orphan.ID, runs[0].ID) + assert.Equal(store.CardDAVSyncRunFailed, runs[0].State) + _, err = reopened.StartCardDAVSyncRunContext(t.Context(), store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerScheduled}) + require.NoError(err) +} + +func TestCardDAVSyncRunSchemaErrorCodeConstraintParity(t *testing.T) { + require := require.New(t) + st := testutil.NewTestStore(t) + insert := st.Rebind(`INSERT INTO carddav_sync_runs + (trigger, full_sync, state, finished_at, error_code, error_message) + VALUES (?, ?, 'failed', CURRENT_TIMESTAMP, ?, 'Safe public failure')`) + + for _, code := range []string{"1bad", "_bad"} { + _, err := st.DB().Exec(insert, store.CardDAVSyncTriggerManual, false, code) + require.Error(err, "schema must reject error code %q", code) + } + + validBoundary := "a" + strings.Repeat("_", 63) + result, err := st.DB().Exec(insert, store.CardDAVSyncTriggerManual, false, validBoundary) + require.NoError(err) + rows, err := result.RowsAffected() + require.NoError(err) + assert.Equal(t, int64(1), rows) +} + +func TestCardDAVSyncRunErrorProjectionIsUTF8BoundedAndRedacted(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + ctx := t.Context() + + run, err := st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + require.NoError(err) + long := strings.Repeat("界", 1000) + finished, err := st.FinishCardDAVSyncRunContext(ctx, run.ID, store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunFailed, ErrorCode: "remote_failure", ErrorMessage: long, + }) + require.NoError(err) + assert.LessOrEqual(len(finished.ErrorMessage), 2000) + assert.True(utf8.ValidString(finished.ErrorMessage)) + + run, err = st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + require.NoError(err) + unsafe := "Authorization: Bearer private-token password=hunter2 BEGIN:VCARD https://contacts.example.test/book" + finished, err = st.FinishCardDAVSyncRunContext(ctx, run.ID, store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunFailed, ErrorCode: "remote_failure", ErrorMessage: unsafe, + }) + require.NoError(err) + assert.Equal("unsafe_error_redacted", finished.ErrorCode) + assert.NotEqual(unsafe, finished.ErrorMessage) + for _, marker := range []string{"authorization", "bearer", "hunter2", "begin:vcard", "https://"} { + assert.NotContains(strings.ToLower(finished.ErrorMessage), marker) + } + + var stored string + require.NoError(st.DB().QueryRow(st.Rebind(`SELECT error_message FROM carddav_sync_runs WHERE id = ?`), finished.ID).Scan(&stored)) + assert.Equal(finished.ErrorMessage, stored) + + run, err = st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + require.NoError(err) + finished, err = st.FinishCardDAVSyncRunContext(ctx, run.ID, store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunFailed, ErrorCode: "password", ErrorMessage: "Safe-looking text", + }) + require.NoError(err) + assert.Equal("unsafe_error_redacted", finished.ErrorCode) + assert.NotContains(strings.ToLower(finished.ErrorMessage), "password") + + run, err = st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{Trigger: store.CardDAVSyncTriggerManual}) + require.NoError(err) + finished, err = st.FinishCardDAVSyncRunContext(ctx, run.ID, store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunFailed, ErrorCode: "remote_failure", ErrorMessage: string([]byte{'o', 'k', 0xff}), + }) + require.NoError(err) + assert.True(utf8.ValidString(finished.ErrorMessage)) + assert.Equal("ok�", finished.ErrorMessage) +} + +func TestCardDAVSyncRunRedactsStandaloneCredentialMarkers(t *testing.T) { + st := testutil.NewTestStore(t) + ctx := t.Context() + + for _, tc := range []struct { + name string + message string + }{ + {name: "api_key", message: "API_KEY=synthetic-value"}, + {name: "access_token", message: "access-token: synthetic-value"}, + {name: "refresh_token", message: "refreshToken=synthetic-value"}, + {name: "credential", message: "Credential: synthetic-value"}, + } { + t.Run(tc.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + run, err := st.StartCardDAVSyncRunContext(ctx, store.CardDAVSyncRunStart{ + Trigger: store.CardDAVSyncTriggerManual, + }) + require.NoError(err) + finished, err := st.FinishCardDAVSyncRunContext(ctx, run.ID, store.CardDAVSyncRunFinish{ + State: store.CardDAVSyncRunFailed, ErrorCode: "remote_failure", ErrorMessage: tc.message, + }) + require.NoError(err) + assert.Equal("unsafe_error_redacted", finished.ErrorCode) + assert.Equal("CardDAV sync failed; sensitive details were removed.", finished.ErrorMessage) + + var storedCode, storedMessage string + require.NoError(st.DB().QueryRow(st.Rebind(`SELECT error_code, error_message + FROM carddav_sync_runs WHERE id = ?`), finished.ID).Scan(&storedCode, &storedMessage)) + assert.Equal("unsafe_error_redacted", storedCode) + assert.Equal("CardDAV sync failed; sensitive details were removed.", storedMessage) + }) + } +} diff --git a/internal/store/export_test.go b/internal/store/export_test.go index 5495207f4..1bf083577 100644 --- a/internal/store/export_test.go +++ b/internal/store/export_test.go @@ -288,3 +288,10 @@ func RollbackPersonEnrichmentAttemptCompletionForTest( } return err } + +// SetPersonNetworkSourceReadHookForTest records the finite layer budget and +// raw adjacency rows consumed before edge deduplication or hydration. +func (s *Store) SetPersonNetworkSourceReadHookForTest(fn func(limit, count int)) func() { + s.personNetworkSourceReadHook = fn + return func() { s.personNetworkSourceReadHook = nil } +} diff --git a/internal/store/operation_history_reader_test.go b/internal/store/operation_history_reader_test.go new file mode 100644 index 000000000..c1f98a82c --- /dev/null +++ b/internal/store/operation_history_reader_test.go @@ -0,0 +1,392 @@ +package store_test + +import ( + "strconv" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/operations" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +func TestOperationHistoryReaderWalksExactMergedOrder(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + instant := time.Date(2026, 8, 29, 2, 0, 0, 0, time.UTC) + seedMergedOperationRuns(t, st, instant) + assert.Equal([]operations.Kind{ + operations.KindCardDAVSync, + operations.KindPersonSweep, + operations.KindSourceSync, + }, st.Kinds()) + + want := []string{ + "carddav_sync:10", "carddav_sync:9", + "person_sweep:run-b", "person_sweep:run-a", + "source_sync:10", "source_sync:9", + } + var got []string + var position *operations.Position + for len(got) < len(want) { + runs, err := st.ListRuns(t.Context(), operations.Query{Position: position, Limit: 1}) + require.NoError(err) + require.NotEmpty(runs) + assert.LessOrEqual(len(runs), 2, "reader returns at most limit+1") + got = append(got, operationRunKey(t, runs[0])) + position = &operations.Position{StartedAt: runs[0].StartedAt, ID: runs[0].ID} + } + assert.Equal(want, got) + after, err := st.ListRuns(t.Context(), operations.Query{Position: position, Limit: 1}) + require.NoError(err) + assert.Empty(after) +} + +func TestOperationHistoryReaderFiltersAndRejectsInvalidQueries(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + instant := time.Date(2026, 8, 29, 3, 0, 0, 0, time.UTC) + seedMergedOperationRuns(t, st, instant) + + people, err := st.ListRuns(t.Context(), operations.Query{ + Kinds: []operations.Kind{operations.KindPersonSweep}, Limit: 100, + }) + require.NoError(err) + assert.Equal([]string{"person_sweep:run-b", "person_sweep:run-a"}, operationRunKeys(t, people)) + + succeeded, err := st.ListRuns(t.Context(), operations.Query{ + States: []operations.State{operations.StateSucceeded}, Limit: 100, + }) + require.NoError(err) + require.Len(succeeded, 6) + for _, run := range succeeded { + assert.Equal(operations.StateSucceeded, run.State) + } + + for _, query := range []operations.Query{{Limit: 0}, {Limit: 101}, { + Kinds: []operations.Kind{operations.KindSourceSync, operations.KindCardDAVSync}, Limit: 10, + }} { + runs, queryErr := st.ListRuns(t.Context(), query) + require.Error(queryErr) + assert.Nil(runs) + } +} + +func TestOperationHistoryReaderOrdersNeighboringTimestamps(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + base := time.Date(2026, 8, 29, 3, 15, 0, 0, time.UTC) + cardDAVID := insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + startedAt: base, trigger: "manual", state: "succeeded", + }) + insertPersonSweepOperationRun(t, st, personSweepOperationSeed{ + id: "neighbor-person", trigger: "manual", state: "succeeded", + startedAt: base.Add(time.Millisecond), + }) + source := createOperationSource(t, st, "neighbor-operations@example.invalid") + sourceID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: base.Add(time.Second), state: "running", processed: 1, + }) + + runs, err := st.ListRuns(t.Context(), operations.Query{Limit: 10}) + require.NoError(err) + assert.Equal([]string{ + "source_sync:" + fmtInt64(sourceID), + "person_sweep:neighbor-person", + "carddav_sync:" + fmtInt64(cardDAVID), + }, operationRunKeys(t, runs)) + + running, err := st.ListRuns(t.Context(), operations.Query{ + States: []operations.State{operations.StateRunning}, Limit: 10, + }) + require.NoError(err) + assert.Equal([]string{"source_sync:" + fmtInt64(sourceID)}, operationRunKeys(t, running)) +} + +func TestOperationHistoryReaderPreservesMixedTimestampPrecision(t *testing.T) { + st := testutil.NewTestStore(t) + second := time.Date(2026, 8, 29, 3, 30, 0, 0, time.UTC) + cardDAVID := insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + startedAt: second, trigger: "manual", state: "succeeded", + }) + source := createOperationSource(t, st, "precision-operations@example.invalid") + sourceID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: second, state: "completed", processed: 1, + }) + personCursor, err := operations.NewTextID(operations.KindPersonSweep, "fractional-cursor") + require.NoError(t, err) + + runs, err := st.ListRuns(t.Context(), operations.Query{ + Position: &operations.Position{StartedAt: second.Add(500 * time.Millisecond), ID: personCursor}, + Limit: 10, + }) + require.NoError(t, err) + assert.Equal(t, []string{ + "carddav_sync:" + fmtInt64(cardDAVID), + "source_sync:" + fmtInt64(sourceID), + }, operationRunKeys(t, runs)) +} + +func TestOperationHistoryReaderDoesNotApplyIDTieAtFinerSourceCursor(t *testing.T) { + st := testutil.NewSQLiteTestStore(t) + second := time.Date(2026, 8, 29, 3, 45, 0, 0, time.UTC) + source := createOperationSource(t, st, "source-fine-cursor@example.invalid") + lowerID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: second, state: "completed", processed: 1, + }) + higherID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: second, state: "completed", processed: 2, + }) + cursorID := mustSourceOperationID(t, lowerID) + + runs, err := st.ListRuns(t.Context(), operations.Query{ + Kinds: []operations.Kind{operations.KindSourceSync}, + Position: &operations.Position{ + StartedAt: second.Add(500 * time.Millisecond), ID: cursorID, + }, + Limit: 10, + }) + require.NoError(t, err) + assert.Equal(t, []string{ + "source_sync:" + fmtInt64(higherID), + "source_sync:" + fmtInt64(lowerID), + }, operationRunKeys(t, runs)) +} + +func TestOperationHistoryReaderDoesNotApplyIDTieAtSubMillisecondPeopleCursor(t *testing.T) { + st := testutil.NewSQLiteTestStore(t) + stored := time.Date(2026, 8, 29, 3, 50, 0, 500_000_000, time.UTC) + for _, id := range []string{"run-a", "run-b"} { + insertPersonSweepOperationRun(t, st, personSweepOperationSeed{ + id: id, trigger: "manual", state: "succeeded", startedAt: stored, + }) + } + cursorID := mustPersonSweepOperationID(t, "run-a") + runs, err := st.ListRuns(t.Context(), operations.Query{ + Kinds: []operations.Kind{operations.KindPersonSweep}, + Position: &operations.Position{ + StartedAt: stored.Add(500 * time.Microsecond), ID: cursorID, + }, + Limit: 10, + }) + require.NoError(t, err) + assert.Equal(t, []string{"person_sweep:run-b", "person_sweep:run-a"}, operationRunKeys(t, runs)) +} + +func TestOperationHistoryReaderBoundsEveryAdapterToLimitPlusOne(t *testing.T) { + st := testutil.NewTestStore(t) + query := operations.Query{Limit: 1} + for _, list := range []func(int) error{ + func(fetchLimit int) error { + _, err := store.ListSourceOperationRunsWithFetchLimitForTest( + st, t.Context(), query, fetchLimit) + return err + }, + func(fetchLimit int) error { + _, err := store.ListPersonSweepOperationRunsWithFetchLimitForTest( + st, t.Context(), query, fetchLimit) + return err + }, + func(fetchLimit int) error { + _, err := store.ListCardDAVOperationRunsWithFetchLimitForTest( + st, t.Context(), query, fetchLimit) + return err + }, + } { + require.NoError(t, list(2)) + require.Error(t, list(3)) + } +} + +func TestOperationHistoryReaderReturnsNoPartialRowsOnAdapterFailure(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + instant := time.Date(2026, 8, 29, 4, 0, 0, 0, time.UTC) + seedMergedOperationRuns(t, st, instant) + _, err := st.DB().ExecContext(t.Context(), + `ALTER TABLE person_sweep_runs RENAME TO person_sweep_runs_unavailable`) + require.NoError(err) + + runs, err := st.ListRuns(t.Context(), operations.Query{Limit: 100}) + require.Error(err) + assert.Nil(runs) + + cardDAVStatus, err := st.LaneStatus(t.Context(), operations.KindCardDAVSync) + require.NoError(err) + assert.Equal(operations.KindCardDAVSync, cardDAVStatus.Kind) + _, err = st.LaneStatus(t.Context(), operations.KindPersonSweep) + require.Error(err) +} + +func TestOperationHistoryReaderGetDispatchesByTypedKind(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + instant := time.Date(2026, 8, 29, 5, 0, 0, 0, time.UTC) + seedMergedOperationRuns(t, st, instant) + + runs, err := st.ListRuns(t.Context(), operations.Query{Limit: 100}) + require.NoError(err) + for _, listed := range runs { + detail, detailErr := st.GetRun(t.Context(), listed.ID) + require.NoError(detailErr) + assert.Equal(listed, detail) + } + + cardDAVID, err := operations.NewInt64ID(operations.KindCardDAVSync, 9) + require.NoError(err) + sourceID, err := operations.NewInt64ID(operations.KindSourceSync, 9) + require.NoError(err) + cardDAV, err := st.GetRun(t.Context(), cardDAVID) + require.NoError(err) + source, err := st.GetRun(t.Context(), sourceID) + require.NoError(err) + assert.Equal(operations.KindCardDAVSync, cardDAV.ID.Kind()) + assert.Equal(operations.KindSourceSync, source.ID.Kind()) + assert.NotEqual(cardDAV, source) + + missing, err := operations.NewTextID(operations.KindPersonSweep, "missing") + require.NoError(err) + _, err = st.GetRun(t.Context(), missing) + require.ErrorIs(err, store.ErrOperationRunNotFound) +} + +func TestOperationHistoryReaderUsesOneCoherentSnapshot(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + before := time.Date(2026, 8, 29, 6, 0, 0, 250_000_000, time.UTC) + after := before.Add(time.Hour) + insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + startedAt: before, trigger: "manual", state: "succeeded", + }) + insertPersonSweepOperationRun(t, st, personSweepOperationSeed{ + id: "snapshot-person", trigger: "manual", state: "succeeded", startedAt: before, + }) + + var once sync.Once + store.SetOperationHistoryAfterAdapterReadHookForTest(st, func(kind operations.Kind) { + if kind != operations.KindCardDAVSync { + return + } + once.Do(func() { + _, err := st.DB().ExecContext(t.Context(), st.Rebind(` + UPDATE person_sweep_runs SET started_at = ?, completed_at = ? WHERE id = ?`), + personSweepTimestampParam(st, after), + personSweepTimestampParam(st, after.Add(time.Second)), + "snapshot-person") + require.NoError(err) + }) + }) + t.Cleanup(func() { store.SetOperationHistoryAfterAdapterReadHookForTest(st, nil) }) + + runs, err := st.ListRuns(t.Context(), operations.Query{Limit: 10}) + require.NoError(err) + require.Len(runs, 2) + for _, run := range runs { + wantStartedAt := before + if run.ID.Kind() == operations.KindCardDAVSync && !st.IsPostgreSQL() { + wantStartedAt = before.Truncate(time.Second) + } + assert.Equal(wantStartedAt, run.StartedAt, + "all adapters must observe the snapshot established by the first adapter") + } + + store.SetOperationHistoryAfterAdapterReadHookForTest(st, nil) + person, err := st.GetRun(t.Context(), mustPersonSweepOperationID(t, "snapshot-person")) + require.NoError(err) + assert.Equal(after, person.StartedAt, + "the concurrent transition committed after the snapshot") +} + +func TestOperationHistoryReaderLaneStatusUsesOneCoherentSnapshot(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + startedAt := time.Date(2026, 8, 29, 6, 30, 0, 0, time.UTC) + source := createOperationSource(t, st, "status-snapshot@example.invalid") + runID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: startedAt, state: "running", processed: 1, + }) + + var once sync.Once + store.SetOperationHistoryStatusAfterActiveHookForTest(st, func(kind operations.Kind) { + if kind != operations.KindSourceSync { + return + } + once.Do(func() { + _, err := st.DB().ExecContext(t.Context(), st.Rebind(` + UPDATE sync_runs SET status = 'failed', completed_at = ? WHERE id = ?`), + sourceOperationTimestampParam(st, startedAt.Add(time.Second)), runID) + require.NoError(err) + }) + }) + t.Cleanup(func() { store.SetOperationHistoryStatusAfterActiveHookForTest(st, nil) }) + + status, err := st.LaneStatus(t.Context(), operations.KindSourceSync) + require.NoError(err) + require.NotNil(status.Active) + require.NotNil(status.Latest) + assert.Equal(operations.StateRunning, status.Active.State) + assert.Equal(operations.StateRunning, status.Latest.State, + "latest must share the active query's snapshot") + assert.Nil(status.LatestSuccessful) + + store.SetOperationHistoryStatusAfterActiveHookForTest(st, nil) + finished, err := st.GetRun(t.Context(), mustSourceOperationID(t, runID)) + require.NoError(err) + assert.Equal(operations.StateFailed, finished.State, + "the terminal transition committed after the status snapshot") +} + +func seedMergedOperationRuns(t *testing.T, st *store.Store, instant time.Time) { + t.Helper() + source := createOperationSource(t, st, "merged-operations@example.invalid") + for ordinal := 1; ordinal <= 10; ordinal++ { + insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: instant, state: "completed", processed: int64(ordinal), + }) + insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + startedAt: instant, trigger: "manual", state: "succeeded", books: int64(ordinal), + }) + } + _, err := st.DB().ExecContext(t.Context(), `DELETE FROM sync_runs WHERE id < 9`) + require.NoError(t, err) + _, err = st.DB().ExecContext(t.Context(), `DELETE FROM carddav_sync_runs WHERE id < 9`) + require.NoError(t, err) + for _, id := range []string{"run-a", "run-b"} { + insertPersonSweepOperationRun(t, st, personSweepOperationSeed{ + id: id, trigger: "manual", state: "succeeded", startedAt: instant, + }) + } +} + +func operationRunKeys(t *testing.T, runs []operations.Run) []string { + t.Helper() + keys := make([]string, 0, len(runs)) + for _, run := range runs { + keys = append(keys, operationRunKey(t, run)) + } + return keys +} + +func operationRunKey(t *testing.T, run operations.Run) string { + t.Helper() + if value, ok := run.ID.Int64(); ok { + return string(run.ID.Kind()) + ":" + fmtInt64(value) + } + value, ok := run.ID.Text() + require.True(t, ok) + return string(run.ID.Kind()) + ":" + value +} + +func fmtInt64(value int64) string { + return strconv.FormatInt(value, 10) +} diff --git a/internal/store/operation_runs.go b/internal/store/operation_runs.go new file mode 100644 index 000000000..aa37ee528 --- /dev/null +++ b/internal/store/operation_runs.go @@ -0,0 +1,943 @@ +package store + +import ( + "cmp" + "context" + "database/sql" + "errors" + "fmt" + "slices" + "strings" + "time" + + "go.kenn.io/msgvault/internal/operations" + "go.kenn.io/msgvault/internal/peoplesweep" +) + +// ErrOperationRunNotFound reports that a valid operation run ID has no row in +// the durable history owned by this store. +var ( + ErrOperationRunNotFound = errors.New("operation run not found") + ErrOperationHistoryUnavailable = errors.New("operation history unavailable") +) + +var _ operations.HistoryReader = (*Store)(nil) + +var durableOperationKinds = []operations.Kind{ + operations.KindCardDAVSync, + operations.KindPersonSweep, + operations.KindSourceSync, +} + +const ( + sourceOperationRunColumns = `id, started_at, completed_at, status, + messages_processed, messages_added, messages_updated, errors_count` + cardDAVOperationRunColumns = `id, trigger, state, started_at, finished_at, + books, created, updated, removed, + CASE WHEN state IN ('failed', 'cancelled', 'partial') THEN error_code ELSE '' END` + operationSQLiteTimestampLayout = "2006-01-02 15:04:05" +) + +func personSweepOperationRunColumns(textCollation string) string { + return `r.id, r.kind, r.status, + r.attempt_count, r.success_count, r.failure_count, r.projected_write_count, + r.started_at, r.completed_at, + COALESCE(( + SELECT a.failure_class + FROM person_sweep_attempts a + WHERE a.run_id = r.id AND a.failure_class <> '' + ORDER BY COALESCE(a.completed_at, a.started_at) DESC, a.id` + textCollation + ` DESC + LIMIT 1 + ), '')` +} + +// strictOperationNullableTimestamp distinguishes SQL NULL from malformed +// non-null values. The shared nullableTimestamp intentionally does not, but +// public operation history must fail closed on durable ledger corruption. +type strictOperationNullableTimestamp struct { + Time time.Time + Valid bool +} + +func (n *strictOperationNullableTimestamp) Scan(src any) error { + if src == nil { + n.Time = time.Time{} + n.Valid = false + return nil + } + var required requiredTimestamp + if err := required.Scan(src); err != nil { + return fmt.Errorf("operation completion timestamp: %w", err) + } + n.Time = required.Time + n.Valid = true + return nil +} + +// listSourceOperationRuns projects source sync rows using only the durable +// columns allowed by the normalized operations contract. +func (s *Store) listSourceOperationRuns( + ctx context.Context, query operations.Query, fetchLimit int, +) ([]operations.Run, error) { + return s.listSourceOperationRunsFrom(ctx, s.db, query, fetchLimit) +} + +func (s *Store) listSourceOperationRunsFrom( + ctx context.Context, queryer contextRowsQuerier, query operations.Query, fetchLimit int, +) ([]operations.Run, error) { + if err := query.Validate(); err != nil { + return nil, fmt.Errorf("list operation runs: %w", err) + } + if fetchLimit < 1 || fetchLimit > query.Limit+1 { + return nil, errors.New("source operation fetch limit must be between one and query limit plus one") + } + + conditions := make([]string, 0, 2) + args := make([]any, 0, 4) + if len(query.States) > 0 { + conditions = append(conditions, sourceOperationStateCondition(query.States)) + } + if query.Position != nil { + condition, positionArgs, err := s.sourceOperationPositionCondition(*query.Position) + if err != nil { + return nil, fmt.Errorf("list operation runs: %w", err) + } + conditions = append(conditions, condition) + args = append(args, positionArgs...) + } + + statement := `SELECT ` + sourceOperationRunColumns + ` FROM sync_runs` + if len(conditions) > 0 { + statement += ` WHERE ` + strings.Join(conditions, ` AND `) + } + statement += ` ORDER BY started_at DESC, id DESC LIMIT ?` + args = append(args, fetchLimit) + + rows, err := queryer.QueryContext(ctx, statement, args...) + if err != nil { + return nil, fmt.Errorf("list source operation runs: %w", err) + } + defer func() { _ = rows.Close() }() + + runs := make([]operations.Run, 0, fetchLimit) + for rows.Next() { + run, scanErr := scanSourceOperationRun(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan source operation run: %w", scanErr) + } + runs = append(runs, run) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate source operation runs: %w", err) + } + return runs, nil +} + +// getSourceOperationRun returns one safe source operation projection. The +// typed stable ID is validated before it can select a durable table. +func (s *Store) getSourceOperationRun( + ctx context.Context, id operations.StableID, +) (operations.Run, error) { + if err := id.Validate(); err != nil { + return operations.Run{}, fmt.Errorf("get operation run: %w", err) + } + if id.Kind() != operations.KindSourceSync { + return operations.Run{}, fmt.Errorf("get operation run: %w", ErrOperationRunNotFound) + } + runID, ok := id.Int64() + if !ok { + return operations.Run{}, fmt.Errorf("get operation run: %w", ErrOperationRunNotFound) + } + + run, err := scanSourceOperationRun(s.db.QueryRowContext(ctx, + `SELECT `+sourceOperationRunColumns+` FROM sync_runs WHERE id = ?`, runID)) + if errors.Is(err, sql.ErrNoRows) { + return operations.Run{}, fmt.Errorf("get operation run: %w", ErrOperationRunNotFound) + } + if err != nil { + return operations.Run{}, fmt.Errorf("get source operation run: %w", err) + } + return run, nil +} + +// sourceOperationLaneStatus returns a consistent source-lane snapshot. Each +// query reads only the safe run columns and all three roles share one database +// snapshot. +func (s *Store) sourceOperationLaneStatus( + ctx context.Context, +) (operations.LaneHistoryStatus, error) { + status := operations.LaneHistoryStatus{ + Kind: operations.KindSourceSync, + Lane: operations.LaneMessages, + HistoryAvailability: operations.HistoryAvailable, + } + err := s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + var err error + status.Active, err = sourceOperationStatusRun( + ctx, tx, `status = 'running'`) + if err != nil { + return fmt.Errorf("read active source operation run: %w", err) + } + if s.operationHistoryStatusAfterActiveHook != nil { + s.operationHistoryStatusAfterActiveHook(string(operations.KindSourceSync)) + } + status.Latest, err = sourceOperationStatusRun(ctx, tx, `TRUE`) + if err != nil { + return fmt.Errorf("read latest source operation run: %w", err) + } + status.LatestSuccessful, err = sourceOperationStatusRun( + ctx, tx, `status = 'completed' AND errors_count = 0`) + if err != nil { + return fmt.Errorf("read latest successful source operation run: %w", err) + } + return nil + }) + if err != nil { + return operations.LaneHistoryStatus{}, fmt.Errorf("read source operation status: %w", err) + } + if err := status.Validate(); err != nil { + return operations.LaneHistoryStatus{}, fmt.Errorf("validate source operation status: %w", err) + } + return status, nil +} + +func sourceOperationStateCondition(states []operations.State) string { + conditions := make([]string, 0, len(states)) + for _, state := range states { + switch state { + case operations.StateRunning: + conditions = append(conditions, `status = 'running'`) + case operations.StateSucceeded: + conditions = append(conditions, `(status = 'completed' AND errors_count = 0)`) + case operations.StatePartial: + conditions = append(conditions, `((status = 'completed' AND errors_count > 0) OR `+ + `(status = 'failed' AND (messages_added > 0 OR messages_updated > 0)))`) + case operations.StateFailed: + conditions = append(conditions, `(status = 'failed' AND messages_added = 0 AND messages_updated = 0)`) + case operations.StateCancelled: + conditions = append(conditions, `status = 'cancelled'`) + case operations.StateQueued: + // Source sync has no durable queued rows. + } + } + if len(conditions) == 0 { + return `FALSE` + } + return `(` + strings.Join(conditions, ` OR `) + `)` +} + +func (s *Store) sourceOperationPositionCondition( + position operations.Position, +) (string, []any, error) { + timestamp := s.operationTimestampParam(position.StartedAt) + if s.sqliteOperationPositionFinerThan(position.StartedAt, time.Second) { + return `started_at <= ?`, []any{timestamp}, nil + } + switch cmp.Compare(operations.KindSourceSync, position.ID.Kind()) { + case -1: + return `started_at < ?`, []any{timestamp}, nil + case 1: + return `started_at <= ?`, []any{timestamp}, nil + default: + positionID, ok := position.ID.Int64() + if !ok { + return "", nil, errors.New("source operation position requires a numeric source run ID") + } + return `(started_at < ? OR (started_at = ? AND id < ?))`, + []any{timestamp, timestamp, positionID}, nil + } +} + +// operationTimestampParam renders a run position timestamp the way the +// source and CardDAV run tables store started_at on each backend. +func (s *Store) operationTimestampParam(value time.Time) any { + if s.IsPostgreSQL() { + return s.dialect.TimestampParam(value) + } + return value.UTC().Format(operationSQLiteTimestampLayout) +} + +func sourceOperationStatusQuery(condition string) string { + return `SELECT ` + sourceOperationRunColumns + ` FROM sync_runs WHERE ` + condition + + ` ORDER BY started_at DESC, id DESC LIMIT 1` +} + +func sourceOperationStatusRun( + ctx context.Context, queryer *loggedTx, condition string, +) (*operations.Run, error) { + run, err := scanSourceOperationRun(queryer.QueryRowContext(ctx, sourceOperationStatusQuery(condition))) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil //nolint:nilnil // No matching source run is a valid empty status. + } + if err != nil { + return nil, err + } + return &run, nil +} + +func scanSourceOperationRun(sc scanner) (operations.Run, error) { + var ( + runID int64 + startedAt requiredTimestamp + completedAt strictOperationNullableTimestamp + durableState string + processed int64 + added int64 + updated int64 + itemErrors int64 + ) + if err := sc.Scan( + &runID, &startedAt, &completedAt, &durableState, + &processed, &added, &updated, &itemErrors, + ); err != nil { + return operations.Run{}, err + } + + id, err := operations.NewInt64ID(operations.KindSourceSync, runID) + if err != nil { + return operations.Run{}, fmt.Errorf("source operation stable ID: %w", err) + } + state, publicError, err := operations.ProjectSourceState( + durableState, itemErrors, added, updated) + if err != nil { + return operations.Run{}, err + } + run := operations.Run{ + ID: id, + Lane: operations.LaneMessages, + State: state, + StartedAt: startedAt.Time.UTC(), + Counters: []operations.PublicCounter{ + {Name: operations.CounterProcessed, Unit: operations.CounterUnitMessages, Value: processed}, + {Name: operations.CounterAdded, Unit: operations.CounterUnitMessages, Value: added}, + {Name: operations.CounterUpdated, Unit: operations.CounterUnitMessages, Value: updated}, + {Name: operations.CounterItemErrors, Unit: operations.CounterUnitMessages, Value: itemErrors}, + }, + Error: publicError, + } + if completedAt.Valid { + finishedAt := completedAt.Time.UTC() + run.FinishedAt = &finishedAt + } + if err := run.Validate(); err != nil { + return operations.Run{}, fmt.Errorf("validate source operation run: %w", err) + } + return run, nil +} + +// listPersonSweepOperationRuns projects only safe run-level sweep metadata and +// one allow-listed aggregate failure class. It never hydrates person, attempt, +// evidence, fingerprint, provider, model, or usage fields. +func (s *Store) listPersonSweepOperationRuns( + ctx context.Context, query operations.Query, fetchLimit int, +) ([]operations.Run, error) { + return s.listPersonSweepOperationRunsFrom(ctx, s.db, query, fetchLimit) +} + +func (s *Store) listPersonSweepOperationRunsFrom( + ctx context.Context, queryer contextRowsQuerier, query operations.Query, fetchLimit int, +) ([]operations.Run, error) { + if err := query.Validate(); err != nil { + return nil, fmt.Errorf("list operation runs: %w", err) + } + if fetchLimit < 1 || fetchLimit > query.Limit+1 { + return nil, errors.New("person sweep operation fetch limit must be between one and query limit plus one") + } + + conditions := make([]string, 0, 2) + args := make([]any, 0, 4) + if len(query.States) > 0 { + conditions = append(conditions, personSweepOperationStateCondition(query.States)) + } + if query.Position != nil { + condition, positionArgs, err := s.personSweepOperationPositionCondition(*query.Position) + if err != nil { + return nil, fmt.Errorf("list operation runs: %w", err) + } + conditions = append(conditions, condition) + args = append(args, positionArgs...) + } + + textCollation := s.bytewiseTextCollation() + statement := `SELECT ` + personSweepOperationRunColumns(textCollation) + ` FROM person_sweep_runs r` + if len(conditions) > 0 { + statement += ` WHERE ` + strings.Join(conditions, ` AND `) + } + statement += ` ORDER BY r.started_at DESC, r.id` + textCollation + ` DESC LIMIT ?` + args = append(args, fetchLimit) + + rows, err := queryer.QueryContext(ctx, statement, args...) + if err != nil { + return nil, fmt.Errorf("list person sweep operation runs: %w", err) + } + defer func() { _ = rows.Close() }() + + runs := make([]operations.Run, 0, fetchLimit) + for rows.Next() { + run, scanErr := scanPersonSweepOperationRun(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan person sweep operation run: %w", scanErr) + } + runs = append(runs, run) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person sweep operation runs: %w", err) + } + return runs, nil +} + +func (s *Store) getPersonSweepOperationRun( + ctx context.Context, id operations.StableID, +) (operations.Run, error) { + if err := id.Validate(); err != nil { + return operations.Run{}, fmt.Errorf("get operation run: %w", err) + } + if id.Kind() != operations.KindPersonSweep { + return operations.Run{}, fmt.Errorf("get operation run: %w", ErrOperationRunNotFound) + } + runID, ok := id.Text() + if !ok { + return operations.Run{}, fmt.Errorf("get operation run: %w", ErrOperationRunNotFound) + } + run, err := scanPersonSweepOperationRun(s.db.QueryRowContext(ctx, + `SELECT `+personSweepOperationRunColumns(s.bytewiseTextCollation())+ + ` FROM person_sweep_runs r WHERE r.id = ?`, runID)) + if errors.Is(err, sql.ErrNoRows) { + return operations.Run{}, fmt.Errorf("get operation run: %w", ErrOperationRunNotFound) + } + if err != nil { + return operations.Run{}, fmt.Errorf("get person sweep operation run: %w", err) + } + return run, nil +} + +func (s *Store) personSweepOperationLaneStatus( + ctx context.Context, +) (operations.LaneHistoryStatus, error) { + status := operations.LaneHistoryStatus{ + Kind: operations.KindPersonSweep, + Lane: operations.LanePersonFacts, + HistoryAvailability: operations.HistoryAvailable, + } + err := s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + var err error + columns := personSweepOperationRunColumns(s.bytewiseTextCollation()) + status.Active, err = personSweepOperationStatusRun( + ctx, tx, columns, s.bytewiseTextCollation(), `r.status = 'running'`) + if err != nil { + return fmt.Errorf("read active person sweep operation run: %w", err) + } + if s.operationHistoryStatusAfterActiveHook != nil { + s.operationHistoryStatusAfterActiveHook(string(operations.KindPersonSweep)) + } + status.Latest, err = personSweepOperationStatusRun( + ctx, tx, columns, s.bytewiseTextCollation(), `TRUE`) + if err != nil { + return fmt.Errorf("read latest person sweep operation run: %w", err) + } + status.LatestSuccessful, err = personSweepOperationStatusRun( + ctx, tx, columns, s.bytewiseTextCollation(), `r.status = 'succeeded'`) + if err != nil { + return fmt.Errorf("read latest successful person sweep operation run: %w", err) + } + return nil + }) + if err != nil { + return operations.LaneHistoryStatus{}, fmt.Errorf("read person sweep operation status: %w", err) + } + if err := status.Validate(); err != nil { + return operations.LaneHistoryStatus{}, fmt.Errorf("validate person sweep operation status: %w", err) + } + return status, nil +} + +func personSweepOperationStateCondition(states []operations.State) string { + conditions := make([]string, 0, len(states)) + for _, state := range states { + switch state { + case operations.StateRunning: + conditions = append(conditions, `r.status = 'running'`) + case operations.StateSucceeded: + conditions = append(conditions, `r.status = 'succeeded'`) + case operations.StatePartial: + conditions = append(conditions, `r.status = 'partial'`) + case operations.StateFailed: + conditions = append(conditions, `r.status = 'failed'`) + case operations.StateQueued, operations.StateCancelled: + // Person sweep has no durable queued or cancelled run state. + } + } + if len(conditions) == 0 { + return `FALSE` + } + return `(` + strings.Join(conditions, ` OR `) + `)` +} + +func (s *Store) personSweepOperationPositionCondition( + position operations.Position, +) (string, []any, error) { + timestamp := s.dialect.TimestampParam(position.StartedAt) + if s.sqliteOperationPositionFinerThan(position.StartedAt, time.Millisecond) { + return `r.started_at <= ?`, []any{timestamp}, nil + } + switch cmp.Compare(operations.KindPersonSweep, position.ID.Kind()) { + case -1: + return `r.started_at < ?`, []any{timestamp}, nil + case 1: + return `r.started_at <= ?`, []any{timestamp}, nil + default: + positionID, ok := position.ID.Text() + if !ok { + return "", nil, errors.New("person sweep operation position requires a text sweep run ID") + } + return `(r.started_at < ? OR (r.started_at = ? AND r.id` + + s.bytewiseTextCollation() + ` < ?))`, + []any{timestamp, timestamp, positionID}, nil + } +} + +func personSweepOperationStatusQuery(columns, textCollation, condition string) string { + return `SELECT ` + columns + ` FROM person_sweep_runs r WHERE ` + condition + + ` ORDER BY r.started_at DESC, r.id` + textCollation + ` DESC LIMIT 1` +} + +func personSweepOperationStatusRun( + ctx context.Context, queryer *loggedTx, columns, textCollation, condition string, +) (*operations.Run, error) { + run, err := scanPersonSweepOperationRun(queryer.QueryRowContext(ctx, + personSweepOperationStatusQuery(columns, textCollation, condition))) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil //nolint:nilnil // No matching person sweep run is a valid empty status. + } + if err != nil { + return nil, err + } + return &run, nil +} + +func (s *Store) bytewiseTextCollation() string { + if s.dialect.DriverName() == postgresDriverName { + return ` COLLATE "C"` + } + return ` COLLATE BINARY` +} + +func scanPersonSweepOperationRun(sc scanner) (operations.Run, error) { + var ( + runID string + durableTrigger string + durableState string + attempted int64 + succeeded int64 + failed int64 + projected int64 + startedAt requiredTimestamp + completedAt strictOperationNullableTimestamp + failureClass string + ) + if err := sc.Scan( + &runID, &durableTrigger, &durableState, + &attempted, &succeeded, &failed, &projected, + &startedAt, &completedAt, &failureClass, + ); err != nil { + return operations.Run{}, err + } + + id, err := operations.NewTextID(operations.KindPersonSweep, runID) + if err != nil { + return operations.Run{}, fmt.Errorf("person sweep operation stable ID: %w", err) + } + trigger := operations.Trigger(durableTrigger) + if err := trigger.Validate(); err != nil { + return operations.Run{}, fmt.Errorf("person sweep operation trigger: %w", err) + } + state := operations.State(durableState) + if err := state.Validate(); err != nil { + return operations.Run{}, fmt.Errorf("person sweep operation state: %w", err) + } + run := operations.Run{ + ID: id, + Lane: operations.LanePersonFacts, + State: state, + Trigger: &trigger, + StartedAt: startedAt.Time.UTC(), + Counters: []operations.PublicCounter{ + {Name: operations.CounterAttempted, Unit: operations.CounterUnitPeople, Value: attempted}, + {Name: operations.CounterSucceeded, Unit: operations.CounterUnitPeople, Value: succeeded}, + {Name: operations.CounterFailed, Unit: operations.CounterUnitPeople, Value: failed}, + {Name: operations.CounterProjectedWrites, Unit: operations.CounterUnitWrites, Value: projected}, + }, + } + if completedAt.Valid { + finishedAt := completedAt.Time.UTC() + run.FinishedAt = &finishedAt + } + if state == operations.StatePartial || state == operations.StateFailed { + publicError := operations.ProjectPersonSweepFailure(peoplesweep.FailureClass(failureClass)) + run.Error = &publicError + } + if err := run.Validate(); err != nil { + return operations.Run{}, fmt.Errorf("validate person sweep operation run: %w", err) + } + return run, nil +} + +// listCardDAVOperationRuns projects CardDAV sync rows using only the durable +// columns allowed by the normalized operations contract. +func (s *Store) listCardDAVOperationRuns( + ctx context.Context, query operations.Query, fetchLimit int, +) ([]operations.Run, error) { + return s.listCardDAVOperationRunsFrom(ctx, s.db, query, fetchLimit) +} + +func (s *Store) listCardDAVOperationRunsFrom( + ctx context.Context, queryer contextRowsQuerier, query operations.Query, fetchLimit int, +) ([]operations.Run, error) { + if err := query.Validate(); err != nil { + return nil, fmt.Errorf("list operation runs: %w", err) + } + if fetchLimit < 1 || fetchLimit > query.Limit+1 { + return nil, errors.New("CardDAV operation fetch limit must be between one and query limit plus one") + } + + conditions := make([]string, 0, 2) + args := make([]any, 0, 4) + if len(query.States) > 0 { + conditions = append(conditions, cardDAVOperationStateCondition(query.States)) + } + if query.Position != nil { + condition, positionArgs, err := s.cardDAVOperationPositionCondition(*query.Position) + if err != nil { + return nil, fmt.Errorf("list operation runs: %w", err) + } + conditions = append(conditions, condition) + args = append(args, positionArgs...) + } + + statement := `SELECT ` + cardDAVOperationRunColumns + ` FROM carddav_sync_runs` + if len(conditions) > 0 { + statement += ` WHERE ` + strings.Join(conditions, ` AND `) + } + statement += ` ORDER BY started_at DESC, id DESC LIMIT ?` + args = append(args, fetchLimit) + + rows, err := queryer.QueryContext(ctx, statement, args...) + if err != nil { + return nil, fmt.Errorf("list CardDAV operation runs: %w", err) + } + defer func() { _ = rows.Close() }() + + runs := make([]operations.Run, 0, fetchLimit) + for rows.Next() { + run, scanErr := scanCardDAVOperationRun(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan CardDAV operation run: %w", scanErr) + } + runs = append(runs, run) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate CardDAV operation runs: %w", err) + } + return runs, nil +} + +func (s *Store) getCardDAVOperationRun( + ctx context.Context, id operations.StableID, +) (operations.Run, error) { + if err := id.Validate(); err != nil { + return operations.Run{}, fmt.Errorf("get operation run: %w", err) + } + if id.Kind() != operations.KindCardDAVSync { + return operations.Run{}, fmt.Errorf("get operation run: %w", ErrOperationRunNotFound) + } + runID, ok := id.Int64() + if !ok { + return operations.Run{}, fmt.Errorf("get operation run: %w", ErrOperationRunNotFound) + } + + run, err := scanCardDAVOperationRun(s.db.QueryRowContext(ctx, + `SELECT `+cardDAVOperationRunColumns+` FROM carddav_sync_runs WHERE id = ?`, runID)) + if errors.Is(err, sql.ErrNoRows) { + return operations.Run{}, fmt.Errorf("get operation run: %w", ErrOperationRunNotFound) + } + if err != nil { + return operations.Run{}, fmt.Errorf("get CardDAV operation run: %w", err) + } + return run, nil +} + +func (s *Store) cardDAVOperationLaneStatus( + ctx context.Context, +) (operations.LaneHistoryStatus, error) { + status := operations.LaneHistoryStatus{ + Kind: operations.KindCardDAVSync, + Lane: operations.LaneContacts, + HistoryAvailability: operations.HistoryAvailable, + } + err := s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + var err error + status.Active, err = cardDAVOperationStatusRun(ctx, tx, `state = 'running'`) + if err != nil { + return fmt.Errorf("read active CardDAV operation run: %w", err) + } + if s.operationHistoryStatusAfterActiveHook != nil { + s.operationHistoryStatusAfterActiveHook(string(operations.KindCardDAVSync)) + } + status.Latest, err = cardDAVOperationStatusRun(ctx, tx, `TRUE`) + if err != nil { + return fmt.Errorf("read latest CardDAV operation run: %w", err) + } + status.LatestSuccessful, err = cardDAVOperationStatusRun(ctx, tx, `state = 'succeeded'`) + if err != nil { + return fmt.Errorf("read latest successful CardDAV operation run: %w", err) + } + return nil + }) + if err != nil { + return operations.LaneHistoryStatus{}, fmt.Errorf("read CardDAV operation status: %w", err) + } + if err := status.Validate(); err != nil { + return operations.LaneHistoryStatus{}, fmt.Errorf("validate CardDAV operation status: %w", err) + } + return status, nil +} + +func cardDAVOperationStateCondition(states []operations.State) string { + conditions := make([]string, 0, len(states)) + for _, state := range states { + switch state { + case operations.StateRunning: + conditions = append(conditions, `state = 'running'`) + case operations.StateSucceeded: + conditions = append(conditions, `state = 'succeeded'`) + case operations.StatePartial: + conditions = append(conditions, `state = 'partial'`) + case operations.StateFailed: + conditions = append(conditions, `state = 'failed'`) + case operations.StateCancelled: + conditions = append(conditions, `state = 'cancelled'`) + case operations.StateQueued: + // CardDAV has no durable queued rows. + } + } + if len(conditions) == 0 { + return `FALSE` + } + return `(` + strings.Join(conditions, ` OR `) + `)` +} + +func (s *Store) cardDAVOperationPositionCondition( + position operations.Position, +) (string, []any, error) { + timestamp := s.operationTimestampParam(position.StartedAt) + // SQLite stores CardDAV run times at whole-second precision. When a cursor + // falls later within that second, every row at the stored second is older + // than the cursor regardless of kind or ID. Apply kind/ID tie-breaking only + // when the cursor itself is exactly representable by the CardDAV ledger. + if s.sqliteOperationPositionFinerThan(position.StartedAt, time.Second) { + return `started_at <= ?`, []any{timestamp}, nil + } + switch cmp.Compare(operations.KindCardDAVSync, position.ID.Kind()) { + case -1: + return `started_at < ?`, []any{timestamp}, nil + case 1: + return `started_at <= ?`, []any{timestamp}, nil + default: + positionID, ok := position.ID.Int64() + if !ok { + return "", nil, errors.New("CardDAV operation position requires a numeric CardDAV run ID") + } + return `(started_at < ? OR (started_at = ? AND id < ?))`, + []any{timestamp, timestamp, positionID}, nil + } +} + +func (s *Store) sqliteOperationPositionFinerThan( + value time.Time, precision time.Duration, +) bool { + return !s.IsPostgreSQL() && value.Nanosecond()%int(precision) != 0 +} + +func cardDAVOperationStatusQuery(condition string) string { + return `SELECT ` + cardDAVOperationRunColumns + ` FROM carddav_sync_runs WHERE ` + condition + + ` ORDER BY started_at DESC, id DESC LIMIT 1` +} + +func cardDAVOperationStatusRun( + ctx context.Context, queryer *loggedTx, condition string, +) (*operations.Run, error) { + run, err := scanCardDAVOperationRun(queryer.QueryRowContext(ctx, cardDAVOperationStatusQuery(condition))) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil //nolint:nilnil // No matching CardDAV run is a valid empty status. + } + if err != nil { + return nil, err + } + return &run, nil +} + +func scanCardDAVOperationRun(sc scanner) (operations.Run, error) { + var ( + runID int64 + durableTrigger string + durableState string + startedAt requiredTimestamp + finishedAt strictOperationNullableTimestamp + books int64 + created int64 + updated int64 + removed int64 + errorCode string + ) + if err := sc.Scan( + &runID, &durableTrigger, &durableState, &startedAt, &finishedAt, + &books, &created, &updated, &removed, &errorCode, + ); err != nil { + return operations.Run{}, err + } + + id, err := operations.NewInt64ID(operations.KindCardDAVSync, runID) + if err != nil { + return operations.Run{}, fmt.Errorf("CardDAV operation stable ID: %w", err) + } + trigger := operations.Trigger(durableTrigger) + if err := trigger.Validate(); err != nil { + return operations.Run{}, err + } + state := operations.State(durableState) + if err := state.Validate(); err != nil { + return operations.Run{}, err + } + run := operations.Run{ + ID: id, + Lane: operations.LaneContacts, + State: state, + Trigger: &trigger, + StartedAt: startedAt.Time.UTC(), + Counters: []operations.PublicCounter{ + {Name: operations.CounterBooks, Unit: operations.CounterUnitBooks, Value: books}, + {Name: operations.CounterCreated, Unit: operations.CounterUnitContacts, Value: created}, + {Name: operations.CounterUpdated, Unit: operations.CounterUnitContacts, Value: updated}, + {Name: operations.CounterRemoved, Unit: operations.CounterUnitContacts, Value: removed}, + }, + } + if finishedAt.Valid { + finished := finishedAt.Time.UTC() + run.FinishedAt = &finished + } + if state == operations.StatePartial || state == operations.StateFailed || state == operations.StateCancelled { + run.Error = operations.ProjectCardDAVFailure(errorCode) + } + if err := run.Validate(); err != nil { + return operations.Run{}, fmt.Errorf("validate CardDAV operation run: %w", err) + } + return run, nil +} + +// Kinds returns the closed set of operation histories backed by durable +// ledgers in this store. The slice is ordered and detached from package state. +func (s *Store) Kinds() []operations.Kind { + return slices.Clone(durableOperationKinds) +} + +// ListRuns reads every selected durable ledger inside one repeatable-read +// snapshot, then applies the normalized cross-kind comparator. It returns at +// most limit+1 rows so the HTTP layer can detect continuation without another +// count query. +func (s *Store) ListRuns( + ctx context.Context, query operations.Query, +) ([]operations.Run, error) { + if err := query.Validate(); err != nil { + return nil, fmt.Errorf("list operation runs: %w", err) + } + selected, err := selectedDurableOperationKinds(query.Kinds) + if err != nil { + return nil, err + } + fetchLimit := query.Limit + 1 + merged := make([]operations.Run, 0, len(selected)*fetchLimit) + err = s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + for _, kind := range selected { + var runs []operations.Run + var listErr error + switch kind { + case operations.KindCardDAVSync: + runs, listErr = s.listCardDAVOperationRunsFrom(ctx, tx, query, fetchLimit) + case operations.KindPersonSweep: + runs, listErr = s.listPersonSweepOperationRunsFrom(ctx, tx, query, fetchLimit) + case operations.KindSourceSync: + runs, listErr = s.listSourceOperationRunsFrom(ctx, tx, query, fetchLimit) + default: + return fmt.Errorf("list operation runs for %q: %w", kind, ErrOperationHistoryUnavailable) + } + if listErr != nil { + return fmt.Errorf("list operation runs for %q: %w", kind, listErr) + } + merged = append(merged, runs...) + if s.operationHistoryAfterAdapterReadHook != nil { + s.operationHistoryAfterAdapterReadHook(string(kind)) + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("list operation history snapshot: %w", err) + } + operations.SortRuns(merged) + if len(merged) > fetchLimit { + merged = merged[:fetchLimit] + } + return merged, nil +} + +func selectedDurableOperationKinds(requested []operations.Kind) ([]operations.Kind, error) { + if len(requested) == 0 { + return slices.Clone(durableOperationKinds), nil + } + for _, kind := range requested { + if !slices.Contains(durableOperationKinds, kind) { + return nil, fmt.Errorf("operation kind %q: %w", kind, ErrOperationHistoryUnavailable) + } + } + return slices.Clone(requested), nil +} + +// GetRun dispatches exclusively through the stable ID's validated kind/type +// pair; equal numeric values in different ledgers cannot cross-select. +func (s *Store) GetRun(ctx context.Context, id operations.StableID) (operations.Run, error) { + if err := id.Validate(); err != nil { + return operations.Run{}, fmt.Errorf("get operation run: %w", err) + } + switch id.Kind() { + case operations.KindCardDAVSync: + return s.getCardDAVOperationRun(ctx, id) + case operations.KindPersonSweep: + return s.getPersonSweepOperationRun(ctx, id) + case operations.KindSourceSync: + return s.getSourceOperationRun(ctx, id) + default: + return operations.Run{}, fmt.Errorf("get operation run for %q: %w", + id.Kind(), ErrOperationHistoryUnavailable) + } +} + +// LaneStatus reads only the requested durable ledger. Unavailable kinds are +// rejected explicitly; the API layer owns their stable unavailable metadata. +func (s *Store) LaneStatus( + ctx context.Context, kind operations.Kind, +) (operations.LaneHistoryStatus, error) { + if err := kind.Validate(); err != nil { + return operations.LaneHistoryStatus{}, fmt.Errorf("read operation lane status: %w", err) + } + switch kind { + case operations.KindCardDAVSync: + return s.cardDAVOperationLaneStatus(ctx) + case operations.KindPersonSweep: + return s.personSweepOperationLaneStatus(ctx) + case operations.KindSourceSync: + return s.sourceOperationLaneStatus(ctx) + default: + return operations.LaneHistoryStatus{}, fmt.Errorf("read operation lane status for %q: %w", + kind, ErrOperationHistoryUnavailable) + } +} diff --git a/internal/store/operation_runs_export_test.go b/internal/store/operation_runs_export_test.go new file mode 100644 index 000000000..4cfe6b659 --- /dev/null +++ b/internal/store/operation_runs_export_test.go @@ -0,0 +1,115 @@ +package store + +import ( + "context" + + "go.kenn.io/msgvault/internal/operations" +) + +//nolint:revive // Test exports mirror the Store receiver-first API they expose. +func ListSourceOperationRunsForTest( + s *Store, ctx context.Context, query operations.Query, +) ([]operations.Run, error) { + return s.listSourceOperationRuns(ctx, query, query.Limit) +} + +//nolint:revive // Test exports mirror the Store receiver-first API they expose. +func ListSourceOperationRunsWithFetchLimitForTest( + s *Store, ctx context.Context, query operations.Query, fetchLimit int, +) ([]operations.Run, error) { + return s.listSourceOperationRuns(ctx, query, fetchLimit) +} + +//nolint:revive // Test exports mirror the Store receiver-first API they expose. +func GetSourceOperationRunForTest( + s *Store, ctx context.Context, id operations.StableID, +) (operations.Run, error) { + return s.getSourceOperationRun(ctx, id) +} + +//nolint:revive // Test exports mirror the Store receiver-first API they expose. +func SourceOperationLaneStatusForTest( + s *Store, ctx context.Context, +) (operations.LaneHistoryStatus, error) { + return s.sourceOperationLaneStatus(ctx) +} + +//nolint:revive // Test exports mirror the Store receiver-first API they expose. +func ListPersonSweepOperationRunsForTest( + s *Store, ctx context.Context, query operations.Query, +) ([]operations.Run, error) { + return s.listPersonSweepOperationRuns(ctx, query, query.Limit) +} + +//nolint:revive // Test exports mirror the Store receiver-first API they expose. +func ListPersonSweepOperationRunsWithFetchLimitForTest( + s *Store, ctx context.Context, query operations.Query, fetchLimit int, +) ([]operations.Run, error) { + return s.listPersonSweepOperationRuns(ctx, query, fetchLimit) +} + +//nolint:revive // Test exports mirror the Store receiver-first API they expose. +func GetPersonSweepOperationRunForTest( + s *Store, ctx context.Context, id operations.StableID, +) (operations.Run, error) { + return s.getPersonSweepOperationRun(ctx, id) +} + +//nolint:revive // Test exports mirror the Store receiver-first API they expose. +func PersonSweepOperationLaneStatusForTest( + s *Store, ctx context.Context, +) (operations.LaneHistoryStatus, error) { + return s.personSweepOperationLaneStatus(ctx) +} + +//nolint:revive // Test exports mirror the Store receiver-first API they expose. +func ListCardDAVOperationRunsForTest( + s *Store, ctx context.Context, query operations.Query, +) ([]operations.Run, error) { + return s.listCardDAVOperationRuns(ctx, query, query.Limit) +} + +//nolint:revive // Test exports mirror the Store receiver-first API they expose. +func ListCardDAVOperationRunsWithFetchLimitForTest( + s *Store, ctx context.Context, query operations.Query, fetchLimit int, +) ([]operations.Run, error) { + return s.listCardDAVOperationRuns(ctx, query, fetchLimit) +} + +//nolint:revive // Test exports mirror the Store receiver-first API they expose. +func GetCardDAVOperationRunForTest( + s *Store, ctx context.Context, id operations.StableID, +) (operations.Run, error) { + return s.getCardDAVOperationRun(ctx, id) +} + +//nolint:revive // Test exports mirror the Store receiver-first API they expose. +func CardDAVOperationLaneStatusForTest( + s *Store, ctx context.Context, +) (operations.LaneHistoryStatus, error) { + return s.cardDAVOperationLaneStatus(ctx) +} + +func SetOperationHistoryAfterAdapterReadHookForTest( + s *Store, hook func(operations.Kind), +) { + if hook == nil { + s.operationHistoryAfterAdapterReadHook = nil + return + } + s.operationHistoryAfterAdapterReadHook = func(kind string) { + hook(operations.Kind(kind)) + } +} + +func SetOperationHistoryStatusAfterActiveHookForTest( + s *Store, hook func(operations.Kind), +) { + if hook == nil { + s.operationHistoryStatusAfterActiveHook = nil + return + } + s.operationHistoryStatusAfterActiveHook = func(kind string) { + hook(operations.Kind(kind)) + } +} diff --git a/internal/store/operation_runs_plan_test.go b/internal/store/operation_runs_plan_test.go new file mode 100644 index 000000000..c818856fe --- /dev/null +++ b/internal/store/operation_runs_plan_test.go @@ -0,0 +1,70 @@ +package store + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// This catches a lane status query that scans every historical run and sorts +// it in a temporary b-tree. sync_runs is never pruned, so the active and +// latest-successful lookups must walk a status-filtered index in run order. +func TestOperationLaneStatusQueriesUseStatusIndexesSQLite(t *testing.T) { + st, err := OpenForTest(filepath.Join(t.TempDir(), "operation-status-plan.db")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, st.Close()) }) + require.NoError(t, st.InitSchema()) + collation := st.bytewiseTextCollation() + + tests := []struct { + name string + query string + wantIndex string + }{ + { + name: "active source run", + query: sourceOperationStatusQuery(`status = 'running'`), + wantIndex: "idx_sync_runs_operations_running", + }, + { + name: "latest successful source run", + query: sourceOperationStatusQuery(`status = 'completed' AND errors_count = 0`), + wantIndex: "idx_sync_runs_operations_succeeded", + }, + { + name: "active person sweep run", + query: personSweepOperationStatusQuery( + personSweepOperationRunColumns(collation), collation, `r.status = 'running'`), + wantIndex: "idx_person_sweep_runs_operations_running", + }, + { + name: "latest successful person sweep run", + query: personSweepOperationStatusQuery( + personSweepOperationRunColumns(collation), collation, `r.status = 'succeeded'`), + wantIndex: "idx_person_sweep_runs_operations_succeeded", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + rows, err := st.db.QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+test.query) + require.NoError(err) + defer func() { require.NoError(rows.Close()) }() + details := make([]string, 0) + for rows.Next() { + var id, parent, unused int + var detail string + require.NoError(rows.Scan(&id, &parent, &unused, &detail)) + details = append(details, detail) + } + require.NoError(rows.Err()) + plan := strings.Join(details, "\n") + assert.NotContains(plan, "USE TEMP B-TREE") + assert.Contains(plan, "USING INDEX "+test.wantIndex) + }) + } +} diff --git a/internal/store/operation_runs_schema_test.go b/internal/store/operation_runs_schema_test.go new file mode 100644 index 000000000..8d59b5dec --- /dev/null +++ b/internal/store/operation_runs_schema_test.go @@ -0,0 +1,173 @@ +package store_test + +import ( + "database/sql" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/testutil" +) + +func TestOperationRunOrderIndexes(t *testing.T) { + st := testutil.NewTestStore(t) + + type expectedIndex struct { + table string + columns []string + } + want := map[string]expectedIndex{ + "idx_sync_runs_operations_order": {table: "sync_runs", columns: []string{"started_at", "id"}}, + "idx_person_sweep_runs_operations_order": {table: "person_sweep_runs", columns: []string{"started_at", "id"}}, + "idx_carddav_sync_runs_operations_order": {table: "carddav_sync_runs", columns: []string{"started_at", "id"}}, + } + for indexName, expected := range want { + t.Run(indexName, func(t *testing.T) { + if st.IsPostgreSQL() { + assertPostgresDescendingIndex(t, st.DB(), expected.table, indexName, expected.columns) + return + } + assertSQLiteDescendingIndex(t, st.DB(), expected.table, indexName, expected.columns) + }) + } +} + +func TestOperationLaneStatusIndexesFilterOnStatus(t *testing.T) { + st := testutil.NewTestStore(t) + tests := []struct { + index string + sqlite string + postgres string + }{ + { + index: "idx_sync_runs_operations_running", + sqlite: "WHERE status = 'running'", + postgres: "WHERE (status = 'running'::text)", + }, + { + index: "idx_sync_runs_operations_succeeded", + sqlite: "WHERE status = 'completed' AND errors_count = 0", + postgres: "WHERE ((status = 'completed'::text) AND (errors_count = 0))", + }, + { + index: "idx_person_sweep_runs_operations_running", + sqlite: "WHERE status = 'running'", + postgres: "WHERE (status = 'running'::text)", + }, + { + index: "idx_person_sweep_runs_operations_succeeded", + sqlite: "WHERE status = 'succeeded'", + postgres: "WHERE (status = 'succeeded'::text)", + }, + } + for _, test := range tests { + t.Run(test.index, func(t *testing.T) { + if st.IsPostgreSQL() { + assertPostgresIndexDefinitionContains(t, st.DB(), test.index, test.postgres) + return + } + assertSQLiteIndexDefinitionContains(t, st.DB(), test.index, test.sqlite) + }) + } +} + +func TestPersonSweepOperationIndexesOwnBytewiseOrdering(t *testing.T) { + st := testutil.NewTestStore(t) + if st.IsPostgreSQL() { + assertPostgresIndexDefinitionContains(t, st.DB(), + "idx_person_sweep_runs_operations_bytewise_order", `id COLLATE "C" DESC`) + assertPostgresIndexDefinitionContains(t, st.DB(), + "idx_person_sweep_attempts_operations_failure", `COALESCE(completed_at, started_at)`) + assertPostgresIndexDefinitionContains(t, st.DB(), + "idx_person_sweep_attempts_operations_failure", `id COLLATE "C" DESC`) + return + } + assertSQLiteIndexDefinitionContains(t, st.DB(), + "idx_person_sweep_runs_operations_bytewise_order", "id COLLATE BINARY DESC") + assertSQLiteIndexDefinitionContains(t, st.DB(), + "idx_person_sweep_attempts_operations_failure", "COALESCE(completed_at, started_at) DESC") + assertSQLiteIndexDefinitionContains(t, st.DB(), + "idx_person_sweep_attempts_operations_failure", "id COLLATE BINARY DESC") +} + +func assertPostgresIndexDefinitionContains( + t *testing.T, db queryRower, indexName, fragment string, +) { + t.Helper() + var definition string + err := db.QueryRow(`SELECT pg_get_indexdef(indexname::regclass) + FROM pg_indexes WHERE schemaname = current_schema() AND indexname = $1`, indexName).Scan(&definition) + require.NoError(t, err) + assert.Contains(t, definition, fragment) +} + +func assertSQLiteIndexDefinitionContains( + t *testing.T, db *sql.DB, indexName, fragment string, +) { + t.Helper() + var definition string + err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`, indexName).Scan(&definition) + require.NoError(t, err) + assert.Contains(t, definition, fragment) +} + +type queryRower interface { + QueryRow(query string, args ...any) *sql.Row +} + +func assertPostgresDescendingIndex(t *testing.T, db queryRower, tableName, indexName string, columns []string) { + t.Helper() + var actualTable, definition string + err := db.QueryRow(`SELECT tablename, pg_get_indexdef(indexname::regclass) + FROM pg_indexes + WHERE schemaname = current_schema() AND indexname = $1`, indexName).Scan(&actualTable, &definition) + require.NoError(t, err) + assert.Equal(t, tableName, actualTable) + assert.Contains(t, definition, fmt.Sprintf("(%s DESC)", strings.Join(columns, " DESC, "))) +} + +func assertSQLiteDescendingIndex(t *testing.T, db *sql.DB, tableName, indexName string, columns []string) { + t.Helper() + indexRows, err := db.Query("PRAGMA index_list(" + tableName + ")") + require.NoError(t, err) + defer func() { require.NoError(t, indexRows.Close()) }() + owned := false + for indexRows.Next() { + var sequence, unique, partial int + var name, origin string + require.NoError(t, indexRows.Scan(&sequence, &name, &unique, &origin, &partial)) + owned = owned || name == indexName + } + require.NoError(t, indexRows.Err()) + require.True(t, owned, "%s must own %s", tableName, indexName) + + type indexColumn struct { + name string + desc int + } + rows, err := db.Query("PRAGMA index_xinfo(" + indexName + ")") + require.NoError(t, err) + defer func() { require.NoError(t, rows.Close()) }() + + got := make([]indexColumn, 0, len(columns)) + for rows.Next() { + var seqno, cid, descending, key int + var name, collation any + require.NoError(t, rows.Scan(&seqno, &cid, &name, &descending, &collation, &key)) + if key == 0 { + continue + } + columnName, ok := name.(string) + require.True(t, ok) + got = append(got, indexColumn{name: columnName, desc: descending}) + } + require.NoError(t, rows.Err()) + + want := make([]indexColumn, 0, len(columns)) + for _, column := range columns { + want = append(want, indexColumn{name: column, desc: 1}) + } + assert.Equal(t, want, got) +} diff --git a/internal/store/operation_runs_test.go b/internal/store/operation_runs_test.go new file mode 100644 index 000000000..48e597da8 --- /dev/null +++ b/internal/store/operation_runs_test.go @@ -0,0 +1,1156 @@ +package store_test + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/operations" + "go.kenn.io/msgvault/internal/peoplesweep" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +func TestOperationRunsSourceProjectsStatesCountersAndFilters(t *testing.T) { + newAssertions := assert.New + newRequirements := require.New + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + source := createOperationSource(t, st, "state-source@example.invalid") + base := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + seed sourceOperationRunSeed + wantState operations.State + wantError *operations.PublicError + }{ + { + name: "running", + seed: sourceOperationRunSeed{startedAt: base, state: "running", processed: 3}, + wantState: operations.StateRunning, + }, + { + name: "completed cleanly", + seed: sourceOperationRunSeed{ + startedAt: base.Add(time.Minute), state: "completed", processed: 8, + added: 2, updated: 3, + }, + wantState: operations.StateSucceeded, + }, + { + name: "completed with item errors", + seed: sourceOperationRunSeed{ + startedAt: base.Add(2 * time.Minute), state: "completed", processed: 8, + added: 2, updated: 3, itemErrors: 1, + }, + wantState: operations.StatePartial, + }, + { + name: "failed after adding", + seed: sourceOperationRunSeed{ + startedAt: base.Add(3 * time.Minute), state: "failed", processed: 5, added: 1, + }, + wantState: operations.StatePartial, + wantError: sourceOperationPublicError(), + }, + { + name: "failed after updating", + seed: sourceOperationRunSeed{ + startedAt: base.Add(4 * time.Minute), state: "failed", processed: 5, updated: 1, + }, + wantState: operations.StatePartial, + wantError: sourceOperationPublicError(), + }, + { + name: "failed after processed only", + seed: sourceOperationRunSeed{ + startedAt: base.Add(5 * time.Minute), state: "failed", processed: 5, + }, + wantState: operations.StateFailed, + wantError: sourceOperationPublicError(), + }, + { + name: "exact legacy cancelled", + seed: sourceOperationRunSeed{ + startedAt: base.Add(6 * time.Minute), state: "cancelled", processed: 1, + }, + wantState: operations.StateCancelled, + }, + } + + idsByState := make(map[operations.State][]int64) + allIDs := make([]int64, 0, len(tests)) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := newRequirements(t) + assert := newAssertions(t) + id := insertSourceOperationRun(t, st, source.ID, test.seed) + allIDs = append(allIDs, id) + idsByState[test.wantState] = append(idsByState[test.wantState], id) + + got, err := store.GetSourceOperationRunForTest( + st, t.Context(), mustSourceOperationID(t, id)) + require.NoError(err) + assert.Equal(test.wantState, got.State) + assert.Equal(test.wantError, got.Error) + assert.Nil(got.Trigger) + assert.Equal([]operations.PublicCounter{ + {Name: operations.CounterProcessed, Unit: operations.CounterUnitMessages, Value: test.seed.processed}, + {Name: operations.CounterAdded, Unit: operations.CounterUnitMessages, Value: test.seed.added}, + {Name: operations.CounterUpdated, Unit: operations.CounterUnitMessages, Value: test.seed.updated}, + {Name: operations.CounterItemErrors, Unit: operations.CounterUnitMessages, Value: test.seed.itemErrors}, + }, got.Counters) + require.NoError(got.Validate()) + }) + } + + runs, err := store.ListSourceOperationRunsForTest( + st, t.Context(), operations.Query{Limit: 100}) + require.NoError(err) + require.Len(runs, len(allIDs)) + for index, run := range runs { + assert.Equal(allIDs[len(allIDs)-1-index], sourceOperationIntID(t, run.ID)) + } + + for _, state := range []operations.State{ + operations.StateRunning, + operations.StateSucceeded, + operations.StatePartial, + operations.StateFailed, + operations.StateCancelled, + } { + filtered, filterErr := store.ListSourceOperationRunsForTest(st, t.Context(), operations.Query{ + States: []operations.State{state}, Limit: 100, + }) + require.NoError(filterErr, state) + gotIDs := make([]int64, 0, len(filtered)) + for _, run := range filtered { + gotIDs = append(gotIDs, sourceOperationIntID(t, run.ID)) + assert.Equal(state, run.State) + } + wantIDs := append([]int64(nil), idsByState[state]...) + reverseInt64s(wantIDs) + assert.Equal(wantIDs, gotIDs, state) + } + + queued, err := store.ListSourceOperationRunsForTest(st, t.Context(), operations.Query{ + States: []operations.State{operations.StateQueued}, Limit: 100, + }) + require.NoError(err) + assert.Empty(queued) +} + +func TestOperationRunsPersonSweepProjectsStatesTriggersCountersAndFailures(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + base := time.Date(2026, 8, 28, 15, 0, 0, 123_000_000, time.UTC) + classes := []peoplesweep.FailureClass{ + peoplesweep.FailurePolicy, peoplesweep.FailureBudget, peoplesweep.FailureLeaseLost, + peoplesweep.FailureRateLimited, peoplesweep.FailureTimeout, + peoplesweep.FailureProviderHTTP, peoplesweep.FailureInvalidOutput, + peoplesweep.FailureArchiveGap, peoplesweep.FailureInternal, "", + } + wantCodes := []operations.PublicErrorCode{ + operations.PublicErrorPolicy, operations.PublicErrorBudget, operations.PublicErrorLeaseLost, + operations.PublicErrorRateLimited, operations.PublicErrorTimeout, + operations.PublicErrorProviderHTTP, operations.PublicErrorInvalidOutput, + operations.PublicErrorArchiveGap, operations.PublicErrorInternal, + operations.PublicErrorPersonSweepFailed, + } + + running := insertPersonSweepOperationRun(t, st, personSweepOperationSeed{ + id: "run-running", trigger: "scheduled", state: "running", startedAt: base, + attempted: 2, succeeded: 1, failed: 1, projectedWrites: 3, + }) + succeeded := insertPersonSweepOperationRun(t, st, personSweepOperationSeed{ + id: "run-succeeded", trigger: "manual", state: "succeeded", startedAt: base.Add(time.Millisecond), + attempted: 4, succeeded: 4, projectedWrites: 5, + }) + + for index, class := range classes { + id := fmt.Sprintf("run-failure-%02d", index) + state := "failed" + if index%2 == 0 { + state = "partial" + } + insertPersonSweepOperationRun(t, st, personSweepOperationSeed{ + id: id, trigger: "manual", state: state, + startedAt: base.Add(time.Duration(index+2) * time.Millisecond), + attempted: 7, succeeded: 2, failed: 5, projectedWrites: 6, + }) + if class != "" { + insertPersonSweepOperationAttempt(t, st, id, fmt.Sprintf("attempt-%02d", index), class, + base.Add(time.Duration(index+2)*time.Millisecond)) + } + run, err := store.GetPersonSweepOperationRunForTest( + st, t.Context(), mustPersonSweepOperationID(t, id)) + require.NoError(err) + assert.Equal(operations.State(state), run.State) + require.NotNil(run.Error) + assert.Equal(wantCodes[index], run.Error.Code) + assert.Equal(operations.TriggerManual, *run.Trigger) + assert.Equal([]operations.PublicCounter{ + {Name: operations.CounterAttempted, Unit: operations.CounterUnitPeople, Value: 7}, + {Name: operations.CounterSucceeded, Unit: operations.CounterUnitPeople, Value: 2}, + {Name: operations.CounterFailed, Unit: operations.CounterUnitPeople, Value: 5}, + {Name: operations.CounterProjectedWrites, Unit: operations.CounterUnitWrites, Value: 6}, + }, run.Counters) + require.NoError(run.Validate()) + } + + runningRun, err := store.GetPersonSweepOperationRunForTest( + st, t.Context(), mustPersonSweepOperationID(t, running)) + require.NoError(err) + assert.Equal(operations.StateRunning, runningRun.State) + assert.Equal(operations.TriggerScheduled, *runningRun.Trigger) + assert.Nil(runningRun.FinishedAt) + assert.Nil(runningRun.Error) + + succeededRun, err := store.GetPersonSweepOperationRunForTest( + st, t.Context(), mustPersonSweepOperationID(t, succeeded)) + require.NoError(err) + assert.Equal(operations.StateSucceeded, succeededRun.State) + assert.Equal(operations.TriggerManual, *succeededRun.Trigger) + require.NotNil(succeededRun.FinishedAt) + assert.Equal(succeededRun.StartedAt.Add(time.Second), *succeededRun.FinishedAt) + assert.Nil(succeededRun.Error) + + for _, state := range []operations.State{ + operations.StateRunning, operations.StateSucceeded, + operations.StatePartial, operations.StateFailed, + } { + runs, listErr := store.ListPersonSweepOperationRunsForTest(st, t.Context(), operations.Query{ + States: []operations.State{state}, Limit: 100, + }) + require.NoError(listErr) + for _, run := range runs { + assert.Equal(state, run.State) + } + } + queued, err := store.ListPersonSweepOperationRunsForTest(st, t.Context(), operations.Query{ + States: []operations.State{operations.StateCancelled, operations.StateQueued}, Limit: 100, + }) + require.NoError(err) + assert.Empty(queued) +} + +func TestOperationRunsPersonSweepPagesMillisecondsAndTextIDs(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + instant := time.Date(2026, 8, 28, 16, 0, 0, 987_000_000, time.UTC) + for _, id := range []string{"run-Z", "run-ä", "run-a"} { + insertPersonSweepOperationRun(t, st, personSweepOperationSeed{ + id: id, trigger: "manual", state: "succeeded", startedAt: instant, + }) + } + var got []string + var position *operations.Position + for { + runs, err := store.ListPersonSweepOperationRunsForTest(st, t.Context(), operations.Query{ + Position: position, Limit: 1, + }) + require.NoError(err) + if len(runs) == 0 { + break + } + require.Len(runs, 1) + id, ok := runs[0].ID.Text() + require.True(ok) + got = append(got, id) + position = &operations.Position{StartedAt: runs[0].StartedAt, ID: runs[0].ID} + } + assert.Equal([]string{"run-ä", "run-a", "run-Z"}, got, + "public ordering is bytewise and cannot follow a locale collation") + + sourceID, err := operations.NewInt64ID(operations.KindSourceSync, 99) + require.NoError(err) + afterSource, err := store.ListPersonSweepOperationRunsForTest(st, t.Context(), operations.Query{ + Position: &operations.Position{StartedAt: instant, ID: sourceID}, Limit: 10, + }) + require.NoError(err) + assert.Empty(afterSource, "person_sweep sorts before source_sync at the same instant") +} + +func TestOperationRunsPersonSweepUsesNewestNonemptyFailureClass(t *testing.T) { + st := testutil.NewTestStore(t) + startedAt := time.Date(2026, 8, 28, 16, 30, 0, 0, time.UTC) + runID := insertPersonSweepOperationRun(t, st, personSweepOperationSeed{ + id: "run-failure-order", trigger: "manual", state: "failed", startedAt: startedAt, + attempted: 4, failed: 4, + }) + insertPersonSweepOperationAttempt(t, st, runID, "attempt-old", + peoplesweep.FailureTimeout, startedAt) + insertPersonSweepOperationAttempt(t, st, runID, "attempt-newer-empty", + "", startedAt.Add(time.Second)) + insertPersonSweepOperationAttempt(t, st, runID, "attempt-Z", + peoplesweep.FailureInvalidOutput, startedAt.Add(2*time.Second)) + insertPersonSweepOperationAttempt(t, st, runID, "attempt-ä", + peoplesweep.FailurePolicy, startedAt.Add(2*time.Second)) + + run, err := store.GetPersonSweepOperationRunForTest( + st, t.Context(), mustPersonSweepOperationID(t, runID)) + require.NoError(t, err) + require.NotNil(t, run.Error) + assert.Equal(t, operations.PublicErrorPolicy, run.Error.Code, + "same-time failure ties use bytewise descending attempt ID") +} + +func TestOperationRunsPersonSweepListDetailStatusAndPrivacy(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + base := time.Date(2026, 8, 28, 17, 0, 0, 456_000_000, time.UTC) + successID := insertPersonSweepOperationRun(t, st, personSweepOperationSeed{ + id: "private-success", trigger: "scheduled", state: "succeeded", startedAt: base, + }) + failedID := insertPersonSweepOperationRun(t, st, personSweepOperationSeed{ + id: "private-failed", trigger: "manual", state: "failed", startedAt: base.Add(time.Millisecond), + attempted: 1, failed: 1, + }) + insertPersonSweepOperationAttempt(t, st, failedID, "private-attempt-id", + peoplesweep.FailureTimeout, base.Add(time.Millisecond)) + runningID := insertPersonSweepOperationRun(t, st, personSweepOperationSeed{ + id: "private-running", trigger: "scheduled", state: "running", startedAt: base.Add(2 * time.Millisecond), + }) + + runs, err := store.ListPersonSweepOperationRunsForTest(st, t.Context(), operations.Query{Limit: 10}) + require.NoError(err) + require.Len(runs, 3) + detail, err := store.GetPersonSweepOperationRunForTest(st, t.Context(), mustPersonSweepOperationID(t, failedID)) + require.NoError(err) + assert.Equal(runs[1], detail) + _, err = store.GetPersonSweepOperationRunForTest(st, t.Context(), mustPersonSweepOperationID(t, "missing")) + require.ErrorIs(err, store.ErrOperationRunNotFound) + + status, err := store.PersonSweepOperationLaneStatusForTest(st, t.Context()) + require.NoError(err) + require.NotNil(status.Active) + require.NotNil(status.Latest) + require.NotNil(status.LatestSuccessful) + assert.Equal(runningID, personSweepOperationTextID(t, status.Active.ID)) + assert.Equal(runningID, personSweepOperationTextID(t, status.Latest.ID)) + assert.Equal(successID, personSweepOperationTextID(t, status.LatestSuccessful.ID)) + require.NoError(status.Validate()) + + projected, err := json.Marshal(struct { //nolint:musttag // Marshal the public operation projection to scan for private fields. + Runs []operations.Run `json:"runs"` + Detail operations.Run `json:"detail"` + Status operations.LaneHistoryStatus `json:"status"` + }{runs, detail, status}) + require.NoError(err) + for _, marker := range []string{ + "private-person-id", "private-cursor-envelope", "private-program-fingerprint", + "private-catalog-fingerprint", "private-provider-fingerprint", "private-evidence", + "private-attempt-id", "private-provider-request", "private-model", "987654321", + } { + assert.NotContains(string(projected), marker) + } + + _, err = st.DB().ExecContext(t.Context(), `DROP TABLE person_sweep_batches`) + require.NoError(err) + withoutBatches, err := store.ListPersonSweepOperationRunsForTest(st, t.Context(), operations.Query{Limit: 10}) + require.NoError(err, "projection must not materialize provider batches") + assert.Equal(runs, withoutBatches) +} + +type personSweepOperationSeed struct { + id string + trigger string + state string + startedAt time.Time + attempted int64 + succeeded int64 + failed int64 + projectedWrites int64 +} + +func insertPersonSweepOperationRun(t *testing.T, st *store.Store, seed personSweepOperationSeed) string { + t.Helper() + var completedAt any + if seed.state != "running" { + completedAt = personSweepTimestampParam(st, seed.startedAt.Add(time.Second)) + } + _, err := st.DB().ExecContext(t.Context(), st.Rebind(` + INSERT INTO person_sweep_runs ( + id, kind, mode, status, program_fingerprint, catalog_fingerprint, + provider_fingerprint, attempt_count, success_count, failure_count, + projected_write_count, actual_requests, actual_input_tokens, + actual_output_tokens, actual_cost_micro_usd, started_at, completed_at + ) VALUES (?, ?, 'incremental', ?, ?, ?, ?, ?, ?, ?, ?, 77, 987654321, 88, 99, ?, ?)`), + seed.id, seed.trigger, seed.state, "private-program-fingerprint", + "private-catalog-fingerprint", "private-provider-fingerprint", seed.attempted, + seed.succeeded, seed.failed, seed.projectedWrites, + personSweepTimestampParam(st, seed.startedAt), completedAt) + require.NoError(t, err) + return seed.id +} + +func insertPersonSweepOperationAttempt( + t *testing.T, st *store.Store, runID, attemptID string, + class peoplesweep.FailureClass, startedAt time.Time, +) { + t.Helper() + var personID int64 + err := st.DB().QueryRowContext(t.Context(), st.Rebind(` + INSERT INTO persons (vcard_uid, display_name) VALUES (?, 'private-evidence') RETURNING id`), + "private-person-id-"+attemptID).Scan(&personID) + require.NoError(t, err) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(` + INSERT INTO person_sweep_attempts ( + id, run_id, person_id, lease_fence, mode, status, failure_class, + cursor_envelope_json, envelope_hash, program_fingerprint, catalog_fingerprint, + provider_fingerprint, generation_key, provider_request_id, input_tokens, + output_tokens, estimated_cost_micro_usd, started_at, completed_at + ) VALUES (?, ?, ?, 1, 'incremental', 'failed', ?, ?, ?, ?, ?, ?, ?, ?, 987654321, 88, 99, ?, ?)`), + attemptID, runID, personID, class, `[{"marker":"private-cursor-envelope"}]`, + "private-envelope-hash", "private-program-fingerprint", "private-catalog-fingerprint", + "private-provider-fingerprint", "private-model", "private-provider-request", + personSweepTimestampParam(st, startedAt), personSweepTimestampParam(st, startedAt.Add(time.Second))) + require.NoError(t, err) +} + +func personSweepTimestampParam(st *store.Store, value time.Time) any { + if st.IsPostgreSQL() { + return value.UTC() + } + return value.UTC().Format("2006-01-02 15:04:05.000") +} + +func mustPersonSweepOperationID(t *testing.T, value string) operations.StableID { + t.Helper() + id, err := operations.NewTextID(operations.KindPersonSweep, value) + require.NoError(t, err) + return id +} + +func personSweepOperationTextID(t *testing.T, id operations.StableID) string { + t.Helper() + value, ok := id.Text() + require.True(t, ok) + return value +} + +func TestOperationRunsSourcePagesWholeSecondExactly(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + source := createOperationSource(t, st, "paging-source@example.invalid") + instant := time.Date(2026, 8, 28, 12, 34, 56, 0, time.UTC) + lowerID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: instant, state: "completed", processed: 1, + }) + higherID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: instant, state: "completed", processed: 2, + }) + require.Greater(higherID, lowerID) + + first, err := store.ListSourceOperationRunsForTest( + st, t.Context(), operations.Query{Limit: 1}) + require.NoError(err) + require.Len(first, 1) + assert.Equal(higherID, sourceOperationIntID(t, first[0].ID)) + + position := &operations.Position{StartedAt: first[0].StartedAt, ID: first[0].ID} + second, err := store.ListSourceOperationRunsForTest( + st, t.Context(), operations.Query{Position: position, Limit: 10}) + require.NoError(err) + require.Len(second, 1) + assert.Equal(lowerID, sourceOperationIntID(t, second[0].ID)) + + lastPosition := &operations.Position{StartedAt: second[0].StartedAt, ID: second[0].ID} + afterLast, err := store.ListSourceOperationRunsForTest( + st, t.Context(), operations.Query{Position: lastPosition, Limit: 10}) + require.NoError(err) + assert.Empty(afterLast) + + cardDAVCursor, err := operations.NewInt64ID(operations.KindCardDAVSync, 99) + require.NoError(err) + afterEarlierKind, err := store.ListSourceOperationRunsForTest(st, t.Context(), operations.Query{ + Position: &operations.Position{StartedAt: instant, ID: cardDAVCursor}, Limit: 10, + }) + require.NoError(err) + require.Len(afterEarlierKind, 2) + assert.Equal([]int64{higherID, lowerID}, []int64{ + sourceOperationIntID(t, afterEarlierKind[0].ID), + sourceOperationIntID(t, afterEarlierKind[1].ID), + }) +} + +func TestOperationRunsSourceListDetailNotFoundAndStatus(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + emptyStatus, err := store.SourceOperationLaneStatusForTest(st, t.Context()) + require.NoError(err) + assert.Equal(operations.LaneHistoryStatus{ + Kind: operations.KindSourceSync, + Lane: operations.LaneMessages, + HistoryAvailability: operations.HistoryAvailable, + }, emptyStatus) + + source := createOperationSource(t, st, "status-source@example.invalid") + base := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC) + succeededID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: base, state: "completed", processed: 3, added: 1, + }) + failedID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: base.Add(time.Minute), state: "failed", processed: 2, + }) + runningID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: base.Add(2 * time.Minute), state: "running", processed: 1, + }) + + limited, err := store.ListSourceOperationRunsForTest( + st, t.Context(), operations.Query{Limit: 2}) + require.NoError(err) + require.Len(limited, 2) + assert.Equal([]int64{runningID, failedID}, []int64{ + sourceOperationIntID(t, limited[0].ID), sourceOperationIntID(t, limited[1].ID), + }) + + detail, err := store.GetSourceOperationRunForTest(st, t.Context(), limited[1].ID) + require.NoError(err) + assert.Equal(limited[1], detail) + + _, err = store.GetSourceOperationRunForTest( + st, t.Context(), mustSourceOperationID(t, runningID+1000)) + require.ErrorIs(err, store.ErrOperationRunNotFound) + _, err = store.ListSourceOperationRunsForTest( + st, t.Context(), operations.Query{Limit: 0}) + require.Error(err) + + status, err := store.SourceOperationLaneStatusForTest(st, t.Context()) + require.NoError(err) + require.NotNil(status.Active) + require.NotNil(status.Latest) + require.NotNil(status.LatestSuccessful) + assert.Equal(runningID, sourceOperationIntID(t, status.Active.ID)) + assert.Equal(runningID, sourceOperationIntID(t, status.Latest.ID)) + assert.Equal(succeededID, sourceOperationIntID(t, status.LatestSuccessful.ID)) + require.NoError(status.Validate()) +} + +func TestOperationRunsSourceDoesNotHydratePrivateRows(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + sourceMarker := "private-source-identifier@example.invalid" + errorMarker := "private-run-error-marker" + beforeMarker := "private-provider-cursor-before" + afterMarker := "private-provider-cursor-after" + itemIDMarker := "private-source-message-id" + itemErrorMarker := "private-item-error-detail" + source := createOperationSource(t, st, sourceMarker) + id := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: time.Date(2026, 8, 28, 9, 0, 0, 0, time.UTC), + state: "failed", + processed: 1, + errorMessage: errorMarker, + cursorBefore: beforeMarker, + cursorAfter: afterMarker, + }) + _, err := st.DB().ExecContext(t.Context(), st.Rebind(` + INSERT INTO sync_run_items ( + sync_run_id, source_message_id, phase, status, error_kind, error_message + ) VALUES (?, ?, 'fetch', 'error', 'synthetic', ?)`), id, itemIDMarker, itemErrorMarker) + require.NoError(err) + + runs, err := store.ListSourceOperationRunsForTest( + st, t.Context(), operations.Query{Limit: 10}) + require.NoError(err) + require.Len(runs, 1) + detail, err := store.GetSourceOperationRunForTest( + st, t.Context(), mustSourceOperationID(t, id)) + require.NoError(err) + status, err := store.SourceOperationLaneStatusForTest(st, t.Context()) + require.NoError(err) + + projected, err := json.Marshal(struct { //nolint:musttag // Marshal the public operation projection to scan for private fields. + Runs []operations.Run `json:"runs"` + Detail operations.Run `json:"detail"` + Status operations.LaneHistoryStatus `json:"status"` + }{Runs: runs, Detail: detail, Status: status}) + require.NoError(err) + for _, marker := range []string{ + sourceMarker, errorMarker, beforeMarker, afterMarker, itemIDMarker, itemErrorMarker, + } { + assert.NotContains(string(projected), marker) + } + assert.Equal(sourceOperationPublicError(), detail.Error) + + _, err = st.DB().ExecContext(t.Context(), `DROP TABLE sync_run_items`) + require.NoError(err) + withoutItems, err := store.ListSourceOperationRunsForTest( + st, t.Context(), operations.Query{Limit: 10}) + require.NoError(err, "the safe projection must not query sync_run_items") + assert.Equal(runs, withoutItems) + withoutItemsDetail, err := store.GetSourceOperationRunForTest( + st, t.Context(), mustSourceOperationID(t, id)) + require.NoError(err, "detail must not hydrate sync_run_items") + assert.Equal(detail, withoutItemsDetail) +} + +func TestOperationRunsSourceRejectsInvalidDurableRows(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + source := createOperationSource(t, st, "invalid-source@example.invalid") + base := time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC) + validID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: base, state: "completed", processed: 1, + }) + invalidID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: base.Add(time.Minute), state: "unknown", processed: 1, + }) + + _, err := store.GetSourceOperationRunForTest( + st, t.Context(), mustSourceOperationID(t, invalidID)) + require.Error(err, "unknown durable states must fail closed") + runs, err := store.ListSourceOperationRunsForTest( + st, t.Context(), operations.Query{Limit: 10}) + require.Error(err, "one invalid selected row must fail the whole list") + assert.Empty(runs) + + _, err = st.DB().ExecContext(t.Context(), st.Rebind(` + UPDATE sync_runs SET status = 'completed', messages_processed = -1 WHERE id = ?`), invalidID) + require.NoError(err) + _, err = store.GetSourceOperationRunForTest( + st, t.Context(), mustSourceOperationID(t, invalidID)) + require.Error(err, "negative progress must fail closed") + + _, err = st.DB().ExecContext(t.Context(), st.Rebind(` + UPDATE sync_runs SET messages_processed = 1, errors_count = -1 WHERE id = ?`), invalidID) + require.NoError(err) + _, err = store.GetSourceOperationRunForTest( + st, t.Context(), mustSourceOperationID(t, invalidID)) + require.Error(err, "negative item errors must fail closed") + + valid, err := store.GetSourceOperationRunForTest( + st, t.Context(), mustSourceOperationID(t, validID)) + require.NoError(err) + assert.Equal(operations.StateSucceeded, valid.State) +} + +func TestOperationRunsSourceRejectsMalformedSQLiteTimestamps(t *testing.T) { + require := require.New(t) + st := testutil.NewSQLiteTestStore(t) + source := createOperationSource(t, st, "timestamp-source@example.invalid") + started := time.Date(2026, 8, 28, 7, 0, 0, 0, time.UTC) + runningID := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: started.Add(time.Minute), state: "running", processed: 1, + }) + _, err := st.DB().ExecContext(t.Context(), + `UPDATE sync_runs SET completed_at = 'malformed' WHERE id = ?`, runningID) + require.NoError(err) + _, err = store.GetSourceOperationRunForTest( + st, t.Context(), mustSourceOperationID(t, runningID)) + require.Error(err, "a running row cannot hide a malformed non-null completion timestamp") + + id := insertSourceOperationRun(t, st, source.ID, sourceOperationRunSeed{ + startedAt: started, state: "completed", processed: 1, + }) + + _, err = st.DB().ExecContext(t.Context(), `UPDATE sync_runs SET started_at = 'malformed' WHERE id = ?`, id) + require.NoError(err) + _, err = store.GetSourceOperationRunForTest( + st, t.Context(), mustSourceOperationID(t, id)) + require.Error(err, "malformed required timestamps must fail closed") + + _, err = st.DB().ExecContext(t.Context(), ` + UPDATE sync_runs SET started_at = ?, completed_at = 'malformed' WHERE id = ?`, + started.UTC().Format("2006-01-02 15:04:05"), id) + require.NoError(err) + _, err = store.GetSourceOperationRunForTest( + st, t.Context(), mustSourceOperationID(t, id)) + require.Error(err, "malformed terminal timestamps must fail closed") +} + +type sourceOperationRunSeed struct { + startedAt time.Time + state string + processed int64 + added int64 + updated int64 + itemErrors int64 + errorMessage string + cursorBefore string + cursorAfter string +} + +func createOperationSource(t *testing.T, st *store.Store, identifier string) *store.Source { + t.Helper() + source, err := st.GetOrCreateSource("gmail", identifier) + require.NoError(t, err) + return source +} + +func insertSourceOperationRun( + t *testing.T, st *store.Store, sourceID int64, seed sourceOperationRunSeed, +) int64 { + t.Helper() + var completedAt any + if seed.state != "running" { + completedAt = sourceOperationTimestampParam(st, seed.startedAt.Add(time.Second)) + } + var id int64 + err := st.DB().QueryRowContext(t.Context(), st.Rebind(` + INSERT INTO sync_runs ( + source_id, started_at, completed_at, status, + messages_processed, messages_added, messages_updated, errors_count, + error_message, cursor_before, cursor_after + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, '')) + RETURNING id`), + sourceID, sourceOperationTimestampParam(st, seed.startedAt), completedAt, seed.state, + seed.processed, seed.added, seed.updated, seed.itemErrors, + seed.errorMessage, seed.cursorBefore, seed.cursorAfter, + ).Scan(&id) + require.NoError(t, err) + return id +} + +func sourceOperationTimestampParam(st *store.Store, value time.Time) any { + if st.IsPostgreSQL() { + return value.UTC() + } + return value.UTC().Format("2006-01-02 15:04:05") +} + +func mustSourceOperationID(t *testing.T, id int64) operations.StableID { + t.Helper() + stableID, err := operations.NewInt64ID(operations.KindSourceSync, id) + require.NoError(t, err) + return stableID +} + +func sourceOperationIntID(t *testing.T, id operations.StableID) int64 { + t.Helper() + value, ok := id.Int64() + require.True(t, ok) + return value +} + +func sourceOperationPublicError() *operations.PublicError { + return &operations.PublicError{ + Code: operations.PublicErrorSourceSyncFailed, Message: "Source sync failed.", + } +} + +func reverseInt64s(values []int64) { + for left, right := 0, len(values)-1; left < right; left, right = left+1, right-1 { + values[left], values[right] = values[right], values[left] + } +} + +func TestOperationRunsCardDAVProjectsNativeStatesTriggersAndCounters(t *testing.T) { + newAssertions := assert.New + newRequirements := require.New + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + base := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + seed cardDAVOperationRunSeed + wantState operations.State + wantError *operations.PublicError + }{ + { + name: "running manual", + seed: cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerManual, state: store.CardDAVSyncRunRunning, + startedAt: base, books: 1, + }, + wantState: operations.StateRunning, + }, + { + name: "succeeded scheduled", + seed: cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerScheduled, state: store.CardDAVSyncRunSucceeded, + startedAt: base.Add(time.Minute), books: 2, created: 3, updated: 4, removed: 5, + }, + wantState: operations.StateSucceeded, + }, + { + name: "partial", + seed: cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerManual, state: store.CardDAVSyncRunPartial, + startedAt: base.Add(2 * time.Minute), books: 2, created: 1, + errorCode: "sync_failed", errorMessage: "private-partial-message", + }, + wantState: operations.StatePartial, + wantError: &operations.PublicError{ + Code: operations.PublicErrorSyncFailed, Message: "CardDAV sync failed.", + }, + }, + { + name: "failed", + seed: cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerScheduled, state: store.CardDAVSyncRunFailed, + startedAt: base.Add(3 * time.Minute), books: 1, + errorCode: "authentication_failed", errorMessage: "private-failed-message", + }, + wantState: operations.StateFailed, + wantError: &operations.PublicError{ + Code: operations.PublicErrorAuthenticationFailed, Message: "CardDAV authentication failed.", + }, + }, + { + name: "cancelled", + seed: cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerManual, state: store.CardDAVSyncRunCancelled, + startedAt: base.Add(4 * time.Minute), books: 1, + errorCode: "cancelled", errorMessage: "private-cancelled-message", + }, + wantState: operations.StateCancelled, + wantError: &operations.PublicError{ + Code: operations.PublicErrorCancelled, Message: "CardDAV sync was cancelled.", + }, + }, + } + + idsByState := make(map[operations.State]int64, len(tests)) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := newRequirements(t) + assert := newAssertions(t) + id := insertCardDAVOperationRun(t, st, test.seed) + idsByState[test.wantState] = id + got, err := store.GetCardDAVOperationRunForTest( + st, t.Context(), mustCardDAVOperationID(t, id)) + require.NoError(err) + require.NotNil(got.Trigger) + assert.Equal(test.wantState, got.State) + assert.Equal(test.wantError, got.Error) + assert.Equal(operations.Trigger(test.seed.trigger), *got.Trigger) + assert.Equal([]operations.PublicCounter{ + {Name: operations.CounterBooks, Unit: operations.CounterUnitBooks, Value: test.seed.books}, + {Name: operations.CounterCreated, Unit: operations.CounterUnitContacts, Value: test.seed.created}, + {Name: operations.CounterUpdated, Unit: operations.CounterUnitContacts, Value: test.seed.updated}, + {Name: operations.CounterRemoved, Unit: operations.CounterUnitContacts, Value: test.seed.removed}, + }, got.Counters) + require.NoError(got.Validate()) + }) + } + + for state, wantID := range idsByState { + runs, err := store.ListCardDAVOperationRunsForTest(st, t.Context(), operations.Query{ + States: []operations.State{state}, Limit: 10, + }) + require.NoError(err) + require.Len(runs, 1) + assert.Equal(wantID, cardDAVOperationIntID(t, runs[0].ID)) + assert.Equal(state, runs[0].State) + } + queued, err := store.ListCardDAVOperationRunsForTest(st, t.Context(), operations.Query{ + States: []operations.State{operations.StateQueued}, Limit: 10, + }) + require.NoError(err) + assert.Empty(queued) +} + +func TestOperationRunsCardDAVProjectsOnlyFixedFailureMessages(t *testing.T) { + st := testutil.NewTestStore(t) + base := time.Date(2026, 8, 28, 13, 0, 0, 0, time.UTC) + tests := []struct { + code string + state store.CardDAVSyncRunState + wantCode operations.PublicErrorCode + wantMessage string + }{ + {"cancelled", store.CardDAVSyncRunCancelled, operations.PublicErrorCancelled, "CardDAV sync was cancelled."}, + {"retry_after", store.CardDAVSyncRunFailed, operations.PublicErrorRetryAfter, "CardDAV sync is temporarily paused."}, + {"authentication_failed", store.CardDAVSyncRunFailed, operations.PublicErrorAuthenticationFailed, "CardDAV authentication failed."}, + {"upstream_failed", store.CardDAVSyncRunFailed, operations.PublicErrorUpstreamFailed, "CardDAV server request failed."}, + {"safety_limit", store.CardDAVSyncRunFailed, operations.PublicErrorSafetyLimit, "CardDAV sync exceeded its safety limits."}, + {"sync_failed", store.CardDAVSyncRunFailed, operations.PublicErrorSyncFailed, "CardDAV sync failed."}, + {"unsafe_error_redacted", store.CardDAVSyncRunFailed, operations.PublicErrorUnsafeErrorRedacted, "CardDAV sync failed; sensitive details were removed."}, + {"daemon_restarted", store.CardDAVSyncRunFailed, operations.PublicErrorDaemonRestarted, "CardDAV sync stopped because the daemon restarted."}, + } + for index, test := range tests { + t.Run(test.code, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + privateMessage := "private-stored-message-" + test.code + id := insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerManual, + state: test.state, startedAt: base.Add(time.Duration(index) * time.Minute), + errorCode: test.code, errorMessage: privateMessage, + }) + run, err := store.GetCardDAVOperationRunForTest( + st, t.Context(), mustCardDAVOperationID(t, id)) + require.NoError(err) + require.NotNil(run.Error) + assert.Equal(test.wantCode, run.Error.Code) + assert.Equal(test.wantMessage, run.Error.Message) + assert.NotContains(fmt.Sprintf("%#v", run), privateMessage) + }) + } + + privateMarker := "credential=private-unknown-code-marker" + unknownID := insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerScheduled, state: store.CardDAVSyncRunFailed, + startedAt: base.Add(20 * time.Minute), errorCode: "new_safe_code", errorMessage: privateMarker, + }) + unknown, err := store.GetCardDAVOperationRunForTest( + st, t.Context(), mustCardDAVOperationID(t, unknownID)) + require.NoError(t, err) + assert.Equal(t, &operations.PublicError{ + Code: operations.PublicErrorCardDAVSyncFailed, Message: "CardDAV sync failed.", + }, unknown.Error) + assert.NotContains(t, fmt.Sprintf("%#v", unknown), privateMarker) +} + +func TestOperationRunsCardDAVPagesSameSecondByNumericID(t *testing.T) { + st := testutil.NewTestStore(t) + instant := time.Date(2026, 8, 28, 14, 15, 16, 0, time.UTC) + for range 10 { + insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerManual, state: store.CardDAVSyncRunSucceeded, + startedAt: instant, + }) + } + + var got []int64 + var position *operations.Position + for { + page, err := store.ListCardDAVOperationRunsForTest(st, t.Context(), operations.Query{ + Position: position, Limit: 1, + }) + require.NoError(t, err) + if len(page) == 0 { + break + } + require.Len(t, page, 1) + got = append(got, cardDAVOperationIntID(t, page[0].ID)) + position = &operations.Position{StartedAt: page[0].StartedAt, ID: page[0].ID} + } + assert.Equal(t, []int64{10, 9, 8, 7, 6, 5, 4, 3, 2, 1}, got) +} + +func TestOperationRunsCardDAVPagesAfterFractionalCrossKindCursor(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + instant := time.Date(2026, 8, 28, 14, 15, 16, 0, time.UTC) + lowerID := insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerManual, state: store.CardDAVSyncRunSucceeded, + startedAt: instant, + }) + higherID := insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerManual, state: store.CardDAVSyncRunSucceeded, + startedAt: instant, + }) + personCursorID, err := operations.NewTextID(operations.KindPersonSweep, "person-sweep-cursor") + require.NoError(err) + + runs, err := store.ListCardDAVOperationRunsForTest(st, t.Context(), operations.Query{ + Position: &operations.Position{ + StartedAt: instant.Add(500 * time.Millisecond), + ID: personCursorID, + }, + Limit: 1, + }) + require.NoError(err) + require.Len(runs, 1, "a whole-second CardDAV row is older than the fractional cursor") + assert.Equal(higherID, cardDAVOperationIntID(t, runs[0].ID)) + + second, err := store.ListCardDAVOperationRunsForTest(st, t.Context(), operations.Query{ + Position: &operations.Position{StartedAt: runs[0].StartedAt, ID: runs[0].ID}, + Limit: 1, + }) + require.NoError(err) + require.Len(second, 1) + assert.Equal(lowerID, cardDAVOperationIntID(t, second[0].ID)) + + afterLast, err := store.ListCardDAVOperationRunsForTest(st, t.Context(), operations.Query{ + Position: &operations.Position{StartedAt: second[0].StartedAt, ID: second[0].ID}, + Limit: 1, + }) + require.NoError(err) + assert.Empty(afterLast) +} + +func TestOperationRunsCardDAVDetailAndStatusMatchListProjection(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + empty, err := store.CardDAVOperationLaneStatusForTest(st, t.Context()) + require.NoError(err) + assert.Equal(operations.LaneHistoryStatus{ + Kind: operations.KindCardDAVSync, Lane: operations.LaneContacts, + HistoryAvailability: operations.HistoryAvailable, + }, empty) + + base := time.Date(2026, 8, 28, 15, 0, 0, 0, time.UTC) + succeededID := insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerManual, state: store.CardDAVSyncRunSucceeded, + startedAt: base, books: 1, created: 1, + }) + insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerScheduled, state: store.CardDAVSyncRunFailed, + startedAt: base.Add(time.Minute), errorCode: "upstream_failed", errorMessage: "private-status-message", + }) + runningID := insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerManual, state: store.CardDAVSyncRunRunning, + startedAt: base.Add(2 * time.Minute), books: 2, + }) + + list, err := store.ListCardDAVOperationRunsForTest( + st, t.Context(), operations.Query{Limit: 10}) + require.NoError(err) + require.Len(list, 3) + detail, err := store.GetCardDAVOperationRunForTest(st, t.Context(), list[1].ID) + require.NoError(err) + assert.Equal(list[1], detail) + + status, err := store.CardDAVOperationLaneStatusForTest(st, t.Context()) + require.NoError(err) + require.NotNil(status.Active) + require.NotNil(status.Latest) + require.NotNil(status.LatestSuccessful) + assert.Equal(runningID, cardDAVOperationIntID(t, status.Active.ID)) + assert.Equal(list[0], *status.Latest) + assert.Equal(succeededID, cardDAVOperationIntID(t, status.LatestSuccessful.ID)) + require.NoError(status.Validate()) + + _, err = store.GetCardDAVOperationRunForTest( + st, t.Context(), mustCardDAVOperationID(t, runningID+1000)) + require.ErrorIs(err, store.ErrOperationRunNotFound) +} + +func TestOperationRunsCardDAVExcludesPrivateCardDAVData(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + privateMarkers := []string{ + "private-account-url-marker", "private-username-marker", "private-book-url-marker", + "private-href-marker", "private-vcard-marker", "private-credential-marker", + "private-remote-cursor-marker", "private-stored-message-marker", + } + _, books, err := st.ReplaceCardDAVDiscoveryContext(t.Context(), store.CardDAVDiscoveryInput{ + BaseURL: "https://private-account-url-marker.example.invalid/carddav/", + Username: "private-username-marker", + PrincipalURL: "https://private-account-url-marker.example.invalid/principal/", + HomeURL: "https://private-account-url-marker.example.invalid/home/", + Books: []store.CardDAVDiscoveredBook{{ + CanonicalURL: "https://private-book-url-marker.example.invalid/book/", + DisplayName: "Private book", DiscoveryIndex: 0, + }}, + }) + require.NoError(err) + require.Len(books, 1) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`UPDATE carddav_address_books + SET sync_token = ? WHERE id = ?`), "private-remote-cursor-marker", books[0].ID) + require.NoError(err) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`INSERT INTO carddav_resources ( + address_book_id, href, remote_uid, remote_etag, remote_body, + remote_semantic_hash, local_hash, mapping_status, governance + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'unbound', 'none')`), + books[0].ID, "private-href-marker.vcf", "private-contact-uid", "private-etag", + []byte("BEGIN:VCARD\nFN:private-vcard-marker\nEND:VCARD"), "remote-hash", "local-hash") + require.NoError(err) + + id := insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerManual, state: store.CardDAVSyncRunFailed, + startedAt: time.Date(2026, 8, 28, 16, 0, 0, 0, time.UTC), full: true, + errorCode: "new_safe_code", + errorMessage: "credential=private-credential-marker private-stored-message-marker", + }) + runs, err := store.ListCardDAVOperationRunsForTest( + st, t.Context(), operations.Query{Limit: 10}) + require.NoError(err) + require.Len(runs, 1) + detail, err := store.GetCardDAVOperationRunForTest( + st, t.Context(), mustCardDAVOperationID(t, id)) + require.NoError(err) + assert.Equal(runs[0], detail) + status, err := store.CardDAVOperationLaneStatusForTest(st, t.Context()) + require.NoError(err) + + projected := strings.ToLower(fmt.Sprintf("%#v %#v %#v", runs, detail, status)) + for _, marker := range privateMarkers { + assert.NotContains(projected, strings.ToLower(marker)) + } + assert.NotContains(projected, "full:") +} + +func TestOperationRunsCardDAVRejectsMalformedSQLiteFinishedTimestamp(t *testing.T) { + st := testutil.NewSQLiteTestStore(t) + st.DB().SetMaxOpenConns(1) + runningID := insertCardDAVOperationRun(t, st, cardDAVOperationRunSeed{ + trigger: store.CardDAVSyncTriggerManual, state: store.CardDAVSyncRunRunning, + startedAt: time.Date(2026, 8, 28, 17, 0, 0, 0, time.UTC), + }) + + _, err := st.DB().ExecContext(t.Context(), `PRAGMA ignore_check_constraints = ON`) + require.NoError(t, err) + _, err = st.DB().ExecContext(t.Context(), + `UPDATE carddav_sync_runs SET finished_at = 'malformed' WHERE id = ?`, runningID) + require.NoError(t, err) + + _, err = store.GetCardDAVOperationRunForTest( + st, t.Context(), mustCardDAVOperationID(t, runningID)) + require.Error(t, err, "a running row cannot hide a malformed non-null finish timestamp") +} + +type cardDAVOperationRunSeed struct { + trigger store.CardDAVSyncTrigger + full bool + state store.CardDAVSyncRunState + startedAt time.Time + books int64 + created int64 + updated int64 + removed int64 + errorCode string + errorMessage string +} + +func insertCardDAVOperationRun( + t *testing.T, st *store.Store, seed cardDAVOperationRunSeed, +) int64 { + t.Helper() + var finishedAt any + if seed.state != store.CardDAVSyncRunRunning { + finishedAt = cardDAVOperationTimestampArg(st, seed.startedAt.Add(time.Second)) + } + var id int64 + err := st.DB().QueryRowContext(t.Context(), st.Rebind(`INSERT INTO carddav_sync_runs ( + trigger, full_sync, state, started_at, finished_at, books, created, updated, removed, + error_code, error_message + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`), + seed.trigger, seed.full, seed.state, cardDAVOperationTimestampArg(st, seed.startedAt), + finishedAt, seed.books, seed.created, seed.updated, seed.removed, + seed.errorCode, seed.errorMessage).Scan(&id) + require.NoError(t, err) + return id +} + +func cardDAVOperationTimestampArg(st *store.Store, value time.Time) any { + if st.IsPostgreSQL() { + return value.UTC() + } + return value.UTC().Format("2006-01-02 15:04:05") +} + +func mustCardDAVOperationID(t *testing.T, id int64) operations.StableID { + t.Helper() + stableID, err := operations.NewInt64ID(operations.KindCardDAVSync, id) + require.NoError(t, err) + return stableID +} + +func cardDAVOperationIntID(t *testing.T, id operations.StableID) int64 { + t.Helper() + value, ok := id.Int64() + require.True(t, ok) + return value +} diff --git a/internal/store/person_directory.go b/internal/store/person_directory.go new file mode 100644 index 000000000..6053ab539 --- /dev/null +++ b/internal/store/person_directory.go @@ -0,0 +1,707 @@ +package store + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +const ( + DefaultDirectoryPeopleLimit = 50 + MaxDirectoryPeopleLimit = 100 + maxDirectoryQueryRunes = 256 + maxDirectoryCursorBytes = 1024 + maxDirectoryRawCandidateChunkSize = 64 + directoryCursorVersion = 6 + directoryLastContactKeyLayout = "2006-01-02T15:04:05.000000000Z" + DirectoryPeopleSortName = "name" + DirectoryPeopleSortLastContactAsc = "last_contact_asc" + DirectoryPeopleSortLastContactDesc = "last_contact_desc" +) + +// DirectoryPeopleQuery selects durable people for the Directory read surface. +// All text fields are case- and whitespace-normalized before matching. +type DirectoryPeopleQuery struct { + Query string `json:"query,omitempty"` + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` + ContactState string `json:"contact_state,omitempty"` + Category string `json:"category,omitempty"` + Organization string `json:"organization,omitempty"` + PrimaryChannel string `json:"primary_channel,omitempty"` + LastContactAfter *time.Time `json:"last_contact_after,omitempty"` + LastContactBefore *time.Time `json:"last_contact_before,omitempty"` + Sort string `json:"sort,omitempty"` +} + +// DirectoryPersonSummary is the non-sensitive, directory-sized projection of +// a durable person root. ContactState is "active" when a contact projection +// has a last-contact timestamp and "inactive" otherwise. +type DirectoryPersonSummary struct { + ID int64 `json:"id"` + DisplayName *string `json:"display_name,omitempty"` + Revision int64 `json:"revision"` + PrimaryChannel string `json:"primary_channel,omitempty"` + ContactState string `json:"contact_state"` + LastContactAt *time.Time `json:"last_contact_at,omitempty"` + Categories []string `json:"categories" nullable:"false"` + Organizations []string `json:"organizations" nullable:"false"` +} + +// DirectoryPeoplePage is one stable keyset page of directory people. +type DirectoryPeoplePage struct { + People []DirectoryPersonSummary `json:"people"` + NextCursor string `json:"next_cursor,omitempty"` +} + +type normalizedDirectoryPeopleQuery struct { + query string + terms []string + cursor string + limit int + contactState string + category string + organization string + primaryChannel string + lastContactAfter string + lastContactBefore string + sort string + fingerprint string +} + +type directoryPeopleCursor struct { + Version int `json:"version"` + Fingerprint string `json:"fingerprint"` + Quality int `json:"quality"` + AnchorHash string `json:"anchor_hash"` + OrderKey string `json:"-"` + SortKey string `json:"-"` + PersonID int64 `json:"person_id"` +} + +type directoryPersonCandidate struct { + summary DirectoryPersonSummary + quality int + sortKey string + orderName string +} + +type directoryRawCandidateCursor struct { + quality int + sortKey string + orderName string + personID int64 +} + +// DirectoryPeoplePageContext returns a deterministic, cursor-paginated page +// over promoted people. Its SQL uses only portable normalization and matching +// so SQLite and PostgreSQL share lexical, filtering, and cursor semantics. +func (s *Store) DirectoryPeoplePageContext( + ctx context.Context, + query DirectoryPeopleQuery, +) (*DirectoryPeoplePage, error) { + normalized, err := normalizeDirectoryPeopleQuery(query) + if err != nil { + return nil, err + } + return s.directoryPeoplePageContext(ctx, normalized) +} + +func normalizeDirectoryPeopleQuery(query DirectoryPeopleQuery) (normalizedDirectoryPeopleQuery, error) { + normalized := normalizedDirectoryPeopleQuery{ + query: normalizeDirectoryText(query.Query), + cursor: strings.TrimSpace(query.Cursor), + contactState: normalizeDirectoryText(query.ContactState), + category: normalizeDirectoryText(query.Category), + organization: normalizeDirectoryText(query.Organization), + primaryChannel: normalizeDirectoryText(query.PrimaryChannel), + sort: strings.TrimSpace(query.Sort), + } + if normalized.sort == "" { + normalized.sort = DirectoryPeopleSortName + } + if normalized.sort != DirectoryPeopleSortName && normalized.sort != DirectoryPeopleSortLastContactAsc && normalized.sort != DirectoryPeopleSortLastContactDesc { + return normalized, fmt.Errorf("%w: unknown sort", ErrInvalidDirectoryQuery) + } + if query.LastContactAfter != nil { + normalized.lastContactAfter = directoryLastContactKey(*query.LastContactAfter) + } + if query.LastContactBefore != nil { + normalized.lastContactBefore = directoryLastContactKey(*query.LastContactBefore) + } + if normalized.lastContactAfter != "" && normalized.lastContactBefore != "" && query.LastContactAfter.After(*query.LastContactBefore) { + return normalized, fmt.Errorf("%w: last contact range is empty", ErrInvalidDirectoryQuery) + } + if utf8.RuneCountInString(normalized.query) > maxDirectoryQueryRunes { + return normalized, fmt.Errorf("%w: query is too long", ErrInvalidDirectoryQuery) + } + if normalized.contactState != "" && normalized.contactState != "active" && normalized.contactState != "inactive" { + return normalized, fmt.Errorf("%w: unknown contact state", ErrInvalidDirectoryQuery) + } + normalized.terms = directoryTokens(query.Query) + normalized.query = strings.Join(normalized.terms, " ") + normalized.limit = query.Limit + if normalized.limit <= 0 { + normalized.limit = DefaultDirectoryPeopleLimit + } + if normalized.limit > MaxDirectoryPeopleLimit { + normalized.limit = MaxDirectoryPeopleLimit + } + encoded, err := json.Marshal(struct { + Query string `json:"query"` + ContactState string `json:"contact_state"` + Category string `json:"category"` + Organization string `json:"organization"` + PrimaryChannel string `json:"primary_channel"` + LastContactAfter string `json:"last_contact_after"` + LastContactBefore string `json:"last_contact_before"` + Sort string `json:"sort"` + }{ + Query: normalized.query, ContactState: normalized.contactState, + Category: normalized.category, Organization: normalized.organization, + PrimaryChannel: normalized.primaryChannel, + LastContactAfter: normalized.lastContactAfter, LastContactBefore: normalized.lastContactBefore, + Sort: normalized.sort, + }) + if err != nil { + return normalized, fmt.Errorf("encode directory filters: %w", err) + } + digest := sha256.Sum256(encoded) + normalized.fingerprint = hex.EncodeToString(digest[:]) + return normalized, nil +} + +func (s *Store) directoryPeoplePageContext( + ctx context.Context, + query normalizedDirectoryPeopleQuery, +) (*DirectoryPeoplePage, error) { + var after *directoryPeopleCursor + if query.cursor != "" { + cursor, err := decodeDirectoryPeopleCursor(query.cursor) + if err != nil { + return nil, err + } + if cursor.Fingerprint != query.fingerprint { + return nil, ErrInvalidDirectoryCursor + } + after = &cursor + } + + var page *DirectoryPeoplePage + err := s.withFreshDirectorySnapshotContext(ctx, func(tx *loggedTx) error { + if after != nil { + anchor, err := s.directoryCursorAnchorTx(ctx, tx, query, after.PersonID) + if err != nil { + return err + } + if after.Quality != anchor.quality || + after.AnchorHash != directoryAnchorHash(anchor.sortKey, anchor.orderName) { + return ErrInvalidDirectoryCursor + } + after.OrderKey = anchor.orderName + after.SortKey = anchor.sortKey + } + candidates, err := s.selectDirectoryPeopleTx(ctx, tx, query, after) + if err != nil { + return err + } + page = &DirectoryPeoplePage{People: make([]DirectoryPersonSummary, 0, min(query.limit, len(candidates)))} + if len(candidates) > query.limit { + last := candidates[query.limit-1] + page.NextCursor, err = encodeDirectoryPeopleCursor(directoryPeopleCursor{ + Version: directoryCursorVersion, Fingerprint: query.fingerprint, + Quality: last.quality, AnchorHash: directoryAnchorHash(last.sortKey, last.orderName), PersonID: last.summary.ID, + }) + if err != nil { + return err + } + candidates = candidates[:query.limit] + } + if err := s.hydrateDirectoryPeopleTx(ctx, tx, candidates); err != nil { + return err + } + for _, candidate := range candidates { + page.People = append(page.People, candidate.summary) + } + return nil + }) + return page, err +} + +// selectDirectoryPeopleTx scans indexed candidate chunks until it has the +// requested verified page plus its cursor row. Hydration stays separate so +// current categories and employments are never read outside that window. +func (s *Store) selectDirectoryPeopleTx(ctx context.Context, tx *loggedTx, query normalizedDirectoryPeopleQuery, after *directoryPeopleCursor) ([]directoryPersonCandidate, error) { + querySQL, args := directoryCandidateProjectionSQL(query) + var rawAfter *directoryRawCandidateCursor + if after != nil { + rawAfter = &directoryRawCandidateCursor{quality: after.Quality, sortKey: after.SortKey, orderName: after.OrderKey, personID: after.PersonID} + } + target, chunkSize := query.limit+1, directoryRawCandidateChunkSize(query.limit) + verified := make([]directoryPersonCandidate, 0, target) + for len(verified) < target { + raw, err := s.selectDirectoryRawCandidateChunkTx(ctx, tx, querySQL, args, rawAfter, chunkSize, query.sort) + if err != nil { + return nil, err + } + if len(raw) == 0 { + break + } + rawCount := len(raw) + if rawAfter != nil && !directoryRawCandidateFollows(raw[0], *rawAfter, query.sort) { + return nil, errors.New("directory raw candidate cursor repeated") + } + lastRaw := raw[len(raw)-1] + if len(query.terms) > 0 { + raw, err = s.verifyDirectoryCandidateTokensTx(ctx, tx, raw, query.terms) + if err != nil { + return nil, err + } + } + verified = append(verified, raw...) + if len(verified) >= target || rawCount < chunkSize { + break + } + rawAfter = &directoryRawCandidateCursor{quality: lastRaw.quality, sortKey: lastRaw.sortKey, orderName: lastRaw.orderName, personID: lastRaw.summary.ID} + } + if len(verified) > target { + verified = verified[:target] + } + return verified, nil +} + +func directoryCandidateProjectionSQL(query normalizedDirectoryPeopleQuery) (string, []any) { + where, filterArgs := []string{}, []any{} + if query.contactState != "" { + where, filterArgs = append(where, "dp.contact_state = ?"), append(filterArgs, query.contactState) + } + if query.primaryChannel != "" { + where, filterArgs = append(where, "dp.primary_channel = ?"), append(filterArgs, query.primaryChannel) + } + if query.lastContactAfter != "" { + where, filterArgs = append(where, "dp.last_contact_key >= ?"), append(filterArgs, query.lastContactAfter) + } + if query.lastContactBefore != "" { + where, filterArgs = append(where, "dp.last_contact_key <= ? AND dp.last_contact_key != ''"), append(filterArgs, query.lastContactBefore) + } + if query.category != "" { + where, filterArgs = append(where, `EXISTS (SELECT 1 FROM directory_person_filters filter WHERE filter.person_id = dp.person_id AND filter.filter_kind = 'category' AND filter.value_key = ?)`), append(filterArgs, directoryKey(query.category)) + } + if query.organization != "" { + where, filterArgs = append(where, `EXISTS (SELECT 1 FROM directory_person_filters filter WHERE filter.person_id = dp.person_id AND filter.filter_kind = 'organization' AND filter.value_key = ?)`), append(filterArgs, directoryKey(query.organization)) + } + filterSQL := "1 = 1" + if len(where) > 0 { + filterSQL = strings.Join(where, " AND ") + } + var args []any + var querySQL string + sortExpression := "dp.order_key" + if query.sort != DirectoryPeopleSortName { + sortExpression = "dp.last_contact_key" + } + if len(query.terms) == 0 { + querySQL, args = `SELECT dp.person_id, 0 AS match_quality, `+sortExpression+` AS sort_key, dp.order_key FROM directory_people dp WHERE `+filterSQL, append(args, filterArgs...) + } else { + raw := make([]string, 0, len(query.terms)*4) + for index, term := range query.terms { + fragment, fragmentArgs := directoryTermProjectionSQL(term, index) + raw, args = append(raw, fragment), append(args, fragmentArgs...) + } + args = append(args, filterArgs...) + querySQL = `WITH raw_matches AS (` + strings.Join(raw, ` UNION ALL `) + `), term_matches AS (SELECT person_id, term_index, MIN(match_quality) AS match_quality FROM raw_matches GROUP BY person_id, term_index), ranked AS (SELECT dp.person_id, MAX(match.match_quality) AS match_quality, ` + sortExpression + ` AS sort_key, dp.order_key FROM term_matches match JOIN directory_people dp ON dp.person_id = match.person_id WHERE ` + filterSQL + ` GROUP BY dp.person_id, dp.order_key, dp.last_contact_key HAVING COUNT(*) = ` + strconv.Itoa(len(query.terms)) + `) SELECT person_id, match_quality, sort_key, order_key FROM ranked` + } + return querySQL, args +} + +func (s *Store) directoryCursorAnchorTx( + ctx context.Context, + tx *loggedTx, + query normalizedDirectoryPeopleQuery, + personID int64, +) (directoryPersonCandidate, error) { + querySQL, args := directoryCandidateProjectionSQL(query) + querySQL = `SELECT person_id, match_quality, sort_key, order_key FROM (` + querySQL + `) candidate WHERE person_id = ?` + args = append(args, personID) + candidates, err := s.selectDirectoryRawCandidateChunkTx(ctx, tx, querySQL, args, nil, 1, query.sort) + if err != nil { + return directoryPersonCandidate{}, err + } + if len(query.terms) > 0 { + candidates, err = s.verifyDirectoryCandidateTokensTx(ctx, tx, candidates, query.terms) + if err != nil { + return directoryPersonCandidate{}, err + } + } + if len(candidates) != 1 { + return directoryPersonCandidate{}, ErrInvalidDirectoryCursor + } + return candidates[0], nil +} + +func directoryRawCandidateChunkSize(limit int) int { + return min(max(limit+1, 16), maxDirectoryRawCandidateChunkSize) +} + +func (s *Store) selectDirectoryRawCandidateChunkTx(ctx context.Context, tx *loggedTx, candidateSQL string, args []any, after *directoryRawCandidateCursor, limit int, order string) ([]directoryPersonCandidate, error) { + queryArgs := append([]any(nil), args...) + sortComparator, sortDirection := ">", "ASC" + if order == DirectoryPeopleSortLastContactDesc { + sortComparator, sortDirection = "<", "DESC" + } + if after != nil { + candidateSQL = `SELECT person_id, match_quality, sort_key, order_key FROM (` + candidateSQL + `) candidate WHERE (match_quality > ? OR (match_quality = ? AND (sort_key ` + sortComparator + ` ? OR (sort_key = ? AND (order_key > ? OR (order_key = ? AND person_id > ?))))))` + queryArgs = append(queryArgs, after.quality, after.quality, after.sortKey, after.sortKey, after.orderName, after.orderName, after.personID) + } + queryArgs = append(queryArgs, limit) + rows, err := tx.QueryContext(ctx, candidateSQL+` ORDER BY match_quality, sort_key `+sortDirection+`, order_key, person_id LIMIT ?`, queryArgs...) + if err != nil { + return nil, fmt.Errorf("select directory people: %w", err) + } + defer func() { _ = rows.Close() }() + candidates := make([]directoryPersonCandidate, 0, limit) + for rows.Next() { + var candidate directoryPersonCandidate + if err := rows.Scan(&candidate.summary.ID, &candidate.quality, &candidate.sortKey, &candidate.orderName); err != nil { + return nil, fmt.Errorf("scan directory person: %w", err) + } + candidates = append(candidates, candidate) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate directory people: %w", err) + } + return candidates, nil +} + +func directoryRawCandidateFollows(candidate directoryPersonCandidate, after directoryRawCandidateCursor, order string) bool { + if candidate.quality != after.quality { + return candidate.quality > after.quality + } + if candidate.sortKey != after.sortKey { + if order == DirectoryPeopleSortLastContactDesc { + return candidate.sortKey < after.sortKey + } + return candidate.sortKey > after.sortKey + } + if candidate.orderName != after.orderName { + return candidate.orderName > after.orderName + } + return candidate.summary.ID > after.personID +} + +// verifyDirectoryCandidateTokensTx treats delete-key hits as an indexed +// prefilter only. The bounded selected IDs are verified against their actual +// canonical tokens so shared deletion keys cannot promote edit-distance-two +// values into the fuzzy tier. +func (s *Store) verifyDirectoryCandidateTokensTx(ctx context.Context, tx *loggedTx, candidates []directoryPersonCandidate, terms []string) ([]directoryPersonCandidate, error) { + if len(candidates) == 0 { + return candidates, nil + } + ids, tokens := make([]any, 0, len(candidates)), make(map[int64][]string, len(candidates)) + for _, candidate := range candidates { + ids = append(ids, candidate.summary.ID) + } + rows, err := tx.QueryContext(ctx, `SELECT person_id, token_key FROM directory_person_tokens WHERE person_id IN (`+directoryPlaceholders(len(ids))+`)`, ids...) + if err != nil { + return nil, fmt.Errorf("load directory candidate tokens: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var id int64 + var key string + if err := rows.Scan(&id, &key); err != nil { + return nil, fmt.Errorf("scan directory candidate token: %w", err) + } + value, err := hex.DecodeString(key) + if err != nil { + return nil, fmt.Errorf("decode directory candidate token: %w", err) + } + tokens[id] = append(tokens[id], string(value)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate directory candidate tokens: %w", err) + } + verified := make([]directoryPersonCandidate, 0, len(candidates)) + for _, candidate := range candidates { + quality, matches := directoryCandidateQuality(tokens[candidate.summary.ID], terms) + if matches { + candidate.quality = quality + verified = append(verified, candidate) + } + } + return verified, nil +} + +func directoryCandidateQuality(tokens, terms []string) (int, bool) { + quality := 0 + for _, term := range terms { + best, found := 3, false + for _, token := range tokens { + switch { + case token == term: + best, found = 0, true + case strings.HasPrefix(token, term) && best > 1: + best, found = 1, true + case utf8.RuneCountInString(term) >= 4 && directoryEditDistanceAtMostOne(token, term) && best > 2: + best, found = 2, true + } + } + if !found { + return 0, false + } + if best > quality { + quality = best + } + } + return quality, true +} + +func directoryEditDistanceAtMostOne(left, right string) bool { + a, b := []rune(left), []rune(right) + if len(a)-len(b) > 1 || len(b)-len(a) > 1 { + return false + } + if len(a) == len(b) { + first, second := -1, -1 + for index := range a { + if a[index] == b[index] { + continue + } + if first == -1 { + first = index + } else if second == -1 { + second = index + } else { + return false + } + } + if second == first+1 && a[first] == b[second] && a[second] == b[first] { + return true + } + } + previous, current := make([]int, len(b)+1), make([]int, len(b)+1) + for index := range previous { + previous[index] = index + } + for i := 1; i <= len(a); i++ { + current[0] = i + minimum := current[0] + for j := 1; j <= len(b); j++ { + cost := 0 + if a[i-1] != b[j-1] { + cost = 1 + } + current[j] = min(previous[j]+1, current[j-1]+1, previous[j-1]+cost) + if current[j] < minimum { + minimum = current[j] + } + } + if minimum > 1 { + return false + } + previous, current = current, previous + } + return previous[len(b)] <= 1 +} + +func directoryTermProjectionSQL(term string, index int) (string, []any) { + key, end := directoryKey(term), directoryPrefixEnd(directoryKey(term)) + parts := []string{`SELECT person_id, 0 AS match_quality, ` + strconv.Itoa(index) + ` AS term_index FROM directory_person_tokens WHERE token_key = ?`, `SELECT person_id, 1 AS match_quality, ` + strconv.Itoa(index) + ` AS term_index FROM directory_person_tokens WHERE token_key >= ? AND token_key < ?`} + args := []any{key, key, end} + if utf8.RuneCountInString(term) >= 4 { + deletes := append([]string{term}, directoryDeleteKeys(term)...) + parts = append(parts, `SELECT person_id, 2 AS match_quality, `+strconv.Itoa(index)+` AS term_index FROM directory_person_token_deletes WHERE delete_key IN (`+directoryPlaceholders(len(deletes))+`)`) + for _, value := range deletes { + args = append(args, directoryKey(value)) + } + fuzzy := directoryFuzzyTokenKeys(term) + parts = append(parts, `SELECT person_id, 2 AS match_quality, `+strconv.Itoa(index)+` AS term_index FROM directory_person_tokens WHERE token_key IN (`+directoryPlaceholders(len(fuzzy))+`)`) + for _, value := range fuzzy { + args = append(args, directoryKey(value)) + } + } + return strings.Join(parts, ` UNION ALL `), args +} + +func directoryPrefixEnd(value string) string { + bytes := []byte(value) + for index := len(bytes) - 1; index >= 0; index-- { + if bytes[index] != 0xff { + bytes[index]++ + return string(bytes[:index+1]) + } + } + return "\xff" +} +func directoryPlaceholders(count int) string { + return strings.TrimSuffix(strings.Repeat("?,", count), ",") +} + +func (s *Store) hydrateDirectoryPeopleTx(ctx context.Context, tx *loggedTx, candidates []directoryPersonCandidate) error { + if len(candidates) == 0 { + return nil + } + ids := make([]any, 0, len(candidates)) + byID := make(map[int64]int, len(candidates)) + for index := range candidates { + ids = append(ids, candidates[index].summary.ID) + byID[candidates[index].summary.ID] = index + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + rows, err := tx.QueryContext(ctx, `SELECT person.id, person.display_name, person.revision, + projection.primary_channel, projection.contact_state, projection.last_contact_key + FROM persons person JOIN directory_people projection ON projection.person_id = person.id + WHERE person.id IN (`+placeholders+`)`, ids...) + if err != nil { + return fmt.Errorf("hydrate directory people: %w", err) + } + for rows.Next() { + var id int64 + var displayName sql.NullString + var primaryChannel, contactState, lastContactKey string + if err := rows.Scan(&id, &displayName, &candidates[byID[id]].summary.Revision, &primaryChannel, &contactState, &lastContactKey); err != nil { + _ = rows.Close() + return fmt.Errorf("scan hydrated directory person: %w", err) + } + candidate := &candidates[byID[id]] + candidate.summary.PrimaryChannel = primaryChannel + candidate.summary.ContactState = contactState + if lastContactKey != "" { + lastContactAt, err := time.Parse(time.RFC3339Nano, lastContactKey) + if err != nil { + _ = rows.Close() + return fmt.Errorf("parse hydrated directory last contact: %w", err) + } + candidate.summary.LastContactAt = &lastContactAt + } + candidate.summary.Categories = []string{} + candidate.summary.Organizations = []string{} + if displayName.Valid { + value := displayName.String + candidate.summary.DisplayName = &value + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate hydrated directory people: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close hydrated directory people: %w", err) + } + if err := hydrateDirectoryValuesTx(ctx, tx, `SELECT person_id, original_value + FROM person_categories WHERE active_until IS NULL AND superseded_at IS NULL + AND person_id IN (`+placeholders+`)`, ids, byID, candidates, true); err != nil { + return err + } + return hydrateDirectoryValuesTx(ctx, tx, `SELECT employment.person_id, organization.name + FROM employments employment JOIN organizations organization ON organization.id = employment.organization_id + WHERE `+s.dialect.BoolTrueExpr("employment.is_current")+` + AND organization.merged_into_id IS NULL AND organization.retired_at IS NULL + AND employment.person_id IN (`+placeholders+`)`, ids, byID, candidates, false) +} + +func hydrateDirectoryValuesTx(ctx context.Context, tx *loggedTx, query string, args []any, byID map[int64]int, candidates []directoryPersonCandidate, categories bool) error { + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("hydrate directory values: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var personID int64 + var value string + if err := rows.Scan(&personID, &value); err != nil { + return fmt.Errorf("scan directory value: %w", err) + } + if categories { + candidates[byID[personID]].summary.Categories = append(candidates[byID[personID]].summary.Categories, value) + } else { + candidates[byID[personID]].summary.Organizations = append(candidates[byID[personID]].summary.Organizations, value) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate directory values: %w", err) + } + for index := range candidates { + candidates[index].summary.Categories = uniqueDirectoryStrings(candidates[index].summary.Categories) + candidates[index].summary.Organizations = uniqueDirectoryStrings(candidates[index].summary.Organizations) + } + return nil +} + +func encodeDirectoryPeopleCursor(cursor directoryPeopleCursor) (string, error) { + encoded, err := json.Marshal(cursor) + if err != nil { + return "", fmt.Errorf("encode directory cursor: %w", err) + } + return base64.RawURLEncoding.EncodeToString(encoded), nil +} + +func decodeDirectoryPeopleCursor(value string) (directoryPeopleCursor, error) { + if len(value) > base64.RawURLEncoding.EncodedLen(maxDirectoryCursorBytes) { + return directoryPeopleCursor{}, ErrInvalidDirectoryCursor + } + decoded, err := base64.RawURLEncoding.DecodeString(value) + if err != nil || len(decoded) > maxDirectoryCursorBytes { + return directoryPeopleCursor{}, ErrInvalidDirectoryCursor + } + var cursor directoryPeopleCursor + decoder := json.NewDecoder(strings.NewReader(string(decoded))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&cursor); err != nil || + cursor.Version != directoryCursorVersion || + !validLowerSHA256(cursor.Fingerprint) || + !validLowerSHA256(cursor.AnchorHash) || + cursor.Quality < 0 || cursor.Quality > 2 || + cursor.PersonID <= 0 { + return directoryPeopleCursor{}, ErrInvalidDirectoryCursor + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return directoryPeopleCursor{}, ErrInvalidDirectoryCursor + } + return cursor, nil +} + +func directoryAnchorHash(sortKey, orderKey string) string { + digest := sha256.Sum256([]byte(sortKey + "\x00" + orderKey)) + return hex.EncodeToString(digest[:]) +} + +func directoryLastContactKey(value time.Time) string { + return value.UTC().Format(directoryLastContactKeyLayout) +} + +func uniqueDirectoryStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + key := normalizeDirectoryText(value) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + result = append(result, value) + } + sort.Slice(result, func(left, right int) bool { + leftKey, rightKey := normalizeDirectoryText(result[left]), normalizeDirectoryText(result[right]) + if leftKey == rightKey { + return result[left] < result[right] + } + return leftKey < rightKey + }) + return result +} diff --git a/internal/store/person_directory_internal_test.go b/internal/store/person_directory_internal_test.go new file mode 100644 index 000000000..27b5a78c1 --- /dev/null +++ b/internal/store/person_directory_internal_test.go @@ -0,0 +1,524 @@ +package store + +import ( + "context" + "database/sql" + "database/sql/driver" + "fmt" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/sqliteutil" +) + +// This catches a decoder that allocates the caller-controlled base64 payload +// before enforcing the Directory cursor's encoded-size boundary. +func TestDecodeDirectoryPeopleCursorRejectsOversizedInputBeforeAllocation(t *testing.T) { + cursor := strings.Repeat("a", maxDirectoryCursorBytes*8) + _, err := decodeDirectoryPeopleCursor(cursor) + require.ErrorIs(t, err, ErrInvalidDirectoryCursor) + + allocations := testing.AllocsPerRun(10, func() { + _, _ = decodeDirectoryPeopleCursor(cursor) + }) + assert.Zero(t, allocations) +} + +// This catches a regression to a whole-directory candidate projection before +// pagination. The query must retain only the requested page plus its cursor +// row, even when the durable directory has substantially more people. +func TestSelectDirectoryPeopleTxBoundsLargeSyntheticDirectory(t *testing.T) { + require := require.New(t) + st, err := OpenForTest(filepath.Join(t.TempDir(), "directory.db")) + require.NoError(err) + t.Cleanup(func() { assert.NoError(t, st.Close()) }) + require.NoError(st.InitSchema()) + + ctx := context.Background() + for index := range 256 { + _, err := st.DB().ExecContext(ctx, + `INSERT INTO persons (vcard_uid, display_name) VALUES (?, ?)`, + fmt.Sprintf("directory-%03d", index), fmt.Sprintf("Person %03d", index), + ) + require.NoError(err) + } + query, err := normalizeDirectoryPeopleQuery(DirectoryPeopleQuery{Limit: 2}) + require.NoError(err) + require.NoError(st.refreshDirectoryProjectionsContext(ctx)) + + var candidates []directoryPersonCandidate + require.NoError(st.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + var selectErr error + candidates, selectErr = st.selectDirectoryPeopleTx(ctx, tx, query, nil) + return selectErr + })) + require.Len(candidates, 3) + assert.Equal(t, []int64{1, 2, 3}, []int64{ + candidates[0].summary.ID, candidates[1].summary.ID, candidates[2].summary.ID, + }) +} + +// This catches an InitSchema upgrade of an archive that predates the +// Directory projection: the tables and triggers must be installed and every +// existing person indexed by a backfill that runs exactly once. +func TestInitSchemaBackfillsDirectoryProjectionOnce(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st, err := OpenForTest(filepath.Join(t.TempDir(), "directory-backfill.db")) + require.NoError(err) + t.Cleanup(func() { assert.NoError(st.Close()) }) + require.NoError(st.InitSchema()) + _, err = st.DB().Exec(`INSERT INTO persons (vcard_uid, display_name) VALUES ('directory-backfill', 'Backfill Person')`) + require.NoError(err) + dropDirectoryProjection(t, st) + require.NoError(st.InitSchema()) + + var orderKey string + require.NoError(st.DB().QueryRow(`SELECT order_key FROM directory_people WHERE person_id = 1`).Scan(&orderKey)) + assert.Equal(directoryKey("backfill person"), orderKey) + var dirty int + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM directory_projection_dirty`).Scan(&dirty)) + assert.Zero(dirty) + + _, err = st.DB().Exec(`UPDATE directory_people SET order_key = ? WHERE person_id = 1`, directoryKey("untouched")) + require.NoError(err) + require.NoError(st.InitSchema()) + require.NoError(st.DB().QueryRow(`SELECT order_key FROM directory_people WHERE person_id = 1`).Scan(&orderKey)) + assert.Equal(directoryKey("untouched"), orderKey, "a second InitSchema must not rebuild the projection") + var applied int + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM applied_migrations WHERE name LIKE 'directory_projection%'`).Scan(&applied)) + assert.Equal(1, applied) +} + +// dropDirectoryProjection returns a SQLite archive to the state before the +// Directory projection existed: no projection tables, triggers, or ledger row. +func dropDirectoryProjection(t *testing.T, st *Store) { + t.Helper() + triggers := directoryTriggerNames(t, st) + require.NotEmpty(t, triggers) + for _, name := range triggers { + _, err := st.DB().Exec(`DROP TRIGGER ` + name) + require.NoError(t, err) + } + for _, table := range []string{ + "directory_people", "directory_person_tokens", "directory_person_token_deletes", + "directory_person_filters", "directory_projection_dirty", + } { + _, err := st.DB().Exec(`DROP TABLE ` + table) + require.NoError(t, err) + } + _, err := st.DB().Exec(`DELETE FROM applied_migrations WHERE name = ?`, migrationDirectoryProjectionV1) + require.NoError(t, err) + st.directoryProjectionReady = false +} + +func directoryTriggerNames(t *testing.T, st *Store) []string { + t.Helper() + rows, err := st.DB().Query(`SELECT name FROM sqlite_master WHERE type = 'trigger' AND name LIKE 'directory_dirty_%'`) + require.NoError(t, err) + defer func() { require.NoError(t, rows.Close()) }() + names := make([]string, 0) + for rows.Next() { + var name string + require.NoError(t, rows.Scan(&name)) + names = append(names, name) + } + require.NoError(t, rows.Err()) + return names +} + +// This catches a writable archive reopen that leaves the Directory projection +// disabled even though its migrations and tables are already installed. +func TestOpenExistingDirectoryProjectionRefreshesDirtyRows(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + path := filepath.Join(t.TempDir(), "directory-reopen.db") + seed, err := OpenForTest(path) + require.NoError(err) + require.NoError(seed.InitSchema()) + require.NoError(seed.Close()) + + reopened, err := OpenForTest(path) + require.NoError(err) + t.Cleanup(func() { assert.NoError(reopened.Close()) }) + _, err = reopened.DB().Exec(`INSERT INTO persons (vcard_uid, display_name) VALUES ('directory-reopen', 'Reopened Person')`) + require.NoError(err) + + page, err := reopened.DirectoryPeoplePageContext(t.Context(), DirectoryPeopleQuery{}) + require.NoError(err) + require.Len(page.People, 1) + require.NotNil(page.People[0].DisplayName) + assert.Equal("Reopened Person", *page.People[0].DisplayName) +} + +// This catches SQLite inheriting the outer contact-state UPSERT conflict +// policy inside the Directory dirty trigger. A person already in the dirty +// queue must not turn a valid contact-state insertion into a duplicate error. +func TestDirectoryDirtyContactStateTriggerIgnoresPreexistingDirtyRowDuringUpsertSQLite(t *testing.T) { + require := require.New(t) + st, err := OpenForTest(filepath.Join(t.TempDir(), "directory-contact-upsert.db")) + require.NoError(err) + t.Cleanup(func() { assert.NoError(t, st.Close()) }) + require.NoError(st.InitSchema()) + result, err := st.DB().Exec(`INSERT INTO persons (vcard_uid, display_name) VALUES ('directory-contact-upsert', 'Contact Person')`) + require.NoError(err) + personID, err := result.LastInsertId() + require.NoError(err) + _, err = st.DB().Exec(`DELETE FROM directory_projection_dirty WHERE person_id = ?`, personID) + require.NoError(err) + _, err = st.DB().Exec(`INSERT INTO person_contact_state (person_id, interaction_count) VALUES (?, 1)`, personID) + require.NoError(err) + _, err = st.DB().Exec(`INSERT OR IGNORE INTO directory_projection_dirty(person_id) VALUES (?)`, personID) + require.NoError(err) + + err = applyDirectoryContactAddition(t.Context(), st, personID, 2) + require.NoError(err) + var interactionCount int64 + require.NoError(st.DB().QueryRow(`SELECT interaction_count FROM person_contact_state WHERE person_id = ?`, personID).Scan(&interactionCount)) + assert.Equal(t, int64(2), interactionCount) +} + +func applyDirectoryContactAddition(ctx context.Context, st *Store, personID, messageID int64) error { + return st.withTxContext(ctx, func(tx *loggedTx) error { + return st.applyContactAdditionTx(ctx, tx, personID, ActivityEvent{ + MessageID: messageID, Channel: ChannelEmail, + OccurredAt: time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC), + Direction: DirectionInbound, + }, ContactRevisions{}, true) + }) +} + +// This instruments the actual indexed token relation used by candidate +// selection; a full current-profile projection cannot satisfy this plan. +func TestDirectoryProjectionTokenLookupUsesIndex(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st, err := OpenForTest(filepath.Join(t.TempDir(), "directory-plan.db")) + require.NoError(err) + t.Cleanup(func() { assert.NoError(st.Close()) }) + require.NoError(st.InitSchema()) + for index := range 128 { + _, err := st.DB().Exec(`INSERT INTO persons (vcard_uid, display_name) VALUES (?, ?)`, fmt.Sprintf("plan-%03d", index), fmt.Sprintf("Plan Person %03d", index)) + require.NoError(err) + } + require.NoError(st.refreshDirectoryProjectionsContext(context.Background())) + rows, err := st.DB().Query(`EXPLAIN QUERY PLAN SELECT person_id FROM directory_person_tokens WHERE token_key = ?`, directoryKey("plan")) + require.NoError(err) + defer func() { _ = rows.Close() }() + var details []string + for rows.Next() { + var selectID, order, from int + var detail string + require.NoError(rows.Scan(&selectID, &order, &from, &detail)) + details = append(details, detail) + } + require.NoError(rows.Err()) + assert.Contains(strings.Join(details, "\n"), "idx_directory_person_tokens_lookup") + rows, err = st.DB().Query(`EXPLAIN QUERY PLAN SELECT person_id FROM directory_person_tokens WHERE token_key >= ? AND token_key < ?`, directoryKey("plan"), directoryPrefixEnd(directoryKey("plan"))) + require.NoError(err) + defer func() { _ = rows.Close() }() + details = nil + for rows.Next() { + var selectID, order, from int + var detail string + require.NoError(rows.Scan(&selectID, &order, &from, &detail)) + details = append(details, detail) + } + require.NoError(rows.Err()) + assert.Contains(strings.Join(details, "\n"), "idx_directory_person_tokens_lookup") +} + +func TestReadOnlyDirectoryRejectsDirtyProjectionUntilWriterRefreshes(t *testing.T) { + require := require.New(t) + path := filepath.Join(t.TempDir(), "directory-reader.db") + writer, err := OpenForTest(path) + require.NoError(err) + t.Cleanup(func() { assert.NoError(t, writer.Close()) }) + require.NoError(writer.InitSchema()) + _, err = writer.DB().Exec(`INSERT INTO persons (vcard_uid, display_name) VALUES ('reader-person', 'Before Refresh')`) + require.NoError(err) + require.NoError(writer.RefreshDirectoryProjectionContext(context.Background())) + _, err = writer.DB().Exec(`UPDATE persons SET display_name = 'After Refresh' WHERE id = 1`) + require.NoError(err) + reader, err := OpenReadOnly(path) + require.NoError(err) + t.Cleanup(func() { assert.NoError(t, reader.Close()) }) + _, err = reader.DirectoryPeoplePageContext(context.Background(), DirectoryPeopleQuery{}) + require.ErrorIs(err, ErrDirectoryProjectionStale) + require.NoError(writer.RefreshDirectoryProjectionContext(context.Background())) + page, err := reader.DirectoryPeoplePageContext(context.Background(), DirectoryPeopleQuery{Query: "after"}) + require.NoError(err) + require.Len(page.People, 1) +} + +// This catches the freshness-check/use race at the actual database-driver +// boundary. The second connection commits after the serving transaction has +// qualified projection freshness but before it reads Directory rows. A +// correct implementation serves one internally consistent older snapshot; +// the next call observes or refreshes the dirty row. +func TestDirectoryFreshnessDecisionSharesServingSnapshot(t *testing.T) { + for _, readOnly := range []bool{false, true} { + t.Run(fmt.Sprintf("read_only_%t", readOnly), func(t *testing.T) { + require := require.New(t) + path := filepath.Join(t.TempDir(), "directory-snapshot.db") + writer, err := OpenForTest(path) + require.NoError(err) + t.Cleanup(func() { assert.NoError(t, writer.Close()) }) + require.NoError(writer.InitSchema()) + _, err = writer.DB().Exec(`INSERT INTO persons (vcard_uid, display_name) VALUES ('snapshot-person', 'Before Refresh')`) + require.NoError(err) + require.NoError(writer.RefreshDirectoryProjectionContext(t.Context())) + + gate := newDirectorySnapshotGate("SELECT EXISTS (SELECT 1 FROM directory_projection_dirty)") + reader := newDirectorySnapshotGateStore(t, path, gate, readOnly) + ctx, cancel := context.WithTimeout(context.WithValue(t.Context(), directorySnapshotGateKey{}, gate), 5*time.Second) + defer cancel() + + type result struct { + page *DirectoryPeoplePage + err error + } + results := make(chan result, 1) + go func() { + page, err := reader.DirectoryPeoplePageContext(ctx, DirectoryPeopleQuery{}) + results <- result{page: page, err: err} + }() + waitDirectorySnapshotSignal(t, gate.paused, "Directory freshness check did not pause") + _, err = writer.DB().Exec(`UPDATE persons SET display_name = 'After Refresh' WHERE id = 1`) + require.NoError(err) + gate.release() + + got := waitDirectorySnapshotResult(t, results) + require.NoError(got.err) + require.Len(got.page.People, 1) + require.NotNil(got.page.People[0].DisplayName) + assert.Equal(t, "Before Refresh", *got.page.People[0].DisplayName) + + if readOnly { + _, err = reader.DirectoryPeoplePageContext(t.Context(), DirectoryPeopleQuery{}) + require.ErrorIs(err, ErrDirectoryProjectionStale) + } else { + page, pageErr := reader.DirectoryPeoplePageContext(t.Context(), DirectoryPeopleQuery{Query: "after"}) + require.NoError(pageErr) + require.Len(page.People, 1) + } + }) + } +} + +func TestWritableDirectoryRefreshRetriesDirtySnapshotContention(t *testing.T) { + requirements := require.New(t) + path := filepath.Join(t.TempDir(), "directory-dirty-contention.db") + writer, err := OpenForTest(path) + requirements.NoError(err) + t.Cleanup(func() { assert.NoError(t, writer.Close()) }) + requirements.NoError(writer.InitSchema()) + _, err = writer.DB().Exec(`INSERT INTO persons (vcard_uid, display_name) VALUES ('dirty-contention', 'Initial Name')`) + requirements.NoError(err) + requirements.NoError(writer.RefreshDirectoryProjectionContext(t.Context())) + _, err = writer.DB().Exec(`UPDATE persons SET display_name = 'First Change' WHERE id = 1`) + requirements.NoError(err) + + gate := newDirectorySnapshotGate("SELECT EXISTS (SELECT 1 FROM directory_projection_dirty)") + reader := newDirectorySnapshotGateStore(t, path, gate, false) + ctx, cancel := context.WithTimeout(context.WithValue(t.Context(), directorySnapshotGateKey{}, gate), 5*time.Second) + defer cancel() + + type result struct { + page *DirectoryPeoplePage + err error + } + results := make(chan result, 1) + go func() { + page, err := reader.DirectoryPeoplePageContext(ctx, DirectoryPeopleQuery{}) + results <- result{page: page, err: err} + }() + waitDirectorySnapshotSignal(t, gate.paused, "Directory dirty check did not pause") + _, err = writer.DB().Exec(`UPDATE persons SET display_name = 'Second Change' WHERE id = 1`) + requirements.NoError(err) + gate.release() + + got := waitDirectorySnapshotResult(t, results) + requirements.NoError(got.err) + requirements.Len(got.page.People, 1) + requirements.NotNil(got.page.People[0].DisplayName) + assert.Equal(t, "Second Change", *got.page.People[0].DisplayName) +} + +type directorySnapshotGateKey struct{} + +type directorySnapshotGate struct { + query string + paused chan struct{} + releaseRead chan struct{} + pauseOnce sync.Once + releaseOnce sync.Once +} + +func (g *directorySnapshotGate) pause(ctx context.Context) { + g.pauseOnce.Do(func() { + close(g.paused) + select { + case <-g.releaseRead: + case <-ctx.Done(): + } + }) +} + +func newDirectorySnapshotGate(query string) *directorySnapshotGate { + return &directorySnapshotGate{ + query: query, paused: make(chan struct{}), releaseRead: make(chan struct{}), + } +} + +func (g *directorySnapshotGate) release() { + g.releaseOnce.Do(func() { close(g.releaseRead) }) +} + +type directorySnapshotConnector struct { + driver driver.Driver + dsn string + gate *directorySnapshotGate +} + +func (c *directorySnapshotConnector) Connect(context.Context) (driver.Conn, error) { + conn, err := c.driver.Open(c.dsn) + if err != nil { + return nil, err + } + return &directorySnapshotConn{Conn: conn, gate: c.gate}, nil +} + +func (c *directorySnapshotConnector) Driver() driver.Driver { return c.driver } + +type directorySnapshotConn struct { + driver.Conn + + gate *directorySnapshotGate +} + +func (c *directorySnapshotConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + queryer, ok := c.Conn.(driver.QueryerContext) + if !ok { + return nil, driver.ErrSkip + } + rows, err := queryer.QueryContext(ctx, query, args) + if err != nil || ctx.Value(directorySnapshotGateKey{}) != c.gate || + !strings.Contains(strings.Join(strings.Fields(query), " "), c.gate.query) { + return rows, err + } + return &directorySnapshotRows{Rows: rows, ctx: ctx, gate: c.gate}, nil +} + +func (c *directorySnapshotConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + if execer, ok := c.Conn.(driver.ExecerContext); ok { + result, err := execer.ExecContext(ctx, query, args) + if err == nil && ctx.Value(directorySnapshotGateKey{}) == c.gate && + strings.Contains(strings.Join(strings.Fields(query), " "), c.gate.query) { + c.gate.pause(ctx) + } + return result, err + } + return nil, driver.ErrSkip +} + +func (c *directorySnapshotConn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) { + if preparer, ok := c.Conn.(driver.ConnPrepareContext); ok { + return preparer.PrepareContext(ctx, query) + } + return c.Prepare(query) +} + +func (c *directorySnapshotConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { + if beginner, ok := c.Conn.(driver.ConnBeginTx); ok { + return beginner.BeginTx(ctx, opts) + } + return nil, driver.ErrSkip +} + +func (c *directorySnapshotConn) Ping(ctx context.Context) error { + if pinger, ok := c.Conn.(driver.Pinger); ok { + return pinger.Ping(ctx) + } + return nil +} + +func (c *directorySnapshotConn) ResetSession(ctx context.Context) error { + if resetter, ok := c.Conn.(driver.SessionResetter); ok { + return resetter.ResetSession(ctx) + } + return nil +} + +func (c *directorySnapshotConn) IsValid() bool { + if validator, ok := c.Conn.(driver.Validator); ok { + return validator.IsValid() + } + return true +} + +func (c *directorySnapshotConn) CheckNamedValue(value *driver.NamedValue) error { + if checker, ok := c.Conn.(driver.NamedValueChecker); ok { + return checker.CheckNamedValue(value) + } + return driver.ErrSkip +} + +type directorySnapshotRows struct { + driver.Rows + + ctx context.Context + gate *directorySnapshotGate +} + +func (r *directorySnapshotRows) Close() error { + err := r.Rows.Close() + r.gate.pause(r.ctx) + return err +} + +func newDirectorySnapshotGateStore(t *testing.T, path string, gate *directorySnapshotGate, readOnly bool) *Store { + t.Helper() + sqliteDriver := &sqlite3.SQLiteDriver{ConnectHook: func(conn *sqlite3.SQLiteConn) error { + return conn.RegisterFunc(sqliteutil.UnicodeLowerFunction, strings.ToLower, true) + }} + connector := &directorySnapshotConnector{ + driver: sqliteDriver, dsn: path + testSQLiteParams, gate: gate, + } + db := sql.OpenDB(connector) + db.SetMaxOpenConns(4) + dialect := &SQLiteDialect{} + st := &Store{ + db: newLoggedDB(db, dialect.Rebind), dbPath: path, dialect: dialect, + readOnly: readOnly, directoryProjectionReady: true, + } + t.Cleanup(func() { assert.NoError(t, st.Close()) }) + return st +} + +func waitDirectorySnapshotSignal(t *testing.T, signal <-chan struct{}, message string) { + t.Helper() + select { + case <-signal: + case <-time.After(5 * time.Second): + require.FailNow(t, message) + } +} + +func waitDirectorySnapshotResult[T any](t *testing.T, results <-chan T) T { + t.Helper() + select { + case result := <-results: + return result + case <-time.After(5 * time.Second): + require.FailNow(t, "Directory query did not finish") + var zero T + return zero + } +} diff --git a/internal/store/person_directory_projection.go b/internal/store/person_directory_projection.go new file mode 100644 index 000000000..4989d97e2 --- /dev/null +++ b/internal/store/person_directory_projection.go @@ -0,0 +1,580 @@ +package store + +import ( + "context" + "database/sql" + "encoding/hex" + "fmt" + "log/slog" + "slices" + "sort" + "strings" + "unicode" + + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" +) + +const ( + migrationDirectoryProjectionV1 = "directory_projection_v1" + personNamesTableName = "person_names" +) + +// ensureDirectoryProjectionInfrastructure installs the durable, Go-canonical +// Directory projection. Base-table triggers only enqueue changed person IDs; +// Store transactions refresh those IDs with the canonical Go representation +// before commit, which keeps SQLite and PostgreSQL behavior identical. +func (s *Store) ensureDirectoryProjectionInfrastructure(ctx context.Context) error { + for _, statement := range []string{ + `CREATE TABLE IF NOT EXISTS directory_people ( + person_id BIGINT PRIMARY KEY, + order_key TEXT NOT NULL, + contact_state TEXT NOT NULL, + primary_channel TEXT NOT NULL, + last_contact_key TEXT NOT NULL DEFAULT '' + )`, + `CREATE TABLE IF NOT EXISTS directory_person_tokens ( + person_id BIGINT NOT NULL, + token_key TEXT NOT NULL, + PRIMARY KEY (person_id, token_key) + )`, + `CREATE TABLE IF NOT EXISTS directory_person_token_deletes ( + person_id BIGINT NOT NULL, + delete_key TEXT NOT NULL, + PRIMARY KEY (person_id, delete_key) + )`, + `CREATE TABLE IF NOT EXISTS directory_person_filters ( + person_id BIGINT NOT NULL, + filter_kind TEXT NOT NULL, + value_key TEXT NOT NULL, + PRIMARY KEY (person_id, filter_kind, value_key) + )`, + `CREATE TABLE IF NOT EXISTS directory_projection_dirty ( + person_id BIGINT PRIMARY KEY + )`, + `CREATE INDEX IF NOT EXISTS idx_directory_people_order + ON directory_people(order_key, person_id)`, + `CREATE INDEX IF NOT EXISTS idx_directory_people_contact + ON directory_people(contact_state, primary_channel, order_key, person_id)`, + `CREATE INDEX IF NOT EXISTS idx_directory_person_tokens_lookup + ON directory_person_tokens(token_key, person_id)`, + `CREATE INDEX IF NOT EXISTS idx_directory_person_token_deletes_lookup + ON directory_person_token_deletes(delete_key, person_id)`, + `CREATE INDEX IF NOT EXISTS idx_directory_person_filters_lookup + ON directory_person_filters(filter_kind, value_key, person_id)`, + `CREATE INDEX IF NOT EXISTS idx_directory_people_last_contact + ON directory_people(last_contact_key, order_key, person_id)`, + } { + if _, err := s.db.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("create directory projection: %w", err) + } + } + if err := s.installDirectoryProjectionTriggers(ctx); err != nil { + return err + } + s.directoryProjectionReady = true + return nil +} + +func (s *Store) installDirectoryProjectionTriggers(ctx context.Context) error { + if s.IsPostgreSQL() { + return s.installPostgresDirectoryProjectionTriggers(ctx) + } + // The projection is installed once, so every trigger is created IF NOT + // EXISTS. The migration backfill that follows marks every person dirty, so + // a base write racing the first install is refreshed anyway. + triggers := []string{ + `CREATE TRIGGER IF NOT EXISTS directory_dirty_person_insert AFTER INSERT ON persons BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (NEW.id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_person_update AFTER UPDATE ON persons BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (NEW.id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_person_delete AFTER DELETE ON persons BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (OLD.id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_names_insert AFTER INSERT ON person_names BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (NEW.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_names_update AFTER UPDATE ON person_names BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (OLD.person_id) ON CONFLICT(person_id) DO NOTHING; + INSERT INTO directory_projection_dirty(person_id) VALUES (NEW.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_names_delete AFTER DELETE ON person_names BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (OLD.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_points_insert AFTER INSERT ON person_contact_points BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (NEW.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_points_update AFTER UPDATE ON person_contact_points BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (OLD.person_id) ON CONFLICT(person_id) DO NOTHING; + INSERT INTO directory_projection_dirty(person_id) VALUES (NEW.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_points_delete AFTER DELETE ON person_contact_points BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (OLD.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_categories_insert AFTER INSERT ON person_categories BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (NEW.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_categories_update AFTER UPDATE ON person_categories BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (OLD.person_id) ON CONFLICT(person_id) DO NOTHING; + INSERT INTO directory_projection_dirty(person_id) VALUES (NEW.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_categories_delete AFTER DELETE ON person_categories BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (OLD.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_employments_insert AFTER INSERT ON employments BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (NEW.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_employments_update AFTER UPDATE ON employments BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (OLD.person_id) ON CONFLICT(person_id) DO NOTHING; + INSERT INTO directory_projection_dirty(person_id) VALUES (NEW.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_employments_delete AFTER DELETE ON employments BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (OLD.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_contact_state_insert AFTER INSERT ON person_contact_state BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (NEW.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_contact_state_update AFTER UPDATE ON person_contact_state BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (OLD.person_id) ON CONFLICT(person_id) DO NOTHING; + INSERT INTO directory_projection_dirty(person_id) VALUES (NEW.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_contact_state_delete AFTER DELETE ON person_contact_state BEGIN + INSERT INTO directory_projection_dirty(person_id) VALUES (OLD.person_id) ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_organizations_insert AFTER INSERT ON organizations BEGIN + INSERT INTO directory_projection_dirty(person_id) + SELECT person_id FROM employments WHERE organization_id = NEW.id + ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_organizations_update AFTER UPDATE ON organizations BEGIN + INSERT INTO directory_projection_dirty(person_id) + SELECT person_id FROM employments WHERE organization_id = NEW.id + ON CONFLICT(person_id) DO NOTHING; END`, + `CREATE TRIGGER IF NOT EXISTS directory_dirty_organizations_delete AFTER DELETE ON organizations BEGIN + INSERT INTO directory_projection_dirty(person_id) + SELECT person_id FROM employments WHERE organization_id = OLD.id + ON CONFLICT(person_id) DO NOTHING; END`, + } + for _, trigger := range triggers { + if _, err := s.db.ExecContext(ctx, trigger); err != nil { + return fmt.Errorf("install SQLite directory projection trigger: %w", err) + } + } + return nil +} + +func (s *Store) installPostgresDirectoryProjectionTriggers(ctx context.Context) error { + statements := []string{ + `CREATE OR REPLACE FUNCTION directory_projection_mark_dirty() RETURNS trigger AS $$ + BEGIN + IF TG_OP = 'UPDATE' THEN + INSERT INTO directory_projection_dirty(person_id) + VALUES ((to_jsonb(OLD)->>TG_ARGV[0])::bigint), ((to_jsonb(NEW)->>TG_ARGV[0])::bigint) + ON CONFLICT DO NOTHING; + ELSIF TG_OP = 'DELETE' THEN + INSERT INTO directory_projection_dirty(person_id) + VALUES ((to_jsonb(OLD)->>TG_ARGV[0])::bigint) ON CONFLICT DO NOTHING; + ELSE + INSERT INTO directory_projection_dirty(person_id) + VALUES ((to_jsonb(NEW)->>TG_ARGV[0])::bigint) ON CONFLICT DO NOTHING; + END IF; + RETURN NULL; + END $$ LANGUAGE plpgsql`, + `CREATE OR REPLACE FUNCTION directory_projection_mark_organization_dirty() RETURNS trigger AS $$ + DECLARE organization bigint; + BEGIN + IF TG_OP = 'DELETE' THEN organization := OLD.id; ELSE organization := NEW.id; END IF; + INSERT INTO directory_projection_dirty(person_id) + SELECT person_id FROM employments WHERE organization_id = organization + ON CONFLICT DO NOTHING; + RETURN NULL; + END $$ LANGUAGE plpgsql`, + } + for _, statement := range statements { + if _, err := s.db.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("install PostgreSQL directory projection function: %w", err) + } + } + for _, tableAndColumn := range [][2]string{ + {"persons", "id"}, {personNamesTableName, personMergePersonIDColumn}, {personContactPointsTableName, personMergePersonIDColumn}, + {"person_categories", personMergePersonIDColumn}, {"employments", personMergePersonIDColumn}, {"person_contact_state", personMergePersonIDColumn}, + } { + name := "directory_dirty_" + tableAndColumn[0] + if _, err := s.db.ExecContext(ctx, `CREATE OR REPLACE TRIGGER `+name+` AFTER INSERT OR UPDATE OR DELETE ON `+tableAndColumn[0]+` + FOR EACH ROW EXECUTE FUNCTION directory_projection_mark_dirty('`+tableAndColumn[1]+`')`); err != nil { + return fmt.Errorf("install PostgreSQL directory projection trigger: %w", err) + } + } + _, err := s.db.ExecContext(ctx, `CREATE OR REPLACE TRIGGER directory_dirty_organizations AFTER INSERT OR UPDATE OR DELETE ON organizations + FOR EACH ROW EXECUTE FUNCTION directory_projection_mark_organization_dirty()`) + if err != nil { + return fmt.Errorf("install PostgreSQL organization projection trigger: %w", err) + } + return nil +} + +func (s *Store) refreshDirectoryProjectionsContext(ctx context.Context) error { + if !s.directoryProjectionReady { + return nil + } + if s.readOnly { + dirty, err := directoryProjectionDirty(ctx, s.db) + if err != nil { + return err + } + if dirty { + return ErrDirectoryProjectionStale + } + return nil + } + return s.withTxContext(ctx, func(tx *loggedTx) error { + return s.refreshDirectoryProjectionsTx(ctx, tx) + }) +} + +func directoryProjectionDirty(ctx context.Context, querier contextRowQuerier) (bool, error) { + var dirty bool + if err := querier.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM directory_projection_dirty)`).Scan(&dirty); err != nil { + return false, fmt.Errorf("check stale directory projection: %w", err) + } + return dirty, nil +} + +func (s *Store) backfillDirectoryProjectionContext(ctx context.Context) error { + return s.runMaintenance(ctx, func(ctx context.Context, tx *loggedTx) error { + if _, err := tx.ExecContext(ctx, s.dialect.InsertOrIgnore( + `INSERT OR IGNORE INTO directory_projection_dirty(person_id) SELECT id FROM persons`, + )); err != nil { + return fmt.Errorf("seed directory projection backfill: %w", err) + } + return s.refreshDirectoryProjectionsTx(ctx, tx) + }) +} + +// withFreshDirectorySnapshotContext makes the freshness decision and every +// Directory result read from one read-only repeatable-read snapshot. A dirty +// writable view closes that snapshot and repairs the projection in a separate +// retried transaction whose first operation is the dirty-row claim. This keeps +// clean reads lock-free and prevents a deferred SQLite snapshot from failing +// when it tries to upgrade after another writer commits. +func (s *Store) withFreshDirectorySnapshotContext( + ctx context.Context, fn func(tx *loggedTx) error, +) error { + for { + refresh := false + opts := &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true} + err := s.withTxOptionsContext(ctx, opts, func(tx *loggedTx) error { + dirty, err := directoryProjectionDirty(ctx, tx) + if err != nil { + return err + } + if dirty { + if s.readOnly { + return ErrDirectoryProjectionStale + } + refresh = true + return nil + } + return fn(tx) + }) + if err != nil || !refresh { + return err + } + if err := retryBusyWriteErr(ctx, s, "refresh Directory projection", func() error { + return s.refreshDirectoryProjectionsContext(ctx) + }); err != nil { + return err + } + } +} + +// RefreshDirectoryProjectionContext flushes dirty projection rows after a +// caller-owned raw write, before a read-only Store serves Directory results. +func (s *Store) RefreshDirectoryProjectionContext(ctx context.Context) error { + if s.readOnly { + return ErrDirectoryProjectionStale + } + return s.refreshDirectoryProjectionsContext(ctx) +} + +// refreshDirectoryProjectionsTx claims every queued person and rebuilds +// their projection rows. Claiming is a DELETE ... RETURNING so it is the +// transaction's first write: SQLite takes the writer lock before any read +// (a deferred transaction that reads first cannot upgrade once another +// writer commits), and a concurrent PostgreSQL refresh waits on the row +// locks and then finds nothing left to claim instead of rebuilding the same +// person and colliding on the projection primary keys. +func (s *Store) refreshDirectoryProjectionsTx(ctx context.Context, tx *loggedTx) error { + if !s.directoryProjectionReady { + return nil + } + ids, err := claimDirtyDirectoryPeopleTx(ctx, tx) + if err != nil { + return err + } + return s.refreshDirectoryProjectionIDsTx(ctx, tx, ids) +} + +// refreshDirectoryProjectionsBeforeCommitTx keeps ordinary writes independent +// from concurrent profile-table DDL, snapshot tests, and other refreshes. +// PostgreSQL aborts a transaction on a lock or serialization failure, so the +// refresh runs behind a savepoint: contention rolls back only the derived +// projection work and leaves its dirty rows for the next refresh. +func (s *Store) refreshDirectoryProjectionsBeforeCommitTx(ctx context.Context, tx *loggedTx) error { + if !s.directoryProjectionReady { + return nil + } + dirty, err := directoryProjectionDirty(ctx, tx) + if err != nil || !dirty { + return err + } + if !s.IsPostgreSQL() { + return s.refreshDirectoryProjectionsTx(ctx, tx) + } + + const savepoint = "directory_projection_refresh" + if _, err := tx.ExecContext(ctx, "SAVEPOINT "+savepoint); err != nil { + return fmt.Errorf("create Directory projection refresh savepoint: %w", err) + } + _, refreshErr := tx.ExecContext(ctx, `LOCK TABLE + persons, person_names, person_contact_points, employments, + organizations, person_contact_state + IN ACCESS SHARE MODE NOWAIT`) + if refreshErr == nil { + refreshErr = s.refreshDirectoryProjectionsTx(ctx, tx) + } + if refreshErr == nil { + if _, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT "+savepoint); err != nil { + return fmt.Errorf("release Directory projection refresh savepoint: %w", err) + } + return nil + } + if _, err := tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT "+savepoint); err != nil { + return fmt.Errorf("rollback Directory projection refresh: refresh: %w; rollback: %w", refreshErr, err) + } + if _, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT "+savepoint); err != nil { + return fmt.Errorf("release deferred Directory projection refresh: %w", err) + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + if s.dialect.IsBusyError(refreshErr) || s.dialect.IsSerializationFailureError(refreshErr) || + s.dialect.IsConflictError(refreshErr) { + slog.Debug("defer Directory projection refresh after PostgreSQL contention", + "error", refreshErr.Error()) + return nil + } + return refreshErr +} + +func claimDirtyDirectoryPeopleTx(ctx context.Context, tx *loggedTx) ([]int64, error) { + rows, err := tx.QueryContext(ctx, `DELETE FROM directory_projection_dirty RETURNING person_id`) + if err != nil { + return nil, fmt.Errorf("claim dirty directory people: %w", err) + } + defer func() { _ = rows.Close() }() + ids := make([]int64, 0) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan dirty directory person: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate dirty directory people: %w", err) + } + if err := rows.Close(); err != nil { + return nil, fmt.Errorf("close dirty directory people: %w", err) + } + slices.Sort(ids) + return ids, nil +} + +func (s *Store) refreshDirectoryProjectionIDsTx(ctx context.Context, tx *loggedTx, ids []int64) error { + for _, id := range ids { + if err := s.refreshDirectoryPersonTx(ctx, tx, id); err != nil { + return err + } + } + return nil +} + +func (s *Store) refreshDirectoryPersonTx(ctx context.Context, tx *loggedTx, personID int64) error { + var displayName sql.NullString + err := tx.QueryRowContext(ctx, `SELECT display_name FROM persons WHERE id = ?`, personID).Scan(&displayName) + if err == sql.ErrNoRows { + return s.deleteDirectoryProjectionPersonTx(ctx, tx, personID) + } + if err != nil { + return fmt.Errorf("load directory person %d: %w", personID, err) + } + values := make([]string, 0, 8) + if displayName.Valid { + values = append(values, displayName.String) + } + for _, query := range []string{ + `SELECT COALESCE(formatted, '') || ' ' || COALESCE(family_name, '') || ' ' || COALESCE(given_name, '') || ' ' || COALESCE(additional_names, '') || ' ' || original_value + FROM person_names WHERE person_id = ? AND active_until IS NULL AND superseded_at IS NULL`, + `SELECT normalized_value FROM person_contact_points + WHERE person_id = ? AND active_until IS NULL AND superseded_at IS NULL`, + `SELECT organization.name FROM employments + JOIN organizations organization ON organization.id = employments.organization_id + WHERE employments.person_id = ? AND ` + s.dialect.BoolTrueExpr("employments.is_current") + ` + AND organization.merged_into_id IS NULL AND organization.retired_at IS NULL`, + } { + rows, err := tx.QueryContext(ctx, query, personID) + if err != nil { + return fmt.Errorf("load directory search values: %w", err) + } + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + _ = rows.Close() + return fmt.Errorf("scan directory search value: %w", err) + } + values = append(values, value) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close directory search values: %w", err) + } + } + contactState, primaryChannel, lastContactKey := "inactive", "", "" + var channel sql.NullString + var lastContact sql.NullTime + err = tx.QueryRowContext(ctx, `SELECT last_contact_channel, last_contact_at + FROM person_contact_state WHERE person_id = ?`, personID).Scan(&channel, &lastContact) + if err != nil && err != sql.ErrNoRows { + return fmt.Errorf("load directory contact state: %w", err) + } + if err == nil { + if lastContact.Valid { + contactState = "active" + lastContactKey = directoryLastContactKey(lastContact.Time) + } + if channel.Valid { + primaryChannel = normalizeDirectoryText(channel.String) + } + } + orderKey := directoryKey("") + if displayName.Valid { + orderKey = directoryKey(normalizeDirectoryText(displayName.String)) + } + if err := s.deleteDirectoryProjectionPersonTx(ctx, tx, personID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO directory_people (person_id, order_key, contact_state, primary_channel, last_contact_key) + VALUES (?, ?, ?, ?, ?)`, personID, orderKey, contactState, primaryChannel, lastContactKey); err != nil { + return fmt.Errorf("insert directory person: %w", err) + } + tokens := make(map[string]struct{}) + for _, value := range values { + for _, token := range directoryTokens(value) { + tokens[token] = struct{}{} + } + } + for token := range tokens { + if _, err := tx.ExecContext(ctx, `INSERT INTO directory_person_tokens (person_id, token_key) VALUES (?, ?)`, personID, directoryKey(token)); err != nil { + return fmt.Errorf("insert directory token: %w", err) + } + } + deleteKeys := make(map[string]struct{}) + for token := range tokens { + for _, key := range directoryDeleteKeys(token) { + deleteKeys[key] = struct{}{} + } + } + for key := range deleteKeys { + if _, err := tx.ExecContext(ctx, `INSERT INTO directory_person_token_deletes (person_id, delete_key) VALUES (?, ?)`, personID, directoryKey(key)); err != nil { + return fmt.Errorf("insert directory token delete key: %w", err) + } + } + if err := s.insertDirectoryFilterValuesTx(ctx, tx, personID, "category", `SELECT original_value FROM person_categories + WHERE person_id = ? AND active_until IS NULL AND superseded_at IS NULL`); err != nil { + return err + } + return s.insertDirectoryFilterValuesTx(ctx, tx, personID, "organization", `SELECT organization.name FROM employments + JOIN organizations organization ON organization.id = employments.organization_id + WHERE employments.person_id = ? AND `+s.dialect.BoolTrueExpr("employments.is_current")+` + AND organization.merged_into_id IS NULL AND organization.retired_at IS NULL`) +} + +func (s *Store) insertDirectoryFilterValuesTx(ctx context.Context, tx *loggedTx, personID int64, kind, query string) error { + rows, err := tx.QueryContext(ctx, query, personID) + if err != nil { + return fmt.Errorf("load directory %s filters: %w", kind, err) + } + keys := make(map[string]struct{}) + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + _ = rows.Close() + return fmt.Errorf("scan directory %s filter: %w", kind, err) + } + keys[directoryKey(normalizeDirectoryText(value))] = struct{}{} + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate directory %s filters: %w", kind, err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close directory %s filters: %w", kind, err) + } + sortedKeys := make([]string, 0, len(keys)) + for key := range keys { + sortedKeys = append(sortedKeys, key) + } + sort.Strings(sortedKeys) + for _, key := range sortedKeys { + if _, err := tx.ExecContext(ctx, `INSERT INTO directory_person_filters (person_id, filter_kind, value_key) VALUES (?, ?, ?)`, personID, kind, key); err != nil { + return fmt.Errorf("insert directory %s filter: %w", kind, err) + } + } + return nil +} + +func (s *Store) deleteDirectoryProjectionPersonTx(ctx context.Context, tx *loggedTx, personID int64) error { + for _, table := range []string{"directory_person_tokens", "directory_person_token_deletes", "directory_person_filters", "directory_people"} { + if _, err := tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE person_id = ?`, personID); err != nil { + return fmt.Errorf("delete directory projection %s: %w", table, err) + } + } + return nil +} + +func normalizeDirectoryText(value string) string { + return strings.Join(strings.Fields(foldDirectoryText(value)), " ") +} + +func directoryTokens(value string) []string { + value = foldDirectoryText(value) + return strings.FieldsFunc(value, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsNumber(r) + }) +} + +// foldDirectoryText builds its Caser per call: x/text documents a Caser as +// possibly stateful, so one shared value must not be used across goroutines. +func foldDirectoryText(value string) string { + return cases.Fold().String(norm.NFC.String(value)) +} + +func directoryKey(value string) string { + return hex.EncodeToString([]byte(value)) +} + +func directoryDeleteKeys(value string) []string { + runes := []rune(value) + keys := make([]string, 0, len(runes)) + seen := make(map[string]struct{}, len(runes)) + for index := range runes { + key := string(append(append([]rune{}, runes[:index]...), runes[index+1:]...)) + if _, exists := seen[key]; !exists { + seen[key] = struct{}{} + keys = append(keys, key) + } + } + return keys +} + +func directoryFuzzyTokenKeys(token string) []string { + keys := directoryDeleteKeys(token) + runes := []rune(token) + seen := make(map[string]struct{}, len(keys)+len(runes)) + for _, key := range keys { + seen[key] = struct{}{} + } + for index := 0; index+1 < len(runes); index++ { + swapped := append([]rune{}, runes...) + swapped[index], swapped[index+1] = swapped[index+1], swapped[index] + key := string(swapped) + if _, exists := seen[key]; !exists { + seen[key] = struct{}{} + keys = append(keys, key) + } + } + return keys +} diff --git a/internal/store/person_directory_test.go b/internal/store/person_directory_test.go new file mode 100644 index 000000000..4fa0d27de --- /dev/null +++ b/internal/store/person_directory_test.go @@ -0,0 +1,366 @@ +package store_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +// This catches a directory query that ignores its typo-tolerant lexical +// matching or any of its conjunctive profile filters. +func TestDirectoryPeoplePageContextFiltersRanksAndPaginates(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + alice := createDirectoryPerson(t, st, "Alice Example", "alice@example.test", "friend", "active", "Acme") + createDirectoryPerson(t, st, "Alicia Example", "alicia@example.test", "friend", "active", "Acme") + createDirectoryPerson(t, st, "Alice Other", "other@example.test", "colleague", "active", "Other Co") + + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{ + Query: "alcie", Category: "friend", Organization: "Acme", ContactState: "active", Limit: 1, + }) + require.NoError(err) + require.Len(page.People, 1) + assert.Equal(alice.ID, page.People[0].ID) + assert.Equal([]string{"friend"}, page.People[0].Categories) + assert.Equal([]string{"Acme"}, page.People[0].Organizations) + assert.Empty(page.NextCursor) +} + +// This catches a sort regression where one-edit typo matches outrank exact or +// prefix matches, which would make a Directory result order unpredictable. +func TestDirectoryPeoplePageContextRanksExactAndPrefixBeforeFuzzyMatches(t *testing.T) { + st := testutil.NewTestStore(t) + alice := createDirectoryPerson(t, st, "Alice Example", "alice@example.test", "friend", "active", "Acme") + alicf := createDirectoryPerson(t, st, "Alicf Example", "alicf@example.test", "friend", "active", "Acme") + + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Query: "alice"}) + require.NoError(t, err) + require.Len(t, page.People, 2) + assert.Equal(t, []int64{alice.ID, alicf.ID}, directoryPersonIDs(page.People)) +} + +// This catches backend-specific SQL lowercasing: Directory matching must use +// the same Go-canonical Unicode token representation on every backend. +func TestDirectoryPeoplePageContextMatchesUnicodeCaseFoldedTokens(t *testing.T) { + st := testutil.NewTestStore(t) + emile := createDirectoryPerson(t, st, "Émile Example", "emile@example.test", "friend", "active", "Acme") + + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Query: "émile"}) + require.NoError(t, err) + assert.Equal(t, []int64{emile.ID}, directoryPersonIDs(page.People)) +} + +// This catches canonical-equivalence regressions across every persisted key: +// composed and decomposed spellings must qualify, filter, sort, and resume +// through the same cursor sequence on the configured backend. +func TestDirectoryPeoplePageContextNormalizesCanonicalUnicodeAcrossKeys(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + composed := createDirectoryPerson(t, st, "Ålpha", "unicode-first@example.test", "Café", "active", "Ångström") + decomposed := createDirectoryPerson(t, st, "A\u030Alpha", "unicode-second@example.test", "Cafe\u0301", "active", "A\u030Angstro\u0308m") + + for _, query := range []string{"ålpha", "a\u030alpha"} { + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{ + Query: query, Category: "Cafe\u0301", Organization: "Ångström", + }) + require.NoError(err) + assert.Equal([]int64{composed.ID, decomposed.ID}, directoryPersonIDs(page.People)) + } + + first, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1}) + require.NoError(err) + require.NotEmpty(first.NextCursor) + second, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{ + Limit: 1, Cursor: first.NextCursor, + }) + require.NoError(err) + assert.Equal([]int64{composed.ID, decomposed.ID}, + append(directoryPersonIDs(first.People), directoryPersonIDs(second.People)...)) +} + +// This catches a search path that recognizes only a fixed punctuation list +// instead of the Unicode-aware lexical token boundaries used by Directory. +func TestDirectoryPeoplePageContextMatchesPunctuationDelimitedTokens(t *testing.T) { + st := testutil.NewTestStore(t) + alice := createDirectoryPerson(t, st, "Alice|Example", "alice@sample.test", "friend", "active", "Acme") + + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Query: "example"}) + require.NoError(t, err) + assert.Equal(t, []int64{alice.ID}, directoryPersonIDs(page.People)) +} + +// This catches keyset cursors that skip or duplicate rows, and cursors that +// accidentally permit a different normalized filter set. +func TestDirectoryPeoplePageContextPaginatesAndRejectsForeignCursor(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + alice := createDirectoryPerson(t, st, "Alice Example", "alice@example.test", "friend", "active", "Acme") + bob := createDirectoryPerson(t, st, "Bob Example", "bob@example.test", "friend", "active", "Acme") + carol := createDirectoryPerson(t, st, "Carol Example", "carol@example.test", "friend", "active", "Acme") + + first, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1}) + require.NoError(err) + require.Len(first.People, 1) + assert.Equal(alice.ID, first.People[0].ID) + require.NotEmpty(first.NextCursor) + + second, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1, Cursor: first.NextCursor}) + require.NoError(err) + require.Len(second.People, 1) + assert.Equal(bob.ID, second.People[0].ID) + require.NotEmpty(second.NextCursor) + + third, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1, Cursor: second.NextCursor}) + require.NoError(err) + require.Len(third.People, 1) + assert.Equal(carol.ID, third.People[0].ID) + assert.Empty(third.NextCursor) + + _, err = st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Query: "alice", Limit: 1, Cursor: first.NextCursor}) + require.ErrorIs(err, store.ErrInvalidDirectoryCursor) + _, err = st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Cursor: "not-a-directory-cursor"}) + require.ErrorIs(err, store.ErrInvalidDirectoryCursor) +} + +// This catches a cursor whose persisted SQL order key differs from the key +// encoded on page one. Unicode folding and repeated whitespace must still +// advance a limit-one sequence without a skipped row. +func TestDirectoryPeoplePageContextUsesCanonicalOrderKeyAcrossPages(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + first := createDirectoryPerson(t, st, "Ålice a", "first@sample.test", "friend", "active", "Acme") + second := createDirectoryPerson(t, st, "Ålice z", "second@sample.test", "friend", "active", "Acme") + + pageOne, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1}) + require.NoError(err) + require.NotEmpty(pageOne.NextCursor) + pageTwo, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1, Cursor: pageOne.NextCursor}) + require.NoError(err) + assert.Equal([]int64{first.ID}, directoryPersonIDs(pageOne.People)) + assert.Equal([]int64{second.ID}, directoryPersonIDs(pageTwo.People)) +} + +// This catches a cursor that becomes oversized when the persisted canonical +// order key is derived from an otherwise valid long display name. +func TestDirectoryPeoplePageContextLongNameCursorRoundTrip(t *testing.T) { + require := require.New(t) + st := testutil.NewTestStore(t) + first := createDirectoryPerson(t, st, strings.Repeat("a", 600), "long-first@sample.test", "friend", "active", "Acme") + second := createDirectoryPerson(t, st, "Zed", "long-second@sample.test", "friend", "active", "Acme") + + pageOne, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1}) + require.NoError(err) + require.NotEmpty(pageOne.NextCursor) + pageTwo, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1, Cursor: pageOne.NextCursor}) + require.NoError(err) + assert.Equal(t, []int64{first.ID, second.ID}, append(directoryPersonIDs(pageOne.People), directoryPersonIDs(pageTwo.People)...)) +} + +// This catches an ID-only cursor that silently resumes using an anchor whose +// persisted order key changed after the prior page was generated. +func TestDirectoryPeoplePageContextRejectsCursorAfterAnchorOrderMutation(t *testing.T) { + require := require.New(t) + st := testutil.NewTestStore(t) + first := createDirectoryPerson(t, st, "Alice", "mutation-first@sample.test", "friend", "active", "Acme") + createDirectoryPerson(t, st, "Bob", "mutation-second@sample.test", "friend", "active", "Acme") + + pageOne, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1}) + require.NoError(err) + require.NotEmpty(pageOne.NextCursor) + current, err := st.GetPersonContext(t.Context(), first.ID) + require.NoError(err) + _, err = st.UpdatePersonDisplayNameContext(t.Context(), first.ID, current.Revision, new("Zed")) + require.NoError(err) + + _, err = st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1, Cursor: pageOne.NextCursor}) + require.ErrorIs(err, store.ErrInvalidDirectoryCursor) +} + +// This catches a projection that is not refreshed after direct bulk mutation +// paths which the dirty triggers centralize for Store reads. +func TestDirectoryPeoplePageContextRefreshesOrganizationAndContactState(t *testing.T) { + require := require.New(t) + st := testutil.NewTestStore(t) + person := createDirectoryPerson(t, st, "Alice Example", "alice@sample.test", "friend", "inactive", "Acme") + ctx := t.Context() + _, err := st.DB().ExecContext(ctx, `UPDATE organizations SET name = 'Renamed Org' WHERE name = 'Acme'`) + require.NoError(err) + _, err = st.DB().ExecContext(ctx, st.Rebind(`INSERT INTO person_contact_state (person_id, last_contact_at, last_contact_channel, interaction_count) VALUES (?, CURRENT_TIMESTAMP, 'chat', 1)`), person.ID) + require.NoError(err) + + page, err := st.DirectoryPeoplePageContext(ctx, store.DirectoryPeopleQuery{Organization: "renamed org", ContactState: "active", PrimaryChannel: "chat"}) + require.NoError(err) + assert.Equal(t, []int64{person.ID}, directoryPersonIDs(page.People)) +} + +// This catches a projection refresh that inserts one organization filter per +// employment row instead of one normalized filter per person and organization. +func TestDirectoryProjectionDeduplicatesOrganizationAcrossCurrentEmployments(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + person := createDirectoryPerson(t, st, "Alice Example", "alice@sample.test", "friend", "inactive", "Acme") + employments, err := st.ListEmploymentsContext(t.Context(), store.EmploymentFilter{ + PersonID: person.ID, CurrentOnly: true, + }) + require.NoError(err) + require.Len(employments, 1) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`INSERT INTO employments ( + person_id, organization_id, title, title_normalized, is_current, source + ) VALUES (?, ?, 'Advisor', 'advisor', ?, 'user')`), person.ID, employments[0].OrganizationID, true) + require.NoError(err) + require.NoError(st.RefreshDirectoryProjectionContext(t.Context())) + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Organization: "Acme"}) + require.NoError(err) + assert.Equal([]int64{person.ID}, directoryPersonIDs(page.People)) + var filterCount int64 + require.NoError(st.DB().QueryRowContext(t.Context(), st.Rebind(`SELECT COUNT(*) + FROM directory_person_filters + WHERE person_id = ? AND filter_kind = 'organization'`), person.ID).Scan(&filterCount)) + assert.Equal(int64(1), filterCount) +} + +// This catches a query path that treats an empty qualified result as an error +// or emits a cursor without a corresponding person. +func TestDirectoryPeoplePageContextReturnsEmptyPage(t *testing.T) { + st := testutil.NewTestStore(t) + createDirectoryPerson(t, st, "Alice Example", "alice@example.test", "friend", "active", "Acme") + + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Query: "no-such-person"}) + require.NoError(t, err) + assert.Empty(t, page.People) + assert.Empty(t, page.NextCursor) +} + +// This catches a directory filter that uses a contact-point service instead +// of the primary activity channel from the contact-state projection. +func TestDirectoryPeoplePageContextFiltersPrimaryChannel(t *testing.T) { + require := require.New(t) + st := testutil.NewTestStore(t) + ctx := t.Context() + alice := createDirectoryPerson(t, st, "Alice Example", "alice@example.test", "friend", "active", "Acme") + bob := createDirectoryPerson(t, st, "Bob Example", "bob@example.test", "friend", "active", "Acme") + _, err := st.DB().ExecContext(ctx, + st.Rebind(`UPDATE person_contact_state SET last_contact_channel = 'email' WHERE person_id = ?`), alice.ID) + require.NoError(err) + _, err = st.DB().ExecContext(ctx, + st.Rebind(`UPDATE person_contact_state SET last_contact_channel = 'chat' WHERE person_id = ?`), bob.ID) + require.NoError(err) + + page, err := st.DirectoryPeoplePageContext(ctx, store.DirectoryPeopleQuery{PrimaryChannel: " email "}) + require.NoError(err) + require.Len(page.People, 1) + assert.Equal(t, alice.ID, page.People[0].ID) +} + +// This catches cursors that decode as JSON but do not contain the normalized +// ordering tuple emitted by the store. +func TestDirectoryPeoplePageContextRejectsMalformedOrderingCursor(t *testing.T) { + require := require.New(t) + st := testutil.NewTestStore(t) + createDirectoryPerson(t, st, "Alice Example", "alice@example.test", "friend", "active", "Acme") + createDirectoryPerson(t, st, "Bob Example", "bob@example.test", "friend", "active", "Acme") + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1}) + require.NoError(err) + require.NotEmpty(page.NextCursor) + + encoded, err := base64.RawURLEncoding.DecodeString(page.NextCursor) + require.NoError(err) + var cursor map[string]any + require.NoError(json.Unmarshal(encoded, &cursor)) + cursor["display_name"] = " Alice Example " + encoded, err = json.Marshal(cursor) + require.NoError(err) + _, err = st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{ + Limit: 1, Cursor: base64.RawURLEncoding.EncodeToString(encoded), + }) + require.ErrorIs(err, store.ErrInvalidDirectoryCursor) +} + +// This catches a cursor whose relevance tier is changed while its person and +// display-name ordering anchor remain valid. The complete ordering tuple must +// still describe the current search projection before pagination resumes. +func TestDirectoryPeoplePageContextRejectsChangedMatchQualityCursor(t *testing.T) { + require := require.New(t) + st := testutil.NewTestStore(t) + createDirectoryPerson(t, st, "Alice", "alice-exact@example.test", "friend", "active", "Acme") + createDirectoryPerson(t, st, "Alicef", "alice-prefix@example.test", "friend", "active", "Acme") + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Query: "alice", Limit: 1}) + require.NoError(err) + require.NotEmpty(page.NextCursor) + + encoded, err := base64.RawURLEncoding.DecodeString(page.NextCursor) + require.NoError(err) + var cursor map[string]any + require.NoError(json.Unmarshal(encoded, &cursor)) + cursor["quality"] = float64(2) + encoded, err = json.Marshal(cursor) + require.NoError(err) + _, err = st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{ + Query: "alice", Limit: 1, Cursor: base64.RawURLEncoding.EncodeToString(encoded), + }) + require.ErrorIs(err, store.ErrInvalidDirectoryCursor) +} + +func directoryPersonIDs(people []store.DirectoryPersonSummary) []int64 { + ids := make([]int64, 0, len(people)) + for _, person := range people { + ids = append(ids, person.ID) + } + return ids +} + +func createDirectoryPerson( + t *testing.T, + st *store.Store, + displayName, email, category, contactState, organizationName string, +) *store.Person { + t.Helper() + ctx := context.Background() + participantID, err := st.EnsureParticipantByIdentifier("email", email, displayName) + require.NoError(t, err) + person, _, err := st.CreatePersonFromParticipantContext(ctx, participantID) + require.NoError(t, err) + person, err = st.UpdatePersonDisplayNameContext(ctx, person.ID, person.Revision, &displayName) + require.NoError(t, err) + _, err = st.AddPersonNameContext(ctx, person.ID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: &displayName, OriginalValue: displayName, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(t, err) + _, err = st.AddPersonContactPointContext(ctx, person.ID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: email, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(t, err) + _, err = st.AddPersonCategoryContext(ctx, person.ID, store.PersonCategoryInput{ + OriginalValue: category, Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(t, err) + organization, err := st.CreateOrganizationContext(ctx, store.OrganizationInput{ + Name: organizationName, Kind: store.OrganizationKindCompany, + }) + require.NoError(t, err) + _, err = st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: person.ID, OrganizationID: organization.ID, Source: store.ProvenanceUser, + }) + require.NoError(t, err) + if contactState == "active" { + _, err = st.DB().ExecContext(ctx, st.Rebind(`INSERT INTO person_contact_state ( + person_id, last_contact_at, interaction_count + ) VALUES (?, CURRENT_TIMESTAMP, 1)`), person.ID) + require.NoError(t, err) + } + return person +} diff --git a/internal/store/person_enrichment_runs_test.go b/internal/store/person_enrichment_runs_test.go index 9e7ad8569..ffec4e1bd 100644 --- a/internal/store/person_enrichment_runs_test.go +++ b/internal/store/person_enrichment_runs_test.go @@ -60,6 +60,53 @@ func TestPersonEnrichmentClaimLocksRunBeforeBindingWork(t *testing.T) { require.ErrorIs(t, <-completeErr, store.ErrRunNotTerminal) } +func TestPersonEnrichmentClaimRetriesSQLiteSnapshotContention(t *testing.T) { + require := require.New(t) + f := newEnrichmentWorkFixture(t) + if f.store.IsPostgreSQL() { + t.Skip("SQLite snapshot contention requires the SQLite backend") + } + run := f.startRun(t, "claim-snapshot-contention") + f.enqueue(t) + + claimRead := make(chan struct{}) + releaseClaim := make(chan struct{}) + var pauseOnce sync.Once + store.SetPersonEnrichmentRunBarrierForTest(f.store, func(phase string) { + if phase == "claim_run_locked" { + pauseOnce.Do(func() { + close(claimRead) + <-releaseClaim + }) + } + }) + t.Cleanup(func() { store.SetPersonEnrichmentRunBarrierForTest(f.store, nil) }) + + type claimOutcome struct { + lease *personenrichment.WorkLease + err error + } + result := make(chan claimOutcome, 1) + go func() { + lease, err := f.store.ClaimWork(t.Context(), personenrichment.ClaimOptions{ + RunID: run.ID, Owner: "claim-worker", ProviderName: f.profile.Name, + Now: f.now, LeaseDuration: time.Minute, + }) + result <- claimOutcome{lease: lease, err: err} + }() + requireChannelSignal(t, claimRead, "claim did not establish its read snapshot") + + _, err := f.store.DB().ExecContext(t.Context(), ` + INSERT INTO archive_metadata (key, value) + VALUES ('claim_snapshot_contention', 'committed')`) + require.NoError(err) + close(releaseClaim) + + claimed := <-result + require.NoError(claimed.err) + require.NotNil(claimed.lease) +} + func TestPersonEnrichmentScheduledRunLifecycle(t *testing.T) { require := require.New(t) assert := assert.New(t) diff --git a/internal/store/person_enrichment_work.go b/internal/store/person_enrichment_work.go index 55342bb3a..00d48acab 100644 --- a/internal/store/person_enrichment_work.go +++ b/internal/store/person_enrichment_work.go @@ -321,6 +321,14 @@ func (s *Store) ClaimWork( return nil, errors.New("person enrichment claim options are invalid") } options.Now = options.Now.UTC() + return retryContendedWrite(ctx, s, "claim person enrichment work", func() (*personenrichment.WorkLease, error) { + return s.claimWorkOnce(ctx, options) + }) +} + +func (s *Store) claimWorkOnce( + ctx context.Context, options personenrichment.ClaimOptions, +) (*personenrichment.WorkLease, error) { leaseUntil := options.Now.Add(options.LeaseDuration) var lease *personenrichment.WorkLease err := s.withTxContext(ctx, func(tx *loggedTx) error { diff --git a/internal/store/person_network.go b/internal/store/person_network.go new file mode 100644 index 000000000..bf543ebcb --- /dev/null +++ b/internal/store/person_network.go @@ -0,0 +1,590 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + "sort" + "strings" +) + +const ( + minPersonNetworkDepth = 1 + maxPersonNetworkDepth = 3 + maxPersonNetworkNodes = 250 + maxPersonNetworkEdges = 500 +) + +var ErrPersonNetworkInvalid = errors.New("invalid person network") + +// PersonNetworkOptions bounds the curated relationship graph around one +// durable person root. +type PersonNetworkOptions struct { + Depth int + IncludeEnded bool +} + +// PersonNetwork is a bounded projection over declared person relationships +// and employment records. It never includes archive-derived associations. +type PersonNetwork struct { + RootPersonID int64 `json:"root_person_id"` + Depth int `json:"depth"` + Truncated bool `json:"truncated"` + Nodes []NetworkNode `json:"nodes"` + Edges []NetworkEdge `json:"edges"` +} + +// NetworkNode identifies a curated person or organization in a person +// network. ID is globally typed so person and organization IDs cannot collide. +type NetworkNode struct { + ID string `json:"id"` + Kind string `json:"kind" enum:"person,organization"` + EntityID int64 `json:"entity_id"` + Label string `json:"label"` + Hop int `json:"hop"` +} + +// NetworkEdge identifies a declared relationship or employment connection. +type NetworkEdge struct { + ID string `json:"id"` + Kind string `json:"kind" enum:"relationship,employment"` + SourceNodeID string `json:"source_node_id"` + TargetNodeID string `json:"target_node_id"` + RelationshipTypeSlug *string `json:"relationship_type_slug,omitempty"` + Label string `json:"label"` + StartDate *string `json:"start_date,omitempty"` + EndDate *string `json:"end_date,omitempty"` +} + +// GetPersonNetworkContext returns a deterministic, breadth-first projection +// of one person's declared network. Only person_relationships and employments +// can introduce an edge; archive observations are intentionally excluded. +func (s *Store) GetPersonNetworkContext( + ctx context.Context, personID int64, opts PersonNetworkOptions, +) (PersonNetwork, error) { + if opts.Depth < minPersonNetworkDepth || opts.Depth > maxPersonNetworkDepth { + return PersonNetwork{}, fmt.Errorf("%w: depth must be between %d and %d", + ErrPersonNetworkInvalid, minPersonNetworkDepth, maxPersonNetworkDepth) + } + root, err := s.personNetworkPersonNode(ctx, personID, 0) + if err != nil { + return PersonNetwork{}, err + } + + traversal := personNetworkTraversal{ + store: s, + ctx: ctx, + opts: opts, + nodes: map[string]NetworkNode{root.ID: root}, + edges: make(map[string]NetworkEdge), + seen: make(map[string][]int64), + } + frontier := []NetworkNode{root} + for hop := 0; hop < opts.Depth && len(frontier) > 0; hop++ { + candidates, readErr := traversal.readLayer(frontier, hop+1) + if readErr != nil { + return PersonNetwork{}, readErr + } + frontier = traversal.admit(candidates) + if traversal.truncated { + break + } + } + + graph := PersonNetwork{ + RootPersonID: personID, + Depth: opts.Depth, + Truncated: traversal.truncated, + Nodes: make([]NetworkNode, 0, len(traversal.nodes)), + Edges: make([]NetworkEdge, 0, len(traversal.edges)), + } + for _, node := range traversal.nodes { + graph.Nodes = append(graph.Nodes, node) + } + for _, edge := range traversal.edges { + graph.Edges = append(graph.Edges, edge) + } + sortNetworkNodes(graph.Nodes) + sort.Slice(graph.Edges, func(i, j int) bool { + if graph.Edges[i].Kind != graph.Edges[j].Kind { + return graph.Edges[i].Kind < graph.Edges[j].Kind + } + if graph.Edges[i].Label != graph.Edges[j].Label { + return graph.Edges[i].Label < graph.Edges[j].Label + } + return personNetworkIDLess(graph.Edges[i].ID, graph.Edges[j].ID) + }) + return graph, nil +} + +type personNetworkTraversal struct { + store *Store + ctx context.Context + opts PersonNetworkOptions + nodes map[string]NetworkNode + edges map[string]NetworkEdge + seen map[string][]int64 + truncated bool +} + +type personNetworkCandidate struct { + node NetworkNode + edge NetworkEdge + source personNetworkSourceEdge +} + +// personNetworkSourceEdge is one distinct edge adjacent to the current +// frontier that no earlier hop admitted, together with the node it reaches. +type personNetworkSourceEdge struct { + edgeKind string + edgeEntityID int64 + nodeKind string + nodeEntityID int64 +} + +type personNetworkHydratedEdge struct { + edge NetworkEdge + sourceNode NetworkNode + targetNode NetworkNode +} + +// readLayer reads the next hop's candidates in public order. The edge budget +// is charged only against distinct edges that no earlier hop admitted, so an +// edge read from both of its frontier endpoints, or already present in the +// graph, costs nothing. +func (t *personNetworkTraversal) readLayer( + frontier []NetworkNode, hop int, +) ([]personNetworkCandidate, error) { + remaining := maxPersonNetworkEdges - len(t.edges) + limit := remaining + 1 + sources, err := t.store.readPersonNetworkLayerSources( + t.ctx, frontier, t.seen, t.opts.IncludeEnded, limit) + if err != nil { + return nil, err + } + if hook := t.store.personNetworkSourceReadHook; hook != nil { + hook(limit, len(sources)) + } + if len(sources) > remaining { + t.truncated = true + sources = sources[:remaining] + } + return t.store.hydratePersonNetworkLayerCandidates(t.ctx, sources, hop) +} + +// admit consumes the public-order prefix of the bounded layer. Stopping at +// the first node or edge omission keeps admission deterministic even when the +// edge budget underfills the node cap. +func (t *personNetworkTraversal) admit(candidates []personNetworkCandidate) []NetworkNode { + next := make([]NetworkNode, 0) + for _, candidate := range candidates { + _, nodeExists := t.nodes[candidate.node.ID] + if !nodeExists && len(t.nodes) >= maxPersonNetworkNodes { + t.truncated = true + break + } + if len(t.edges) >= maxPersonNetworkEdges { + t.truncated = true + break + } + t.edges[candidate.edge.ID] = candidate.edge + t.seen[candidate.source.edgeKind] = append( + t.seen[candidate.source.edgeKind], candidate.source.edgeEntityID) + if nodeExists { + continue + } + t.nodes[candidate.node.ID] = candidate.node + next = append(next, candidate.node) + } + return next +} + +func (s *Store) readPersonNetworkLayerSources( + ctx context.Context, frontier []NetworkNode, seen map[string][]int64, + includeEnded bool, limit int, +) ([]personNetworkSourceEdge, error) { + people := make([]int64, 0, len(frontier)) + organizations := make([]int64, 0) + for _, node := range frontier { + switch node.Kind { + case "person": + people = append(people, node.EntityID) + case "organization": + organizations = append(organizations, node.EntityID) + default: + return nil, fmt.Errorf("%w: unknown node kind %q", ErrPersonNetworkInvalid, node.Kind) + } + } + query, args := s.personNetworkLayerSourcesQuery(people, organizations, seen, includeEnded, limit) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("read person network layer: %w", err) + } + defer func() { _ = rows.Close() }() + sources := make([]personNetworkSourceEdge, 0, limit) + for rows.Next() { + var source personNetworkSourceEdge + if err := rows.Scan(&source.edgeKind, &source.edgeEntityID, &source.nodeKind, &source.nodeEntityID); err != nil { + return nil, fmt.Errorf("scan person network layer: %w", err) + } + sources = append(sources, source) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read person network layer: %w", err) + } + return sources, nil +} + +// personNetworkLayerSourcesQuery selects every current edge adjacent to the +// frontier that is not already in the graph, keeps one row per edge (the +// endpoint that sorts first), and returns the first limit rows in the same +// order the public response uses: node kind, node label, node ID, edge kind, +// edge ID. Labels sort bytewise so SQL and Go agree on the prefix. +func (s *Store) personNetworkLayerSourcesQuery( + people, organizations []int64, seen map[string][]int64, includeEnded bool, limit int, +) (string, []any) { + collation := s.bytewiseTextCollation() + ctes := make([]string, 0, 4) + args := make([]any, 0) + addCTE := func(name string, ids []int64) { + clause, values := personNetworkIDValuesCTE(name, ids) + ctes = append(ctes, clause) + args = append(args, values...) + } + relationshipFilter, employmentFilter := "TRUE", "TRUE" + if !includeEnded { + relationshipFilter = "relationship.end_year IS NULL" + employmentFilter = s.dialect.BoolTrueExpr("employment.is_current") + } + if ids := seen["relationship"]; len(ids) > 0 { + addCTE("seen_relationships", ids) + relationshipFilter += ` AND NOT EXISTS (SELECT 1 FROM seen_relationships seen WHERE seen.id = relationship.id)` + } + if ids := seen["employment"]; len(ids) > 0 { + addCTE("seen_employments", ids) + employmentFilter += ` AND NOT EXISTS (SELECT 1 FROM seen_employments seen WHERE seen.id = employment.id)` + } + personLabel := func(alias string) string { + return `COALESCE(NULLIF(` + alias + `.display_name, ''), ` + alias + `.vcard_uid)` + } + // CROSS JOIN pins the frontier as the outer loop on SQLite, so each + // frontier node probes its adjacency index instead of the planner + // scanning every edge and filtering by the small frontier set. + // PostgreSQL treats CROSS JOIN plus WHERE as an ordinary inner join. + branches := make([]string, 0, 4) + if len(people) > 0 { + addCTE("frontier_people", people) + branches = append(branches, + `SELECT 'relationship', relationship.id, 'person', relationship.target_person_id, + `+personLabel("target_person")+` + FROM frontier_people frontier + CROSS JOIN person_relationships relationship + JOIN persons target_person ON target_person.id = relationship.target_person_id + WHERE relationship.source_person_id = frontier.id AND `+relationshipFilter, + `SELECT 'relationship', relationship.id, 'person', relationship.source_person_id, + `+personLabel("source_person")+` + FROM frontier_people frontier + CROSS JOIN person_relationships relationship + JOIN persons source_person ON source_person.id = relationship.source_person_id + WHERE relationship.target_person_id = frontier.id AND `+relationshipFilter, + `SELECT 'employment', employment.id, 'organization', employment.organization_id, organization.name + FROM frontier_people frontier + CROSS JOIN employments employment + JOIN organizations organization ON organization.id = employment.organization_id + WHERE employment.person_id = frontier.id AND `+employmentFilter, + ) + } + if len(organizations) > 0 { + addCTE("frontier_organizations", organizations) + branches = append(branches, + `SELECT 'employment', employment.id, 'person', employment.person_id, `+personLabel("person")+` + FROM frontier_organizations frontier + CROSS JOIN employments employment + JOIN persons person ON person.id = employment.person_id + WHERE employment.organization_id = frontier.id AND `+employmentFilter, + ) + } + nodeOrder := `node_kind` + collation + `, node_label` + collation + `, node_id` + query := `WITH ` + strings.Join(ctes, ",\n") + `, + layer(edge_kind, edge_id, node_kind, node_id, node_label) AS (` + strings.Join(branches, "\nUNION ALL\n") + `), + ranked AS ( + SELECT edge_kind, edge_id, node_kind, node_id, node_label, + ROW_NUMBER() OVER (PARTITION BY edge_kind, edge_id ORDER BY ` + nodeOrder + `) AS candidate_rank + FROM layer + ) + SELECT edge_kind, edge_id, node_kind, node_id + FROM ranked + WHERE candidate_rank = 1 + ORDER BY ` + nodeOrder + `, edge_kind` + collation + `, edge_id + LIMIT ?` + return query, append(args, limit) +} + +func (s *Store) hydratePersonNetworkLayerCandidates( + ctx context.Context, sources []personNetworkSourceEdge, hop int, +) ([]personNetworkCandidate, error) { + ids := make(map[string][]int64, 2) + for _, source := range sources { + switch source.edgeKind { + case "relationship", "employment": + ids[source.edgeKind] = append(ids[source.edgeKind], source.edgeEntityID) + default: + return nil, fmt.Errorf("%w: unknown edge kind %q", ErrPersonNetworkInvalid, source.edgeKind) + } + } + hydrated := make(map[string]personNetworkHydratedEdge, len(sources)) + if len(ids["relationship"]) > 0 { + edges, err := s.hydratePersonNetworkRelationships(ctx, ids["relationship"]) + if err != nil { + return nil, err + } + for id, edge := range edges { + hydrated[personNetworkEdgeID("relationship", id)] = edge + } + } + if len(ids["employment"]) > 0 { + edges, err := s.hydratePersonNetworkEmployments(ctx, ids["employment"]) + if err != nil { + return nil, err + } + for id, edge := range edges { + hydrated[personNetworkEdgeID("employment", id)] = edge + } + } + candidates := make([]personNetworkCandidate, 0, len(sources)) + for _, source := range sources { + key := personNetworkEdgeID(source.edgeKind, source.edgeEntityID) + edge, exists := hydrated[key] + if !exists { + return nil, fmt.Errorf("hydrate person network edge %s: missing row", key) + } + var node NetworkNode + switch personNetworkNodeID(source.nodeKind, source.nodeEntityID) { + case edge.sourceNode.ID: + node = edge.sourceNode + case edge.targetNode.ID: + node = edge.targetNode + default: + return nil, fmt.Errorf("%w: edge %s does not reach node %s:%d", + ErrPersonNetworkInvalid, key, source.nodeKind, source.nodeEntityID) + } + node.Hop = hop + candidates = append(candidates, personNetworkCandidate{node: node, edge: edge.edge, source: source}) + } + return candidates, nil +} + +func (s *Store) hydratePersonNetworkRelationships( + ctx context.Context, ids []int64, +) (map[int64]personNetworkHydratedEdge, error) { + cte, args := personNetworkIDValuesCTE("selected_relationships", ids) + rows, err := s.db.QueryContext(ctx, ` + WITH `+cte+` + SELECT relationship.id, + relationship.source_person_id, + COALESCE(NULLIF(source_person.display_name, ''), source_person.vcard_uid), + relationship.target_person_id, + COALESCE(NULLIF(target_person.display_name, ''), target_person.vcard_uid), + relationship_type.slug, + relationship_type.forward_label, + relationship.start_year, relationship.start_month, relationship.start_day, + relationship.end_year, relationship.end_month, relationship.end_day + FROM selected_relationships selected + JOIN person_relationships relationship ON relationship.id = selected.id + JOIN relationship_types relationship_type ON relationship_type.id = relationship.relationship_type_id + JOIN persons source_person ON source_person.id = relationship.source_person_id + JOIN persons target_person ON target_person.id = relationship.target_person_id + `, args...) + if err != nil { + return nil, fmt.Errorf("hydrate person network relationships: %w", err) + } + defer func() { _ = rows.Close() }() + + hydrated := make(map[int64]personNetworkHydratedEdge, len(ids)) + for rows.Next() { + var ( + id, sourceID, targetID int64 + sourceLabel, targetLabel, slug, label string + startYear, startMonth, startDay sql.NullInt64 + endYear, endMonth, endDay sql.NullInt64 + ) + if err := rows.Scan( + &id, &sourceID, &sourceLabel, &targetID, &targetLabel, &slug, &label, + &startYear, &startMonth, &startDay, &endYear, &endMonth, &endDay, + ); err != nil { + return nil, fmt.Errorf("scan person network relationship: %w", err) + } + relationshipSlug := slug + hydrated[id] = personNetworkHydratedEdge{ + edge: NetworkEdge{ + ID: personNetworkEdgeID("relationship", id), + Kind: "relationship", + SourceNodeID: personNetworkNodeID("person", sourceID), + TargetNodeID: personNetworkNodeID("person", targetID), + RelationshipTypeSlug: &relationshipSlug, + Label: label, + StartDate: personNetworkDateFromColumns(startYear, startMonth, startDay), + EndDate: personNetworkDateFromColumns(endYear, endMonth, endDay), + }, + sourceNode: NetworkNode{ + ID: personNetworkNodeID("person", sourceID), Kind: "person", EntityID: sourceID, Label: sourceLabel, + }, + targetNode: NetworkNode{ + ID: personNetworkNodeID("person", targetID), Kind: "person", EntityID: targetID, Label: targetLabel, + }, + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("hydrate person network relationships: %w", err) + } + return hydrated, nil +} + +func (s *Store) hydratePersonNetworkEmployments( + ctx context.Context, ids []int64, +) (map[int64]personNetworkHydratedEdge, error) { + cte, args := personNetworkIDValuesCTE("selected_employments", ids) + rows, err := s.db.QueryContext(ctx, ` + WITH `+cte+` + SELECT employment.id, + employment.person_id, + COALESCE(NULLIF(person.display_name, ''), person.vcard_uid), + employment.organization_id, + organization.name, + COALESCE(NULLIF(employment.title, ''), NULLIF(employment.role, ''), 'employment'), + employment.start_year, employment.start_month, employment.start_day, + employment.end_year, employment.end_month, employment.end_day + FROM selected_employments selected + JOIN employments employment ON employment.id = selected.id + JOIN persons person ON person.id = employment.person_id + JOIN organizations organization ON organization.id = employment.organization_id + `, args...) + if err != nil { + return nil, fmt.Errorf("hydrate person network employments: %w", err) + } + defer func() { _ = rows.Close() }() + + hydrated := make(map[int64]personNetworkHydratedEdge, len(ids)) + for rows.Next() { + var ( + id, personID, organizationID int64 + personLabel, organizationLabel, edgeLabel string + startYear, startMonth, startDay sql.NullInt64 + endYear, endMonth, endDay sql.NullInt64 + ) + if err := rows.Scan( + &id, &personID, &personLabel, &organizationID, &organizationLabel, &edgeLabel, + &startYear, &startMonth, &startDay, &endYear, &endMonth, &endDay, + ); err != nil { + return nil, fmt.Errorf("scan person network employment: %w", err) + } + hydrated[id] = personNetworkHydratedEdge{ + edge: NetworkEdge{ + ID: personNetworkEdgeID("employment", id), + Kind: "employment", + SourceNodeID: personNetworkNodeID("person", personID), + TargetNodeID: personNetworkNodeID("organization", organizationID), + Label: edgeLabel, + StartDate: personNetworkDateFromColumns(startYear, startMonth, startDay), + EndDate: personNetworkDateFromColumns(endYear, endMonth, endDay), + }, + sourceNode: NetworkNode{ + ID: personNetworkNodeID("person", personID), Kind: "person", EntityID: personID, Label: personLabel, + }, + targetNode: NetworkNode{ + ID: personNetworkNodeID("organization", organizationID), Kind: "organization", EntityID: organizationID, Label: organizationLabel, + }, + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("hydrate person network employments: %w", err) + } + return hydrated, nil +} + +func personNetworkEdgeID(kind string, entityID int64) string { + return fmt.Sprintf("%s:%d", kind, entityID) +} + +func personNetworkIDValuesCTE(name string, ids []int64) (string, []any) { + ids = append([]int64(nil), ids...) + slices.Sort(ids) + values := make([]string, len(ids)) + args := make([]any, len(ids)) + for index, id := range ids { + values[index] = "(CAST(? AS BIGINT))" + args[index] = id + } + return name + "(id) AS (VALUES " + strings.Join(values, ", ") + ")", args +} + +func personNetworkDateFromColumns(year, month, day sql.NullInt64) *string { + date := ScanPartialDate(year, month, day) + if date.IsZero() { + return nil + } + return personNetworkDate(&date) +} + +func (s *Store) personNetworkPersonNode(ctx context.Context, personID int64, hop int) (NetworkNode, error) { + var ( + id int64 + displayName sql.NullString + vcardUID string + ) + err := s.db.QueryRowContext(ctx, ` + SELECT id, display_name, vcard_uid + FROM persons + WHERE id = ? + `, personID).Scan(&id, &displayName, &vcardUID) + if errors.Is(err, sql.ErrNoRows) { + return NetworkNode{}, ErrPersonNotFound + } + if err != nil { + return NetworkNode{}, fmt.Errorf("get person network node %d: %w", personID, err) + } + label := vcardUID + if displayName.Valid && displayName.String != "" { + label = displayName.String + } + return NetworkNode{ID: personNetworkNodeID("person", id), Kind: "person", EntityID: id, Label: label, Hop: hop}, nil +} + +func personNetworkNodeID(kind string, entityID int64) string { + return fmt.Sprintf("%s:%d", kind, entityID) +} + +func personNetworkDate(date *PartialDate) *string { + if date == nil { + return nil + } + value := date.String() + return &value +} + +func sortNetworkNodes(nodes []NetworkNode) { + sort.Slice(nodes, func(i, j int) bool { + if nodes[i].Hop != nodes[j].Hop { + return nodes[i].Hop < nodes[j].Hop + } + if nodes[i].Kind != nodes[j].Kind { + return nodes[i].Kind < nodes[j].Kind + } + if nodes[i].Label != nodes[j].Label { + return nodes[i].Label < nodes[j].Label + } + return nodes[i].EntityID < nodes[j].EntityID + }) +} + +// personNetworkIDLess orders two IDs of the same kind by their numeric entity +// ID. Both share the "kind:" prefix and carry a decimal without leading +// zeros, so a shorter ID is smaller and equal lengths compare digit by digit. +func personNetworkIDLess(left, right string) bool { + if len(left) != len(right) { + return len(left) < len(right) + } + return left < right +} diff --git a/internal/store/person_network_query_test.go b/internal/store/person_network_query_test.go new file mode 100644 index 000000000..79776c0e9 --- /dev/null +++ b/internal/store/person_network_query_test.go @@ -0,0 +1,125 @@ +package store + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPersonNetworkIDValuesCTECastsEveryParameterOnlyIDRowAsBigint(t *testing.T) { + tests := []struct { + name string + cteName string + want string + }{ + { + name: "person frontier", + cteName: "frontier_people", + want: "frontier_people(id) AS (VALUES (CAST(? AS BIGINT)), (CAST(? AS BIGINT)))", + }, + { + name: "organization frontier", + cteName: "frontier_organizations", + want: "frontier_organizations(id) AS (VALUES (CAST(? AS BIGINT)), (CAST(? AS BIGINT)))", + }, + { + name: "seen relationship edges", + cteName: "admitted_relationships", + want: "admitted_relationships(id) AS (VALUES (CAST(? AS BIGINT)), (CAST(? AS BIGINT)))", + }, + { + name: "seen employment edges", + cteName: "admitted_employments", + want: "admitted_employments(id) AS (VALUES (CAST(? AS BIGINT)), (CAST(? AS BIGINT)))", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + query, args := personNetworkIDValuesCTE(test.cteName, []int64{9, 3}) + + assert.Equal(t, test.want, query) + assert.Equal(t, []any{int64(3), int64(9)}, args) + }) + } +} + +// This catches a layer query that scans person_relationships or employments +// instead of probing the adjacency indexes for each frontier node. The +// public-order sort over the candidates is expected; a table scan is not. +func TestPersonNetworkLayerQueryProbesAdjacencyIndexes(t *testing.T) { + st, err := OpenForTest(filepath.Join(t.TempDir(), "network-query-plan.db")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, st.Close()) }) + require.NoError(t, st.InitSchema()) + + tests := []struct { + name string + people []int64 + organizations []int64 + seen map[string][]int64 + includeEnded bool + wantProbes []string + }{ + { + name: "current person edges", + people: []int64{7}, + wantProbes: []string{ + "SEARCH relationship USING INDEX idx_person_relationships_source_current_edge (source_person_id=?)", + "SEARCH relationship USING INDEX idx_person_relationships_target_current_edge (target_person_id=?)", + "SEARCH employment USING COVERING INDEX idx_employments_active_person_org_title (person_id=?)", + }, + }, + { + name: "current organization employments excluding seen edges", + organizations: []int64{9}, + seen: map[string][]int64{"employment": {1, 2}}, + wantProbes: []string{"SEARCH employment USING INDEX idx_employments_organization (organization_id=?)"}, + }, + { + name: "all organization employments", + organizations: []int64{9}, + includeEnded: true, + wantProbes: []string{"SEARCH employment USING INDEX idx_employments_organization (organization_id=?)"}, + }, + { + name: "mixed frontier excluding seen edges", + people: []int64{7, 11}, + organizations: []int64{9}, + seen: map[string][]int64{"relationship": {3}, "employment": {1}}, + wantProbes: []string{ + "SEARCH relationship USING INDEX idx_person_relationships_source_current_edge (source_person_id=?)", + "SEARCH relationship USING INDEX idx_person_relationships_target_current_edge (target_person_id=?)", + "SEARCH employment USING COVERING INDEX idx_employments_active_person_org_title (person_id=?)", + "SEARCH employment USING INDEX idx_employments_organization_current_edge (organization_id=?)", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + query, args := st.personNetworkLayerSourcesQuery( + test.people, test.organizations, test.seen, test.includeEnded, 501) + rows, err := st.db.QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+query, args...) + require.NoError(err) + defer func() { require.NoError(rows.Close()) }() + details := make([]string, 0) + for rows.Next() { + var id, parent, unused int + var detail string + require.NoError(rows.Scan(&id, &parent, &unused, &detail)) + details = append(details, detail) + } + require.NoError(rows.Err()) + plan := strings.Join(details, "\n") + assert.NotContains(plan, "SCAN person_relationships") + assert.NotContains(plan, "SCAN employments") + for _, probe := range test.wantProbes { + assert.Contains(plan, probe) + } + }) + } +} diff --git a/internal/store/person_network_test.go b/internal/store/person_network_test.go new file mode 100644 index 000000000..c38d74edb --- /dev/null +++ b/internal/store/person_network_test.go @@ -0,0 +1,485 @@ +package store_test + +import ( + "context" + "fmt" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +type networkSourceRead struct { + limit int + count int +} + +func TestGetPersonNetworkContextUsesCuratedEdgesOnly(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + s := f.Store + root := createNetworkPerson(t, s, "Root") + peer := createNetworkPerson(t, s, "Peer") + organization := createNetworkOrganization(t, s, "Example Works") + createNetworkRelationship(t, s, root.ID, peer.ID, "colleague") + createNetworkEmployment(t, s, peer.ID, organization.ID, true) + messageOnlyParticipantID, err := s.EnsureParticipant("message-only@example.test", "Message Only", "example.test") + require.NoError(err) + messageOnly, _, err := s.CreatePersonFromParticipantContext(t.Context(), messageOnlyParticipantID) + require.NoError(err) + messageID := f.CreateMessage("archive-linked-message-only") + require.NotEmpty(root.ParticipantIDs) + require.NoError(s.ReplaceMessageRecipients(messageID, "from", []int64{root.ParticipantIDs[0]}, []string{"Root"})) + require.NoError(s.ReplaceMessageRecipients(messageID, "to", []int64{messageOnlyParticipantID}, []string{"Message Only"})) + + graph, err := s.GetPersonNetworkContext(context.Background(), root.ID, store.PersonNetworkOptions{Depth: 2}) + require.NoError(err) + assert.Len(graph.Nodes, 3) + assert.Len(graph.Edges, 2) + assert.NotContains(networkNodeIDs(graph.Nodes), fmt.Sprintf("person:%d", messageOnly.ID)) +} + +func TestGetPersonNetworkContextReturnsRootOnly(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + root := createNetworkPerson(t, f.Store, "Root") + + graph, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 1}) + require.NoError(err) + require.Len(graph.Nodes, 1) + assert.Equal(store.NetworkNode{ + ID: fmt.Sprintf("person:%d", root.ID), + Kind: "person", + EntityID: root.ID, + Label: "Root", + Hop: 0, + }, graph.Nodes[0]) + assert.Empty(graph.Edges) +} + +func TestGetPersonNetworkContextUsesFirstHopAndStableFrontierOrder(t *testing.T) { + f := storetest.New(t) + root := createNetworkPerson(t, f.Store, "Root") + zulu := createNetworkPerson(t, f.Store, "Zulu") + alpha := createNetworkPerson(t, f.Store, "Alpha") + organization := createNetworkOrganization(t, f.Store, "Example Works") + createNetworkRelationship(t, f.Store, root.ID, zulu.ID, "friend") + createNetworkRelationship(t, f.Store, root.ID, alpha.ID, "friend") + createNetworkEmployment(t, f.Store, alpha.ID, organization.ID, true) + + graph, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 2}) + require.NoError(t, err) + require.Len(t, graph.Nodes, 4) + assert.Equal(t, []store.NetworkNode{ + {ID: fmt.Sprintf("person:%d", root.ID), Kind: "person", EntityID: root.ID, Label: "Root", Hop: 0}, + {ID: fmt.Sprintf("person:%d", alpha.ID), Kind: "person", EntityID: alpha.ID, Label: "Alpha", Hop: 1}, + {ID: fmt.Sprintf("person:%d", zulu.ID), Kind: "person", EntityID: zulu.ID, Label: "Zulu", Hop: 1}, + {ID: fmt.Sprintf("organization:%d", organization.ID), Kind: "organization", EntityID: organization.ID, Label: "Example Works", Hop: 2}, + }, graph.Nodes) +} + +func TestGetPersonNetworkContextReachesIncomingRelationships(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + root := createNetworkPerson(t, f.Store, "Root") + incoming := createNetworkPerson(t, f.Store, "Incoming") + _, err := f.Store.CreateRelationshipTypeContext(t.Context(), store.RelationshipTypeInput{ + Slug: "incoming", ForwardLabel: "source", ReverseLabel: "target", + }) + require.NoError(err) + createNetworkRelationship(t, f.Store, incoming.ID, root.ID, "incoming") + + graph, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 1}) + require.NoError(err) + assert.Equal([]string{ + fmt.Sprintf("person:%d", root.ID), + fmt.Sprintf("person:%d", incoming.ID), + }, networkNodeIDs(graph.Nodes)) + require.Len(graph.Edges, 1) + assert.Equal(fmt.Sprintf("person:%d", incoming.ID), graph.Edges[0].SourceNodeID) + assert.Equal(fmt.Sprintf("person:%d", root.ID), graph.Edges[0].TargetNodeID) +} + +func TestGetPersonNetworkContextExpandsOrganizationsToPeople(t *testing.T) { + f := storetest.New(t) + root := createNetworkPerson(t, f.Store, "Root") + peer := createNetworkPerson(t, f.Store, "Peer") + organization := createNetworkOrganization(t, f.Store, "Example Works") + createNetworkEmployment(t, f.Store, root.ID, organization.ID, true) + createNetworkEmployment(t, f.Store, peer.ID, organization.ID, true) + + graph, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 2}) + require.NoError(t, err) + assert.Equal(t, []string{ + fmt.Sprintf("person:%d", root.ID), + fmt.Sprintf("organization:%d", organization.ID), + fmt.Sprintf("person:%d", peer.ID), + }, networkNodeIDs(graph.Nodes)) + assert.Len(t, graph.Edges, 2) +} + +func TestGetPersonNetworkContextBoundsHighDegreeEmploymentLayerInPublicOrder(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + root := createNetworkPerson(t, f.Store, "Root") + organization := createNetworkOrganization(t, f.Store, "Example Works") + createNetworkEmployment(t, f.Store, root.ID, organization.ID, true) + reads := make([]networkSourceRead, 0, 4) + restore := f.Store.SetPersonNetworkSourceReadHookForTest(func(limit, count int) { + reads = append(reads, networkSourceRead{limit: limit, count: count}) + }) + t.Cleanup(restore) + + people := make([]*store.Person, 0, 1000) + for index := range 1000 { + person := createNetworkPerson(t, f.Store, fmt.Sprintf("Zed %04d", index)) + people = append(people, person) + createNetworkEmployment(t, f.Store, person.ID, organization.ID, true) + } + earlier := createNetworkPerson(t, f.Store, "AAA Beyond Employment Page") + createNetworkEmployment(t, f.Store, earlier.ID, organization.ID, true) + + graph, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 2}) + require.NoError(err) + assert.True(graph.Truncated) + require.Len(graph.Nodes, 250) + wantHopTwo := []string{fmt.Sprintf("person:%d", earlier.ID)} + for _, person := range people[:247] { + wantHopTwo = append(wantHopTwo, fmt.Sprintf("person:%d", person.ID)) + } + assert.Equal(wantHopTwo, networkNodeIDsAtHop(graph.Nodes, 2), + "the kept layer prefix follows label order, not employment ID order") + + repeated, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 2}) + require.NoError(err) + assert.Equal(graph, repeated) + assert.Equal([]networkSourceRead{ + {limit: 501, count: 1}, + {limit: 500, count: 500}, + {limit: 501, count: 1}, + {limit: 500, count: 500}, + }, reads) +} + +func TestGetPersonNetworkContextAppliesNodeCapAfterLayerOrdering(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + root := createNetworkPerson(t, f.Store, "Root") + firstParent := createNetworkPerson(t, f.Store, "First Parent") + secondParent := createNetworkPerson(t, f.Store, "Second Parent") + createNetworkRelationship(t, f.Store, root.ID, firstParent.ID, "friend") + createNetworkRelationship(t, f.Store, root.ID, secondParent.ID, "friend") + + people := make([]*store.Person, 0, 248) + for index := range 248 { + person := createNetworkPerson(t, f.Store, fmt.Sprintf("Person %03d", index)) + people = append(people, person) + createNetworkRelationship(t, f.Store, firstParent.ID, person.ID, "friend") + } + organization := createNetworkOrganization(t, f.Store, "A Organization") + createNetworkEmployment(t, f.Store, secondParent.ID, organization.ID, true) + + graph, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 2}) + require.NoError(err) + assert.True(graph.Truncated) + require.Len(graph.Nodes, 250) + assert.Contains(networkNodeIDs(graph.Nodes), fmt.Sprintf("organization:%d", organization.ID)) + assert.NotContains(networkNodeIDs(graph.Nodes), fmt.Sprintf("person:%d", people[247].ID)) + assert.Equal([]string{ + fmt.Sprintf("organization:%d", organization.ID), + fmt.Sprintf("person:%d", people[0].ID), + fmt.Sprintf("person:%d", people[1].ID), + }, networkNodeIDsAtHop(graph.Nodes, 2)[:3]) +} + +func TestGetPersonNetworkContextAppliesEdgeCapSeparately(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + root := createNetworkPerson(t, f.Store, "Root") + peer := createNetworkPerson(t, f.Store, "Peer") + reads := make([]networkSourceRead, 0, 2) + restore := f.Store.SetPersonNetworkSourceReadHookForTest(func(limit, count int) { + reads = append(reads, networkSourceRead{limit: limit, count: count}) + }) + t.Cleanup(restore) + for index := range 501 { + slug := fmt.Sprintf("edge-%03d", index) + _, err := f.Store.CreateRelationshipTypeContext(t.Context(), store.RelationshipTypeInput{ + Slug: slug, ForwardLabel: slug, ReverseLabel: slug, IsSymmetric: true, + }) + require.NoError(err) + createNetworkRelationship(t, f.Store, root.ID, peer.ID, slug) + } + + graph, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 1}) + require.NoError(err) + assert.True(graph.Truncated) + assert.Len(graph.Nodes, 2) + require.Len(graph.Edges, 500) + wantSlugs := make([]string, 0, 500) + for index := range 500 { + wantSlugs = append(wantSlugs, fmt.Sprintf("edge-%03d", index)) + } + assert.Equal(wantSlugs, networkRelationshipSlugs(graph.Edges)) + + repeated, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 1}) + require.NoError(err) + assert.Equal(graph, repeated) + assert.Equal([]networkSourceRead{ + {limit: 501, count: 501}, + {limit: 501, count: 501}, + }, reads) +} + +// This catches an edge budget charged for rows the traversal already holds: +// every hop-two edge here joins two frontier peers, so a naive read sees it +// once from each endpoint, and the root edges are visible again from every +// peer. Distinct unseen edges stay under the cap, so nothing may be dropped. +func TestGetPersonNetworkContextChargesEdgeBudgetForDistinctUnseenEdgesOnly(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + root := createNetworkPerson(t, f.Store, "Root") + peers := make([]*store.Person, 0, 249) + for index := range 249 { + peer := createNetworkPerson(t, f.Store, fmt.Sprintf("Peer %03d", index)) + peers = append(peers, peer) + createNetworkRelationship(t, f.Store, root.ID, peer.ID, "friend") + } + const cliqueSize = 20 + for left := range cliqueSize { + for right := left + 1; right < cliqueSize; right++ { + createNetworkRelationship(t, f.Store, peers[left].ID, peers[right].ID, "friend") + } + } + const wantEdges = 249 + cliqueSize*(cliqueSize-1)/2 + reads := make([]networkSourceRead, 0, 2) + restore := f.Store.SetPersonNetworkSourceReadHookForTest(func(limit, count int) { + reads = append(reads, networkSourceRead{limit: limit, count: count}) + }) + t.Cleanup(restore) + + graph, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 3}) + require.NoError(err) + assert.False(graph.Truncated) + assert.Len(graph.Nodes, 250) + assert.Len(graph.Edges, wantEdges) + assert.Equal([]networkSourceRead{ + {limit: 501, count: 249}, + {limit: 252, count: cliqueSize * (cliqueSize - 1) / 2}, + }, reads, "already-admitted edges must not be read or charged again") +} + +// This catches a truncated layer that keeps edges in frontier or ID order. +// The kept prefix must follow the public ordering: the organization reached +// from the last peer sorts before every person, and the clique edges follow +// their lesser endpoint's label. +func TestGetPersonNetworkContextTruncatesLayerInPublicOrder(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + root := createNetworkPerson(t, f.Store, "Root") + peers := make([]*store.Person, 0, 248) + for index := range 248 { + peer := createNetworkPerson(t, f.Store, fmt.Sprintf("Peer %03d", index)) + peers = append(peers, peer) + createNetworkRelationship(t, f.Store, root.ID, peer.ID, "friend") + } + const cliqueSize = 24 + cliqueEdges := make([][]int64, cliqueSize) + for left := cliqueSize - 1; left >= 0; left-- { + for right := cliqueSize - 1; right > left; right-- { + relationship, err := f.Store.AddPersonRelationshipContext(t.Context(), store.PersonRelationshipInput{ + SourcePersonID: peers[left].ID, TargetPersonID: peers[right].ID, TypeSlug: "friend", + Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + cliqueEdges[left] = append(cliqueEdges[left], relationship.ID) + } + } + organization := createNetworkOrganization(t, f.Store, "Zeta Works") + createNetworkEmployment(t, f.Store, peers[cliqueSize-1].ID, organization.ID, true) + + graph, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 2}) + require.NoError(err) + assert.True(graph.Truncated) + assert.Len(graph.Nodes, 250) + require.Len(graph.Edges, 500) + + const remaining = 500 - 248 + wantHopTwo := []string{fmt.Sprintf("employment:%d", employmentEdgeID(t, f.Store, peers[cliqueSize-1].ID))} + for _, ids := range cliqueEdges { + slices.Sort(ids) + for _, id := range ids { + if len(wantHopTwo) == remaining { + break + } + wantHopTwo = append(wantHopTwo, fmt.Sprintf("relationship:%d", id)) + } + } + rootID := fmt.Sprintf("person:%d", root.ID) + gotHopTwo := make([]string, 0, remaining) + for _, edge := range graph.Edges { + if edge.SourceNodeID != rootID && edge.TargetNodeID != rootID { + gotHopTwo = append(gotHopTwo, edge.ID) + } + } + assert.ElementsMatch(wantHopTwo, gotHopTwo) +} + +func TestGetPersonNetworkContextIncludesEndedRowsOnlyWhenRequested(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + root := createNetworkPerson(t, f.Store, "Root") + peer := createNetworkPerson(t, f.Store, "Peer") + organization := createNetworkOrganization(t, f.Store, "Example Works") + ended := partialDate(2024, 0, 0) + _, err := f.Store.AddPersonRelationshipContext(t.Context(), store.PersonRelationshipInput{ + SourcePersonID: root.ID, TargetPersonID: peer.ID, TypeSlug: "friend", + EndDate: &ended, Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + createNetworkEmployment(t, f.Store, peer.ID, organization.ID, false) + + current, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 2}) + require.NoError(err) + assert.Len(current.Nodes, 1) + assert.Empty(current.Edges) + + history, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 2, IncludeEnded: true}) + require.NoError(err) + assert.Len(history.Nodes, 3) + assert.Len(history.Edges, 2) +} + +func TestGetPersonNetworkContextUsesEmploymentTitleRoleLabelFallback(t *testing.T) { + f := storetest.New(t) + root := createNetworkPerson(t, f.Store, "Root") + titleOrganization := createNetworkOrganization(t, f.Store, "Title Works") + roleOrganization := createNetworkOrganization(t, f.Store, "Role Works") + defaultOrganization := createNetworkOrganization(t, f.Store, "Default Works") + title, role, empty := "Engineer", "Advisor", "" + for _, input := range []store.EmploymentInput{ + {PersonID: root.ID, OrganizationID: titleOrganization.ID, Title: &title, Role: &role}, + {PersonID: root.ID, OrganizationID: roleOrganization.ID, Role: &role}, + {PersonID: root.ID, OrganizationID: defaultOrganization.ID, Title: &empty, Role: &empty}, + } { + current := true + input.IsCurrent = ¤t + input.Source = store.ProvenanceUser + _, err := f.Store.AddEmploymentContext(t.Context(), input) + require.NoError(t, err) + } + + graph, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: 1}) + require.NoError(t, err) + labels := make(map[string]string, len(graph.Edges)) + for _, edge := range graph.Edges { + labels[edge.TargetNodeID] = edge.Label + } + assert.Equal(t, map[string]string{ + fmt.Sprintf("organization:%d", titleOrganization.ID): "Engineer", + fmt.Sprintf("organization:%d", roleOrganization.ID): "Advisor", + fmt.Sprintf("organization:%d", defaultOrganization.ID): "employment", + }, labels) +} + +func TestGetPersonNetworkContextValidatesBoundsAndRoot(t *testing.T) { + f := storetest.New(t) + root := createNetworkPerson(t, f.Store, "Root") + + for _, depth := range []int{0, 4} { + _, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID, store.PersonNetworkOptions{Depth: depth}) + require.ErrorIs(t, err, store.ErrPersonNetworkInvalid) + } + _, err := f.Store.GetPersonNetworkContext(t.Context(), root.ID+999, store.PersonNetworkOptions{Depth: 1}) + assert.ErrorIs(t, err, store.ErrPersonNotFound) +} + +func createNetworkPerson(t *testing.T, s *store.Store, name string) *store.Person { + t.Helper() + identifier := strings.ToLower(strings.ReplaceAll(name, " ", "-")) + "@example.test" + participantID, err := s.EnsureParticipant(identifier, name, "example.test") + require.NoError(t, err) + person, _, err := s.CreatePersonFromParticipantContext(t.Context(), participantID) + require.NoError(t, err) + person, err = s.UpdatePersonDisplayNameContext(t.Context(), person.ID, person.Revision, &name) + require.NoError(t, err) + return person +} + +func createNetworkOrganization(t *testing.T, s *store.Store, name string) *store.Organization { + t.Helper() + organization, err := s.CreateOrganizationContext(t.Context(), store.OrganizationInput{Name: name}) + require.NoError(t, err) + return organization +} + +func createNetworkRelationship(t *testing.T, s *store.Store, sourceID, targetID int64, typeSlug string) { + t.Helper() + _, err := s.AddPersonRelationshipContext(t.Context(), store.PersonRelationshipInput{ + SourcePersonID: sourceID, + TargetPersonID: targetID, + TypeSlug: typeSlug, + Source: store.ProvenanceUser, + Actor: "test", + }) + require.NoError(t, err) +} + +func createNetworkEmployment(t *testing.T, s *store.Store, personID, organizationID int64, current bool) { + t.Helper() + _, err := s.AddEmploymentContext(t.Context(), store.EmploymentInput{ + PersonID: personID, + OrganizationID: organizationID, + IsCurrent: ¤t, + Source: store.ProvenanceUser, + }) + require.NoError(t, err) +} + +func networkNodeIDs(nodes []store.NetworkNode) []string { + ids := make([]string, 0, len(nodes)) + for _, node := range nodes { + ids = append(ids, node.ID) + } + return ids +} + +func networkNodeIDsAtHop(nodes []store.NetworkNode, hop int) []string { + ids := make([]string, 0) + for _, node := range nodes { + if node.Hop == hop { + ids = append(ids, node.ID) + } + } + return ids +} + +func networkRelationshipSlugs(edges []store.NetworkEdge) []string { + slugs := make([]string, 0, len(edges)) + for _, edge := range edges { + if edge.RelationshipTypeSlug != nil { + slugs = append(slugs, *edge.RelationshipTypeSlug) + } + } + return slugs +} + +func employmentEdgeID(t *testing.T, s *store.Store, personID int64) int64 { + t.Helper() + var id int64 + require.NoError(t, s.DB().QueryRow(s.Rebind(`SELECT id FROM employments WHERE person_id = ?`), personID).Scan(&id)) + return id +} diff --git a/internal/store/person_profile_backend_test.go b/internal/store/person_profile_backend_test.go index ec9896d7c..03fc93d94 100644 --- a/internal/store/person_profile_backend_test.go +++ b/internal/store/person_profile_backend_test.go @@ -2,8 +2,10 @@ package store_test import ( "context" + "fmt" "sort" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -191,6 +193,240 @@ func TestProfileReadsSucceedOnTheConfiguredBackend(t *testing.T) { assert.Empty(candidates) } +func TestDirectoryPeoplePageSucceedsOnTheConfiguredBackend(t *testing.T) { + st := storetest.New(t).Store + alice := createDirectoryPerson(t, st, "Alice Example", "alice@example.test", "friend", "active", "Acme") + createDirectoryPerson(t, st, "Alice Other", "other@example.test", "colleague", "active", "Other Co") + + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{ + Query: "alcie", Category: "friend", Organization: "acme", Limit: 1, + }) + require.NoError(t, err) + require.Len(t, page.People, 1) + assert.Equal(t, alice.ID, page.People[0].ID) +} + +// This uses storetest's selected backend (SQLite by default, PostgreSQL when +// MSGVAULT_TEST_DB is configured) to keep keyset ordering identical across +// the exact, prefix, and one-edit tiers. +func TestDirectoryPeoplePageSequenceOnTheConfiguredBackend(t *testing.T) { + st := storetest.New(t).Store + exact := createDirectoryPerson(t, st, "Alice Exact", "alice-exact@example.test", "friend", "active", "Acme") + prefix := createDirectoryPerson(t, st, "Alicef Prefix", "alicef-prefix@example.test", "friend", "active", "Acme") + fuzzy := createDirectoryPerson(t, st, "Alicf Fuzzy", "alicf-fuzzy@example.test", "friend", "active", "Acme") + + var got []int64 + cursor := "" + for pageNumber := 0; ; pageNumber++ { + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{ + Query: "alice", Limit: 1, Cursor: cursor, + }) + require.NoError(t, err) + got = append(got, directoryPersonIDs(page.People)...) + if page.NextCursor == "" { + break + } + require.Less(t, pageNumber, 3, "directory cursor must make bounded progress") + cursor = page.NextCursor + } + + assert.Equal(t, []int64{exact.ID, prefix.ID, fuzzy.ID}, got) +} + +// This runs on the configured backend and protects the persisted canonical +// order key from whitespace or Unicode collation drift between page requests. +func TestDirectoryPeopleUnicodeCursorSequenceOnTheConfiguredBackend(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + first := createDirectoryPerson(t, st, "Ålice a", "unicode-first@sample.test", "friend", "active", "Acme") + second := createDirectoryPerson(t, st, "Ålice z", "unicode-second@sample.test", "friend", "active", "Acme") + + pageOne, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1}) + require.NoError(err) + require.NotEmpty(pageOne.NextCursor) + pageTwo, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Limit: 1, Cursor: pageOne.NextCursor}) + require.NoError(err) + assert.Equal(t, []int64{first.ID, second.ID}, append(directoryPersonIDs(pageOne.People), directoryPersonIDs(pageTwo.People)...)) +} + +func TestDirectoryPeopleLastContactRangeAndCursorOnTheConfiguredBackend(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + oldest := createDirectoryPerson(t, st, "Alice Oldest", "last-contact-oldest@sample.test", "friend", "inactive", "Acme") + middle := createDirectoryPerson(t, st, "Bob Middle", "last-contact-middle@sample.test", "friend", "inactive", "Acme") + newest := createDirectoryPerson(t, st, "Carol Newest", "last-contact-newest@sample.test", "friend", "inactive", "Acme") + + oldestAt := time.Date(2026, time.January, 1, 9, 0, 0, 0, time.UTC) + middleAt := oldestAt.Add(24 * time.Hour) + newestAt := middleAt.Add(500 * time.Millisecond) + for _, contact := range []struct { + personID int64 + at time.Time + }{{oldest.ID, oldestAt}, {middle.ID, middleAt}, {newest.ID, newestAt}} { + _, err := st.DB().ExecContext(t.Context(), st.Rebind(`INSERT INTO person_contact_state ( + person_id, last_contact_at, interaction_count + ) VALUES (?, ?, 1)`), contact.personID, contact.at) + require.NoError(err) + } + + query := store.DirectoryPeopleQuery{ + LastContactAfter: &middleAt, + LastContactBefore: &newestAt, + Sort: store.DirectoryPeopleSortLastContactDesc, + Limit: 1, + } + first, err := st.DirectoryPeoplePageContext(t.Context(), query) + require.NoError(err) + require.Len(first.People, 1) + assert.Equal(newest.ID, first.People[0].ID) + require.NotNil(first.People[0].LastContactAt) + assert.Equal(newestAt, *first.People[0].LastContactAt) + require.NotEmpty(first.NextCursor) + + query.Cursor = first.NextCursor + second, err := st.DirectoryPeoplePageContext(t.Context(), query) + require.NoError(err) + assert.Equal([]int64{middle.ID}, directoryPersonIDs(second.People)) + assert.Empty(second.NextCursor) + + query.Sort = store.DirectoryPeopleSortLastContactAsc + _, err = st.DirectoryPeoplePageContext(t.Context(), query) + require.ErrorIs(err, store.ErrInvalidDirectoryCursor) + + exact, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{ + LastContactAfter: &middleAt, LastContactBefore: &middleAt, + }) + require.NoError(err) + assert.Equal([]int64{middle.ID}, directoryPersonIDs(exact.People)) +} + +// Delete keys are only an indexed prefilter: this configured-backend fixture +// proves the actual canonical token distance before Directory returns a row. +func TestDirectoryPeopleFuzzyTokenDistanceOnTheConfiguredBackend(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + insert := createDirectoryPerson(t, st, "abc", "insert@sample.test", "friend", "active", "Acme") + deleted := createDirectoryPerson(t, st, "abcde", "delete@sample.test", "friend", "active", "Acme") + substitute := createDirectoryPerson(t, st, "abxd", "substitute@sample.test", "friend", "active", "Acme") + transpose := createDirectoryPerson(t, st, "acbd", "transpose@sample.test", "friend", "active", "Acme") + invalid := createDirectoryPerson(t, st, "abcx", "invalid@sample.test", "friend", "active", "Acme") + invalidTranspose := createDirectoryPerson(t, st, "abac", "invalid-transpose@sample.test", "friend", "active", "Acme") + + for _, tc := range []struct { + query string + want int64 + }{{"abcd", insert.ID}, {"abcd", deleted.ID}, {"abcd", substitute.ID}, {"abcd", transpose.ID}} { + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Query: tc.query}) + require.NoError(err) + assert.Contains(directoryPersonIDs(page.People), tc.want) + } + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Query: "axbc"}) + require.NoError(err) + assert.NotContains(directoryPersonIDs(page.People), invalid.ID) + page, err = st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Query: "baca"}) + require.NoError(err) + assert.NotContains(directoryPersonIDs(page.People), invalidTranspose.ID) +} + +// False delete-key collisions must not consume a complete public page before +// a later verified one-edit match. The resumed page starts after the exact +// prior row and must still scan past those false raw candidates. +func TestDirectoryPeopleFuzzyCollisionPagingOnTheConfiguredBackend(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + exact := createDirectoryPerson(t, st, "axbc", "collision-exact@sample.test", "friend", "active", "Acme") + for index := range 65 { + _, err := st.DB().ExecContext(t.Context(), st.Rebind(`INSERT INTO persons (vcard_uid, display_name) VALUES (?, ?)`), fmt.Sprintf("collision-false-%03d", index), "abcx") + require.NoError(err) + } + verified := createDirectoryPerson(t, st, "axbd", "collision-verified@sample.test", "friend", "active", "Acme") + + first, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Query: "axbc", Limit: 1}) + require.NoError(err) + assert.Equal([]int64{exact.ID}, directoryPersonIDs(first.People)) + require.NotEmpty(first.NextCursor) + + second, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Query: "axbc", Limit: 1, Cursor: first.NextCursor}) + require.NoError(err) + assert.Equal([]int64{verified.ID}, directoryPersonIDs(second.People)) + assert.Empty(second.NextCursor) +} + +// Moving a current employment through a raw update must queue both the old +// and new people, so refresh never leaves the old Directory projection stale. +func TestDirectoryProjectionEmploymentMoveOnTheConfiguredBackend(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + first := createDirectoryPerson(t, st, "First Person", "move-first@sample.test", "friend", "active", "Shared Org") + second := createDirectoryPerson(t, st, "Second Person", "move-second@sample.test", "friend", "active", "Other Org") + employments, err := st.ListEmploymentsContext(t.Context(), store.EmploymentFilter{PersonID: first.ID, CurrentOnly: true}) + require.NoError(err) + require.Len(employments, 1) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`UPDATE employments SET is_primary = FALSE WHERE person_id = ?`), second.ID) + require.NoError(err) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(`UPDATE employments SET person_id = ? WHERE id = ?`), second.ID, employments[0].ID) + require.NoError(err) + require.NoError(st.RefreshDirectoryProjectionContext(t.Context())) + + page, err := st.DirectoryPeoplePageContext(t.Context(), store.DirectoryPeopleQuery{Organization: "shared org"}) + require.NoError(err) + assert.Equal(t, []int64{second.ID}, directoryPersonIDs(page.People)) +} + +// Two writers that refresh the same dirty person at the same time must both +// succeed. PostgreSQL READ COMMITTED lets both transactions read the same +// dirty row, so the second refresh must tolerate the first one committing +// its projection rows in between. +func TestDirectoryProjectionConcurrentRefreshOnTheConfiguredBackend(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + person := createDirectoryPerson(t, st, "Shared Person", "shared@sample.test", "friend", "active", "Shared Org") + ctx := t.Context() + + const workers, rounds = 2, 40 + start := make(chan struct{}) + results := make(chan error, workers) + for worker := range workers { + go func() { + <-start + for round := range rounds { + name := fmt.Sprintf("Shared Person %d-%d", worker, round) + if _, err := st.DB().ExecContext(ctx, st.Rebind(`UPDATE persons SET display_name = ? WHERE id = ?`), name, person.ID); err != nil { + results <- fmt.Errorf("dirty person: %w", err) + return + } + if err := st.RefreshDirectoryProjectionContext(ctx); err != nil { + results <- fmt.Errorf("refresh round %d: %w", round, err) + return + } + } + results <- nil + }() + } + close(start) + for range workers { + select { + case err := <-results: + require.NoError(err) + case <-time.After(60 * time.Second): + require.FailNow("concurrent Directory refresh did not finish") + } + } + + require.NoError(st.RefreshDirectoryProjectionContext(ctx)) + var projected, dirty int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) FROM directory_people WHERE person_id = ?`), person.ID).Scan(&projected)) + require.NoError(st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM directory_projection_dirty`).Scan(&dirty)) + assert.Equal(t, 1, projected) + assert.Zero(t, dirty) + page, err := st.DirectoryPeoplePageContext(ctx, store.DirectoryPeopleQuery{Query: "shared"}) + require.NoError(err) + assert.Equal(t, []int64{person.ID}, directoryPersonIDs(page.People)) +} + func TestFullProfileLifecycleOnConfiguredBackend(t *testing.T) { require := require.New(t) assert := assert.New(t) diff --git a/internal/store/persons.go b/internal/store/persons.go index 87ba2dd66..f7ca2752f 100644 --- a/internal/store/persons.go +++ b/internal/store/persons.go @@ -21,6 +21,9 @@ var ( ErrPersonCardDAVPublished = errors.New("person has CardDAV publication state") ErrPersonMergeActive = errors.New("person has active merge lineage") ErrPersonEnrichmentDispatchInProgress = errors.New("person enrichment provider dispatch is in progress") + ErrInvalidDirectoryQuery = errors.New("invalid directory query") + ErrInvalidDirectoryCursor = errors.New("invalid directory cursor") + ErrDirectoryProjectionStale = errors.New("directory projection is stale") ) // PersonBindingConflictError reports the curated people that would be diff --git a/internal/store/schema.sql b/internal/store/schema.sql index 324543cce..bdcc469b2 100644 --- a/internal/store/schema.sql +++ b/internal/store/schema.sql @@ -111,6 +111,35 @@ CREATE TABLE IF NOT EXISTS carddav_discovery_lock ( ); INSERT OR IGNORE INTO carddav_discovery_lock(singleton) VALUES (1); +CREATE TABLE IF NOT EXISTS carddav_sync_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trigger TEXT NOT NULL CHECK (trigger IN ('manual', 'scheduled')), + full_sync BOOLEAN NOT NULL DEFAULT FALSE CHECK (full_sync IN (FALSE, TRUE)), + state TEXT NOT NULL CHECK (state IN ('running', 'succeeded', 'failed', 'cancelled', 'partial')), + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME, + books INTEGER NOT NULL DEFAULT 0 CHECK (books >= 0), + created INTEGER NOT NULL DEFAULT 0 CHECK (created >= 0), + updated INTEGER NOT NULL DEFAULT 0 CHECK (updated >= 0), + removed INTEGER NOT NULL DEFAULT 0 CHECK (removed >= 0), + error_code TEXT NOT NULL DEFAULT '' CHECK ( + error_code = '' OR (length(error_code) <= 64 AND + substr(error_code, 1, 1) GLOB '[a-z]' AND + error_code NOT GLOB '*[^a-z0-9_]*') + ), + error_message TEXT NOT NULL DEFAULT '' CHECK (length(CAST(error_message AS BLOB)) <= 2000), + CHECK ((state = 'running' AND finished_at IS NULL) OR + (state <> 'running' AND finished_at IS NOT NULL)), + CHECK ((state IN ('running', 'succeeded') AND error_code = '' AND error_message = '') OR + (state IN ('failed', 'cancelled', 'partial') AND error_code <> '')) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_carddav_sync_runs_one_active + ON carddav_sync_runs((1)) WHERE state = 'running'; +CREATE INDEX IF NOT EXISTS idx_carddav_sync_runs_state_id + ON carddav_sync_runs(state, id DESC); +CREATE INDEX IF NOT EXISTS idx_carddav_sync_runs_operations_order + ON carddav_sync_runs(started_at DESC, id DESC); + CREATE TABLE IF NOT EXISTS carddav_accounts ( id INTEGER PRIMARY KEY CHECK (id = 1), base_url TEXT NOT NULL, @@ -727,6 +756,14 @@ CREATE TABLE IF NOT EXISTS person_sweep_runs ( started_at TEXT NOT NULL, completed_at TEXT ); +CREATE INDEX IF NOT EXISTS idx_person_sweep_runs_operations_order + ON person_sweep_runs(started_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_sweep_runs_operations_bytewise_order + ON person_sweep_runs(started_at DESC, id COLLATE BINARY DESC); +CREATE INDEX IF NOT EXISTS idx_person_sweep_runs_operations_running + ON person_sweep_runs(started_at DESC, id COLLATE BINARY DESC) WHERE status = 'running'; +CREATE INDEX IF NOT EXISTS idx_person_sweep_runs_operations_succeeded + ON person_sweep_runs(started_at DESC, id COLLATE BINARY DESC) WHERE status = 'succeeded'; CREATE TABLE IF NOT EXISTS person_sweep_attempts ( id TEXT PRIMARY KEY, @@ -766,6 +803,12 @@ CREATE INDEX IF NOT EXISTS idx_person_sweep_attempts_person_started ON person_sweep_attempts(person_id, started_at DESC); CREATE INDEX IF NOT EXISTS idx_person_sweep_attempts_run ON person_sweep_attempts(run_id, id); +CREATE INDEX IF NOT EXISTS idx_person_sweep_attempts_operations_failure + ON person_sweep_attempts( + run_id, + COALESCE(completed_at, started_at) DESC, + id COLLATE BINARY DESC + ) WHERE failure_class <> ''; CREATE INDEX IF NOT EXISTS idx_person_sweep_attempts_generation ON person_sweep_attempts(generation_id); @@ -1736,6 +1779,12 @@ CREATE INDEX IF NOT EXISTS idx_message_labels_label ON message_labels(label_id); -- Sync CREATE INDEX IF NOT EXISTS idx_sync_runs_source ON sync_runs(source_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_sync_runs_operations_order + ON sync_runs(started_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_sync_runs_operations_running + ON sync_runs(started_at DESC, id DESC) WHERE status = 'running'; +CREATE INDEX IF NOT EXISTS idx_sync_runs_operations_succeeded + ON sync_runs(started_at DESC, id DESC) WHERE status = 'completed' AND errors_count = 0; CREATE INDEX IF NOT EXISTS idx_sync_run_items_run_status ON sync_run_items(sync_run_id, status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_source_import_items_source_provider @@ -1958,6 +2007,14 @@ CREATE INDEX IF NOT EXISTS idx_person_relationships_target CREATE INDEX IF NOT EXISTS idx_person_relationships_target_active ON person_relationships(target_person_id, relationship_type_id) WHERE end_year IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_relationships_source_edge + ON person_relationships(source_person_id, id); +CREATE INDEX IF NOT EXISTS idx_person_relationships_target_edge + ON person_relationships(target_person_id, id); +CREATE INDEX IF NOT EXISTS idx_person_relationships_source_current_edge + ON person_relationships(source_person_id, id) WHERE end_year IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_relationships_target_current_edge + ON person_relationships(target_person_id, id) WHERE end_year IS NULL; CREATE INDEX IF NOT EXISTS idx_person_relationships_type ON person_relationships(relationship_type_id); @@ -3370,6 +3427,12 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_employments_active_person_org_title CREATE INDEX IF NOT EXISTS idx_employments_person ON employments(person_id); CREATE INDEX IF NOT EXISTS idx_employments_organization ON employments(organization_id); CREATE INDEX IF NOT EXISTS idx_employments_person_current ON employments(person_id) WHERE is_current = 1; +CREATE INDEX IF NOT EXISTS idx_employments_person_edge ON employments(person_id, id); +CREATE INDEX IF NOT EXISTS idx_employments_organization_edge ON employments(organization_id, id); +CREATE INDEX IF NOT EXISTS idx_employments_person_current_edge + ON employments(person_id, id) WHERE is_current = 1; +CREATE INDEX IF NOT EXISTS idx_employments_organization_current_edge + ON employments(organization_id, id) WHERE is_current = 1; CREATE INDEX IF NOT EXISTS idx_employments_address ON employments(address_id) WHERE address_id IS NOT NULL; -- ============================================================================ diff --git a/internal/store/schema_pg.sql b/internal/store/schema_pg.sql index aff850366..3743bf289 100644 --- a/internal/store/schema_pg.sql +++ b/internal/store/schema_pg.sql @@ -107,6 +107,33 @@ CREATE TABLE IF NOT EXISTS carddav_discovery_lock ( ); INSERT INTO carddav_discovery_lock(singleton) VALUES (1) ON CONFLICT DO NOTHING; +CREATE TABLE IF NOT EXISTS carddav_sync_runs ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + trigger TEXT NOT NULL CHECK (trigger IN ('manual', 'scheduled')), + full_sync BOOLEAN NOT NULL DEFAULT FALSE, + state TEXT NOT NULL CHECK (state IN ('running', 'succeeded', 'failed', 'cancelled', 'partial')), + started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMPTZ, + books BIGINT NOT NULL DEFAULT 0 CHECK (books >= 0), + created BIGINT NOT NULL DEFAULT 0 CHECK (created >= 0), + updated BIGINT NOT NULL DEFAULT 0 CHECK (updated >= 0), + removed BIGINT NOT NULL DEFAULT 0 CHECK (removed >= 0), + error_code TEXT NOT NULL DEFAULT '' CHECK ( + error_code = '' OR error_code ~ '^[a-z][a-z0-9_]{0,63}$' + ), + error_message TEXT NOT NULL DEFAULT '' CHECK (octet_length(error_message) <= 2000), + CHECK ((state = 'running' AND finished_at IS NULL) OR + (state <> 'running' AND finished_at IS NOT NULL)), + CHECK ((state IN ('running', 'succeeded') AND error_code = '' AND error_message = '') OR + (state IN ('failed', 'cancelled', 'partial') AND error_code <> '')) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_carddav_sync_runs_one_active + ON carddav_sync_runs((1)) WHERE state = 'running'; +CREATE INDEX IF NOT EXISTS idx_carddav_sync_runs_state_id + ON carddav_sync_runs(state, id DESC); +CREATE INDEX IF NOT EXISTS idx_carddav_sync_runs_operations_order + ON carddav_sync_runs(started_at DESC, id DESC); + CREATE TABLE IF NOT EXISTS carddav_accounts ( id SMALLINT PRIMARY KEY CHECK (id = 1), base_url TEXT NOT NULL, @@ -706,6 +733,14 @@ CREATE TABLE IF NOT EXISTS person_sweep_runs ( started_at TIMESTAMPTZ NOT NULL, completed_at TIMESTAMPTZ ); +CREATE INDEX IF NOT EXISTS idx_person_sweep_runs_operations_order + ON person_sweep_runs(started_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_sweep_runs_operations_bytewise_order + ON person_sweep_runs(started_at DESC, id COLLATE "C" DESC); +CREATE INDEX IF NOT EXISTS idx_person_sweep_runs_operations_running + ON person_sweep_runs(started_at DESC, id COLLATE "C" DESC) WHERE status = 'running'; +CREATE INDEX IF NOT EXISTS idx_person_sweep_runs_operations_succeeded + ON person_sweep_runs(started_at DESC, id COLLATE "C" DESC) WHERE status = 'succeeded'; CREATE TABLE IF NOT EXISTS person_sweep_attempts ( id TEXT PRIMARY KEY, @@ -745,6 +780,12 @@ CREATE INDEX IF NOT EXISTS idx_person_sweep_attempts_person_started ON person_sweep_attempts(person_id, started_at DESC); CREATE INDEX IF NOT EXISTS idx_person_sweep_attempts_run ON person_sweep_attempts(run_id, id); +CREATE INDEX IF NOT EXISTS idx_person_sweep_attempts_operations_failure + ON person_sweep_attempts( + run_id, + (COALESCE(completed_at, started_at)) DESC, + id COLLATE "C" DESC + ) WHERE failure_class <> ''; CREATE INDEX IF NOT EXISTS idx_person_sweep_attempts_generation ON person_sweep_attempts(generation_id); @@ -1778,6 +1819,12 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_employments_active_person_org_title CREATE INDEX IF NOT EXISTS idx_employments_person ON employments(person_id); CREATE INDEX IF NOT EXISTS idx_employments_organization ON employments(organization_id); CREATE INDEX IF NOT EXISTS idx_employments_person_current ON employments(person_id) WHERE is_current; +CREATE INDEX IF NOT EXISTS idx_employments_person_edge ON employments(person_id, id); +CREATE INDEX IF NOT EXISTS idx_employments_organization_edge ON employments(organization_id, id); +CREATE INDEX IF NOT EXISTS idx_employments_person_current_edge + ON employments(person_id, id) WHERE is_current; +CREATE INDEX IF NOT EXISTS idx_employments_organization_current_edge + ON employments(organization_id, id) WHERE is_current; CREATE INDEX IF NOT EXISTS idx_employments_address ON employments(address_id) WHERE address_id IS NOT NULL; -- Daemon-owned analytical Saved Views. Canonical state contains only the @@ -1968,6 +2015,14 @@ CREATE INDEX IF NOT EXISTS idx_person_relationships_target CREATE INDEX IF NOT EXISTS idx_person_relationships_target_active ON person_relationships(target_person_id, relationship_type_id) WHERE end_year IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_relationships_source_edge + ON person_relationships(source_person_id, id); +CREATE INDEX IF NOT EXISTS idx_person_relationships_target_edge + ON person_relationships(target_person_id, id); +CREATE INDEX IF NOT EXISTS idx_person_relationships_source_current_edge + ON person_relationships(source_person_id, id) WHERE end_year IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_relationships_target_current_edge + ON person_relationships(target_person_id, id) WHERE end_year IS NULL; CREATE INDEX IF NOT EXISTS idx_person_relationships_type ON person_relationships(relationship_type_id); @@ -3340,6 +3395,12 @@ CREATE INDEX IF NOT EXISTS idx_labels_source ON labels(source_id); CREATE INDEX IF NOT EXISTS idx_message_labels_label ON message_labels(label_id); CREATE INDEX IF NOT EXISTS idx_sync_runs_source ON sync_runs(source_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_sync_runs_operations_order + ON sync_runs(started_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_sync_runs_operations_running + ON sync_runs(started_at DESC, id DESC) WHERE status = 'running'; +CREATE INDEX IF NOT EXISTS idx_sync_runs_operations_succeeded + ON sync_runs(started_at DESC, id DESC) WHERE status = 'completed' AND errors_count = 0; CREATE INDEX IF NOT EXISTS idx_sync_run_items_run_status ON sync_run_items(sync_run_id, status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_source_import_items_source_provider diff --git a/internal/store/store.go b/internal/store/store.go index c67045a66..827bab69f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -50,6 +50,10 @@ type Store struct { readOnly bool // Opened via OpenReadOnly; skips WAL checkpoint on close fts5Available bool // Whether FTS5 is available for full-text search closeCleanup func() + // directoryProjectionReady becomes true only after InitSchema has created + // the projection tables and dirty-marking triggers. Every writable Store + // transaction then refreshes its affected Directory rows before commit. + directoryProjectionReady bool // syncGeneration is immutable metadata on a per-run Store view. // Mutating transactions on that view fence the exact running source @@ -80,6 +84,7 @@ type Store struct { listIDRepairAfterFingerprintLockHook func() cardDAVConflictResolveSnapshotHook func() cardDAVTombstonePrepareSnapshotHook func() + cardDAVPublicationStateReadHook func() identityMatchAcceptBeforeDecisionHook func() senderRepairMessageLockHook func() personOperationBeforeIdentityLockHook func() @@ -89,6 +94,9 @@ type Store struct { personEnrichmentRunBarrier func(phase string) personEnrichmentTxBarrier func(phase string) personEnrichmentOwnershipBarrier func(phase string, tx *loggedTx) + personNetworkSourceReadHook func(limit, count int) + operationHistoryAfterAdapterReadHook func(kind string) + operationHistoryStatusAfterActiveHook func(kind string) // Zero means "use the production batch size"; see // contentChangedBackfillBatch and rfc822IDBackfillBatch. Per-Store for @@ -225,6 +233,10 @@ func openSQLite(dbPath, params string) (*Store, error) { return nil, fmt.Errorf("probe FTS availability: %w", err) } s.fts5Available = available + if err := s.detectDirectoryProjectionReadiness(context.Background()); err != nil { + _ = db.Close() + return nil, err + } return s, nil } @@ -280,10 +292,31 @@ func openPostgres(dbURL string) (*Store, error) { return nil, fmt.Errorf("probe FTS availability: %w", err) } s.fts5Available = available + if err := s.detectDirectoryProjectionReadiness(context.Background()); err != nil { + _ = db.Close() + cleanup() + return nil, err + } return s, nil } +// detectDirectoryProjectionReadiness distinguishes an old database without +// the optional Directory projection from one whose dirty queue must be +// respected by a read-only Store. It does not create or migrate anything. +func (s *Store) detectDirectoryProjectionReadiness(ctx context.Context) error { + var installed bool + query := `SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'directory_projection_dirty')` + if s.IsPostgreSQL() { + query = `SELECT to_regclass('directory_projection_dirty') IS NOT NULL` + } + if err := s.db.QueryRowContext(ctx, query).Scan(&installed); err != nil { + return fmt.Errorf("detect directory projection: %w", err) + } + s.directoryProjectionReady = installed + return nil +} + // OpenReadOnly opens an existing database in read-only mode. Suitable for // query-only workloads (MCP server) where multiple processes access the // same database concurrently. Does not create the database, run migrations, @@ -343,6 +376,10 @@ func OpenReadOnly(dbPath string) (*Store, error) { return nil, fmt.Errorf("probe FTS availability: %w", err) } s.fts5Available = available + if err := s.detectDirectoryProjectionReadiness(context.Background()); err != nil { + _ = db.Close() + return nil, err + } return s, nil } @@ -395,6 +432,11 @@ func openPostgresReadOnly(dbURL string) (*Store, error) { return nil, fmt.Errorf("probe FTS availability: %w", err) } s.fts5Available = available + if err := s.detectDirectoryProjectionReadiness(context.Background()); err != nil { + _ = db.Close() + cleanup() + return nil, err + } return s, nil } @@ -721,6 +763,12 @@ func (s *Store) withTxOptionsContext( } return err } + if s.directoryProjectionReady && !s.readOnly && (opts == nil || !opts.ReadOnly) { + if err := s.refreshDirectoryProjectionsBeforeCommitTx(ctx, tx); err != nil { + _ = tx.Rollback() + return err + } + } if err := ctx.Err(); err != nil { _ = tx.Rollback() return err @@ -1209,6 +1257,21 @@ func (s *Store) InitSchemaContext(ctx context.Context) error { ); err != nil { return fmt.Errorf("migrate person sweep call journal: %w", err) } + if err := s.ensureDirectoryProjectionInfrastructure(ctx); err != nil { + return err + } + // The Directory projection is derived from the person tables, so an + // archive that predates it gets every person marked dirty and refreshed + // once. Later opens find the ledger entry and skip the backfill; triggers + // keep the projection current from then on. + if err := s.runOnceMigration( + ctx, migrationDirectoryProjectionV1, false, + func(ctx context.Context) error { + return s.backfillDirectoryProjectionContext(ctx) + }, + ); err != nil { + return err + } // Legacy databases may hold duplicate (message_id, content_hash) // attachment rows from the old SELECT-then-INSERT UpsertAttachment. // Dedupe before creating the partial unique index that enforces diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 7d6f728fd..320043fcc 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -19,6 +19,8 @@ const ( keyNameBackspace = "backspace" keyNameCtrlU = "ctrl+u" keyNameCtrlD = "ctrl+d" + keyNameCtrlC = "ctrl+c" + keyNameRight = "right" keyNamePageUp = "pgup" keyNamePageDown = "pgdown" keyNameHome = "home" @@ -42,7 +44,7 @@ func (m Model) handleInlineSearchKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) case keyNameEsc: return m.cancelInlineSearch() - case "ctrl+c": + case keyNameCtrlC: m.quitting = true return m, tea.Quit @@ -162,7 +164,7 @@ func (m Model) handleGlobalKeys(msg tea.KeyPressMsg) (Model, tea.Cmd, bool) { case "q": m.modal = modalQuitConfirm return m, nil, true - case "ctrl+c": + case keyNameCtrlC: m.quitting = true return m, tea.Quit, true case "?": @@ -951,7 +953,7 @@ func (m Model) handleMessageDetailKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) return m.navigateDetailPrev() // Navigate to next message in list (right = towards last) - case "right", "l": + case keyNameRight, "l": return m.navigateDetailNext() // Scroll content diff --git a/internal/tui/meeting_keys.go b/internal/tui/meeting_keys.go index ef8e75af8..cd6da7c12 100644 --- a/internal/tui/meeting_keys.go +++ b/internal/tui/meeting_keys.go @@ -197,7 +197,7 @@ func (m Model) handleMeetingDetailKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) m.meetingState.detailScroll += m.visibleRows() case "left", "h": return m.changeMeetingDetail(-1) - case "right", "l": + case keyNameRight, "l": return m.changeMeetingDetail(1) case "/": m.meetingState.detailSearchActive = true @@ -227,7 +227,7 @@ func (m Model) handleMeetingDetailSearchInput(msg tea.KeyPressMsg) (tea.Model, t m.meetingState.detailSearchActive = false m.meetingState.detailSearchInput.Blur() return m, nil - case "ctrl+c": + case keyNameCtrlC: m.quitting = true return m, tea.Quit default: @@ -327,7 +327,7 @@ func (m Model) handleMeetingSearchInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd m.meetingState.searchInput.Blur() m.meetingState.searchInput.SetValue(m.meetingState.searchQuery) return m, nil - case "ctrl+c": + case keyNameCtrlC: m.quitting = true return m, tea.Quit default: diff --git a/internal/tui/model.go b/internal/tui/model.go index ab20ecf12..2394cfc8d 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -79,6 +79,10 @@ type Options struct { // the daemon falling back to live SQL because no analytics cache is // built for the archive). AnalyticsNotice string + + // SettingsBackend reads and writes the same daemon-owned settings catalog + // used by the Web UI. When nil, the Settings surface reports unavailable. + SettingsBackend SettingsBackend } // modalType represents the type of modal dialog. @@ -157,6 +161,12 @@ type Model struct { peopleBackend peoplebrowser.Backend peopleState peopleState + // Settings is a global shell surface, deliberately separate from the + // content-mode cycle so opening it cannot disturb content navigation. + settingsBackend SettingsBackend + settings settingsState + settingsRequestID uint64 + // Version info for title bar version string @@ -311,9 +321,11 @@ func New(engine query.Engine, opts Options) Model { } return Model{ - engine: engine, - textEngine: textEngine, - peopleBackend: opts.PeopleBackend, + engine: engine, + textEngine: textEngine, + peopleBackend: opts.PeopleBackend, + settingsBackend: opts.SettingsBackend, + settings: newSettingsState(), actions: NewActionControllerWithOptions(engine, ActionControllerOptions{ DataDir: opts.DataDir, ManifestSaver: opts.ManifestSaver, @@ -1093,6 +1105,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case AnalyticsNoticeMsg: m.analyticsNotice = msg.Notice return m, nil + case settingsLoadedMsg: + return m.handleSettingsLoaded(msg) + case settingsSavedMsg: + return m.handleSettingsSaved(msg) // People messages are delegated before the shared Email handlers. case peopleSearchDebounceMsg: return m.handlePeopleSearchDebounce(msg) @@ -1679,6 +1695,12 @@ func (m Model) handleSpinnerTick() (tea.Model, tea.Cmd) { // handleKeyPress processes keyboard input. func (m Model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.settings.active { + return m.handleSettingsKeyPress(msg) + } + if msg.String() == "," && m.settingsShortcutAvailable() { + return m.openSettings() + } if m.mode == modePeople { return m.handlePeopleKeyPress(msg) } @@ -1917,6 +1939,10 @@ func (m Model) View() tea.View { content = "" } else if m.width == 0 { content = "Loading..." + } else if m.settings.active { + // Settings is a shell layer above content transitions. Keep the frozen + // content frame intact underneath so Esc restores it exactly. + content = m.renderSettingsView() } else if m.transitionBuffer != "" { // If view is frozen (during level transitions), return the cached view // to prevent flashing while async data loads complete. @@ -1934,6 +1960,9 @@ func (m Model) View() tea.View { // Separated from View() so transitions can capture the current output // before changing state (for the transitionBuffer pattern). func (m Model) renderView() string { + if m.settings.active { + return m.renderSettingsView() + } if m.mode == modePeople { return m.renderPeopleView() } diff --git a/internal/tui/people_attributes.go b/internal/tui/people_attributes.go index 37f35503d..fc16c8a54 100644 --- a/internal/tui/people_attributes.go +++ b/internal/tui/people_attributes.go @@ -175,7 +175,7 @@ func (m Model) handlePeopleNewFieldKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) form := &m.peopleState.form if form.fieldFocus == peopleFieldFocusName && !form.submitting { switch msg.String() { - case keyNameEsc, "ctrl+c", keyNameTab, "shift+tab", keyNameEnter: + case keyNameEsc, keyNameCtrlC, keyNameTab, "shift+tab", keyNameEnter: default: var cmd tea.Cmd form.nameInput, cmd = form.nameInput.Update(msg) @@ -190,7 +190,7 @@ func (m Model) handlePeopleNewFieldKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) } form.close() return m, nil - case "ctrl+c": + case keyNameCtrlC: m.quitting = true return m, tea.Quit case keyNameTab: @@ -204,7 +204,7 @@ func (m Model) handlePeopleNewFieldKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) case "left", "h": m.changePeopleFieldChoice(-1) return m, nil - case "right", "l", "j", "down": + case keyNameRight, "l", "j", "down": m.changePeopleFieldChoice(1) return m, nil case "k", "up": @@ -275,7 +275,7 @@ func (m Model) handlePeopleAttributeValueKey(msg tea.KeyPressMsg) (tea.Model, te } form.close() return m, nil - case "ctrl+c": + case keyNameCtrlC: m.quitting = true return m, tea.Quit case "ctrl+s": diff --git a/internal/tui/people_keys.go b/internal/tui/people_keys.go index 9d79d0e9d..635018e0c 100644 --- a/internal/tui/people_keys.go +++ b/internal/tui/people_keys.go @@ -562,7 +562,7 @@ func (m Model) handlePeopleActivityMessageKey(msg tea.KeyPressMsg) (tea.Model, t return m, nil } if msg.String() == "left" || msg.String() == "h" || - msg.String() == "right" || msg.String() == "l" { + msg.String() == keyNameRight || msg.String() == "l" { return m, nil } if msg.String() == "r" { @@ -595,7 +595,7 @@ func (m Model) handlePeopleMeetingDetailKey(msg tea.KeyPressMsg) (tea.Model, tea m.peopleState.meetingsErr = nil m.updatePeopleLoading() return m, nil - case "left", "h", "right", "l": + case "left", "h", keyNameRight, "l": return m, nil case "r": if m.peopleState.meetingsErr == nil || m.peopleState.selectedContentMessage <= 0 { @@ -899,7 +899,7 @@ func (m Model) handlePeopleMessageKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) return m, nil } if msg.String() == "left" || msg.String() == "h" || - msg.String() == "right" || msg.String() == "l" { + msg.String() == keyNameRight || msg.String() == "l" { return m, nil } return m.handleMessageDetailKeys(msg) @@ -1080,7 +1080,7 @@ func (m Model) handlePeopleSearchInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) } m.settlePeopleDirectoryLoad() return m, nil - case "ctrl+c": + case keyNameCtrlC: m.quitting = true return m, tea.Quit default: diff --git a/internal/tui/settings_keys.go b/internal/tui/settings_keys.go new file mode 100644 index 000000000..02abbbc4c --- /dev/null +++ b/internal/tui/settings_keys.go @@ -0,0 +1,324 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" +) + +const settingsWideWidth = 86 + +func (m Model) settingsIsNarrow() bool { + return m.width > 0 && m.width < settingsWideWidth +} + +func (m Model) handleSettingsKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if msg.String() == keyNameCtrlC { + m.quitting = true + return m, tea.Quit + } + if m.settings.saving { + if msg.String() == keyNameEsc { + m.settings.confirmDiscard = false + m.settings.status = "Save in progress; wait for it to finish." + m.settings.statusIsError = false + } + return m, nil + } + if m.settings.confirmDiscard { + switch msg.String() { + case "y", "Y": + m.settings = newSettingsState() + case "n", "N", keyNameEsc: + m.settings.confirmDiscard = false + } + return m, nil + } + + if m.settings.editing { + return m.handleSettingsEditorKey(msg) + } + + switch msg.String() { + case "ctrl+s": + return m.saveSettings() + case keyNameEsc: + if m.settings.dirty() { + m.settings.confirmDiscard = true + m.settings.status = "" + return m, nil + } + m.settings = newSettingsState() + return m, nil + } + + if m.settings.loading { + return m, nil + } + if m.settingsIsNarrow() && !m.settings.narrowFields { + return m.handleNarrowSettingsCategoryKey(msg) + } + + switch msg.String() { + case "up", "k", keyNameCtrlP: + if m.settings.rowCursor > 0 { + m.settings.rowCursor-- + } + case keyNameDown, "j", keyNameCtrlN: + if m.settings.rowCursor+1 < len(m.settings.currentFields()) { + m.settings.rowCursor++ + } + case "left", "h": + if m.settingsIsNarrow() { + m.settings.narrowFields = false + return m, nil + } + m.changeSettingsGroup(-1) + case keyNameRight, "l": + if !m.settingsIsNarrow() { + m.changeSettingsGroup(1) + } + case keyNameEnter: + return m.beginSettingsEdit() + case " ", "space": + m.toggleSelectedBooleanSetting() + case "x": + m.clearSelectedSecretSetting() + } + return m, nil +} + +func (m Model) handleNarrowSettingsCategoryKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k", keyNameCtrlP: + if m.settings.groupCursor > 0 { + m.settings.groupCursor-- + } + case keyNameDown, "j", keyNameCtrlN: + if m.settings.groupCursor+1 < len(m.settings.groups) { + m.settings.groupCursor++ + } + case keyNameEnter, keyNameRight, "l": + if len(m.settings.groups) > 0 { + m.settings.narrowFields = true + m.settings.rowCursor = 0 + } + } + return m, nil +} + +func (m *Model) changeSettingsGroup(delta int) { + if len(m.settings.groups) == 0 { + return + } + m.settings.groupCursor = min(max(m.settings.groupCursor+delta, 0), len(m.settings.groups)-1) + m.settings.rowCursor = 0 + m.settings.status = "" +} + +func (m Model) beginSettingsEdit() (tea.Model, tea.Cmd) { + field, ok := m.settings.selectedField() + if !ok { + return m, nil + } + if field.ReadOnly { + m.settings.status = "This setting is read-only; edit it on the daemon host." + m.settings.statusIsError = false + return m, nil + } + if field.Kind == SettingKindBoolean { + m.toggleSelectedBooleanSetting() + return m, nil + } + + m.settings.editing = true + m.settings.editKey = field.Key + m.settings.status = "" + m.settings.statusIsError = false + if len(field.Options) > 0 { + m.settings.editOptions = append([]string(nil), field.Options...) + current := m.currentSettingText(field) + m.settings.editOption = 0 + for i, option := range field.Options { + if option == current { + m.settings.editOption = i + break + } + } + return m, nil + } + + input := textinput.New() + input.CharLimit = 2048 + input.SetWidth(max(min(m.width-12, 64), 12)) + input.Placeholder = "enter value" + if field.Kind == SettingKindSecret { + input.EchoMode = textinput.EchoPassword + input.EchoCharacter = '*' + input.Placeholder = "new secret (write-only)" + } else { + input.SetValue(m.currentSettingText(field)) + } + m.settings.editor = input + return m, m.settings.editor.Focus() +} + +func (m Model) handleSettingsEditorKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + field, ok := m.settingFieldByKey(m.settings.editKey) + if !ok { + m.cancelSettingsEdit() + return m, nil + } + if msg.String() == "ctrl+s" { + if !m.commitSettingsEdit(field) { + return m, nil + } + return m.saveSettings() + } + if len(m.settings.editOptions) > 0 { + switch msg.String() { + case "left", "h", "up", "k": + if m.settings.editOption > 0 { + m.settings.editOption-- + } + case keyNameRight, "l", keyNameDown, "j": + if m.settings.editOption+1 < len(m.settings.editOptions) { + m.settings.editOption++ + } + case keyNameEnter: + m.commitSettingsEdit(field) + case keyNameEsc: + m.cancelSettingsEdit() + } + return m, nil + } + + switch msg.String() { + case keyNameEnter: + m.commitSettingsEdit(field) + return m, nil + case keyNameEsc: + m.cancelSettingsEdit() + return m, nil + case keyNameCtrlC: + m.quitting = true + return m, tea.Quit + } + + var cmd tea.Cmd + m.settings.editor, cmd = m.settings.editor.Update(msg) + return m, cmd +} + +func (m *Model) commitSettingsEdit(field SettingField) bool { + if len(m.settings.editOptions) > 0 { + if m.settings.editOption < 0 || m.settings.editOption >= len(m.settings.editOptions) { + return false + } + value := m.settings.editOptions[m.settings.editOption] + m.setSettingsDraft(field, SettingUpdate{Key: field.Key, Value: &SettingValue{String: &value}}) + m.cancelSettingsEdit() + return true + } + + raw := m.settings.editor.Value() + if field.Kind == SettingKindSecret { + if raw != "" { + m.setSecretDraft(field, "set", raw) + } + m.cancelSettingsEdit() + return true + } + value, err := parseSettingInput(field, raw) + if err != nil { + m.settings.status = "Invalid value: " + err.Error() + m.settings.statusIsError = true + return false + } + m.setSettingsDraft(field, SettingUpdate{Key: field.Key, Value: value}) + m.cancelSettingsEdit() + return true +} + +func (m *Model) cancelSettingsEdit() { + m.settings.editor.Blur() + m.settings.editor.Reset() + m.settings.editing = false + m.settings.editKey = "" + m.settings.editOptions = nil + m.settings.editOption = 0 +} + +func (m *Model) toggleSelectedBooleanSetting() { + field, ok := m.settings.selectedField() + if !ok || field.ReadOnly || field.Kind != SettingKindBoolean { + return + } + current := false + if draft, ok := m.settings.drafts[field.Key]; ok && draft.Value != nil && draft.Value.Boolean != nil { + current = *draft.Value.Boolean + } else if field.Value != nil && field.Value.Boolean != nil { + current = *field.Value.Boolean + } + value := !current + m.setSettingsDraft(field, SettingUpdate{Key: field.Key, Value: &SettingValue{Boolean: &value}}) +} + +func (m *Model) clearSelectedSecretSetting() { + field, ok := m.settings.selectedField() + if !ok || field.ReadOnly || field.Kind != SettingKindSecret { + return + } + configured := field.Secret != nil && field.Secret.Configured + if !configured { + delete(m.settings.configSecretDrafts, field.Key) + delete(m.settings.credentialDrafts, field.Key) + return + } + m.setSecretDraft(field, "clear", "") +} + +func (m *Model) setSecretDraft(field SettingField, action, value string) { + credentialID := settingCredentialID(field) + if credentialID != "" { + delete(m.settings.configSecretDrafts, field.Key) + m.setCredentialDraft(field, CredentialUpdate{ + Key: field.Key, CredentialID: credentialID, Action: action, Value: value, + }) + return + } + delete(m.settings.credentialDrafts, field.Key) + m.setConfigSecretDraft(field, ConfigSecretUpdate{ + Key: field.Key, Action: action, Value: value, + }) +} + +func (m Model) currentSettingText(field SettingField) string { + if draft, ok := m.settings.drafts[field.Key]; ok && draft.Value != nil { + return settingUpdateText(draft) + } + return settingValueText(field.Value) +} + +func (m Model) secretSettingDisplay(field SettingField) string { + if draft, ok := m.settings.configSecretDrafts[field.Key]; ok { + if strings.EqualFold(draft.Action, "clear") { + return "not configured after save" + } + return "configured after save" + } + if draft, ok := m.settings.credentialDrafts[field.Key]; ok { + if strings.EqualFold(draft.Action, "clear") { + return "clear stored override; environment may remain" + } + return "configured after save" + } + if field.Secret == nil || !field.Secret.Configured { + return "not configured" + } + if source := strings.TrimSpace(field.Secret.Source); source != "" { + return "configured (" + source + ")" + } + return "configured" +} diff --git a/internal/tui/settings_state.go b/internal/tui/settings_state.go new file mode 100644 index 000000000..194f0ebc1 --- /dev/null +++ b/internal/tui/settings_state.go @@ -0,0 +1,727 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "reflect" + "sort" + "strconv" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" +) + +// SettingsBackend is the daemon settings boundary used by the TUI. The TUI +// intentionally owns these provider-neutral types instead of depending on a +// generated HTTP client contract. +type SettingsBackend interface { + LoadSettings(ctx context.Context) (SettingsSnapshot, error) + SaveSettings(ctx context.Context, request SettingsSaveRequest) (SettingsSnapshot, error) +} + +// SettingsConflictScope identifies which optimistic-concurrency token became +// stale. Config and provider credentials are separate persistence domains. +type SettingsConflictScope string + +const ( + SettingsConflictConfig SettingsConflictScope = "config" + SettingsConflictCredentials SettingsConflictScope = "credentials" +) + +// SettingsConflictError reports an optimistic-concurrency conflict. The TUI +// reloads the latest snapshot and reapplies the user's unsaved drafts. +type SettingsConflictError struct { + Scope SettingsConflictScope + Err error +} + +func (e *SettingsConflictError) Error() string { + if e == nil || e.Err == nil { + return "settings changed concurrently" + } + return e.Err.Error() +} + +func (e *SettingsConflictError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// SettingsPartialSaveError reports that the backend committed only a subset +// of the requested keys. SavedKeys lets the TUI clear those drafts while +// retaining every mutation that still needs attention. +type SettingsPartialSaveError struct { + SavedKeys []string + Err error +} + +func (e *SettingsPartialSaveError) Error() string { + if e == nil || e.Err == nil { + return "settings were only partially saved" + } + return e.Err.Error() +} + +func (e *SettingsPartialSaveError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// SettingKind identifies the editor and renderer for one setting. +type SettingKind string + +const ( + SettingKindString SettingKind = "string" + SettingKindInteger SettingKind = "integer" + SettingKindNumber SettingKind = "number" + SettingKindBoolean SettingKind = "boolean" + SettingKindStringArray SettingKind = "string_array" + SettingKindSecret SettingKind = "secret" +) + +// SettingsGroup is a daemon-described settings category. +type SettingsGroup struct { + ID string + Label string +} + +// SettingValidation describes safe, non-secret input constraints. +type SettingValidation struct { + Hint string + Required bool + Minimum *float64 + Maximum *float64 +} + +// SecretSettingState is the only secret state that may be loaded or rendered. +// Secret values themselves are write-only. +type SecretSettingState struct { + Configured bool + Source string +} + +// SettingValue mirrors the daemon's explicit scalar union without importing a +// generated API package into the TUI. +type SettingValue struct { + String *string + Integer *int + Number *float64 + Boolean *bool + Strings *[]string +} + +// SettingField describes one daemon-managed setting. +type SettingField struct { + Key string + CredentialID string + Group string + Label string + Description string + Kind SettingKind + Value *SettingValue + Secret *SecretSettingState + Options []string + ReadOnly bool + RestartRequired bool + Validation SettingValidation +} + +// SettingsSnapshot is one ETag-addressed settings read. +type SettingsSnapshot struct { + ETag string + CredentialETag string + Groups []SettingsGroup + Fields []SettingField + PendingRestart bool +} + +// CredentialUpdate is a write-only provider-credential mutation. It is kept +// separate from SettingUpdate so callers cannot accidentally send secrets to +// PATCH /settings. +type CredentialUpdate struct { + Key string + CredentialID string + Action string + Value string +} + +// ConfigSecretUpdate is a write-only legacy config-file secret mutation. It +// uses PATCH /settings and the config ETag, unlike provider credentials. +type ConfigSecretUpdate struct { + Key string + Action string + Value string +} + +// SettingUpdate is one non-secret PATCH /settings mutation. +type SettingUpdate struct { + Key string + Value *SettingValue +} + +// SettingsSaveRequest carries both concurrency tokens while keeping config +// and credential mutations on distinct lanes for the daemon adapter. +type SettingsSaveRequest struct { + ConfigETag string + CredentialETag string + Updates []SettingUpdate + ConfigSecrets []ConfigSecretUpdate + Credentials []CredentialUpdate +} + +type settingsState struct { + active bool + loading bool + saving bool + confirmDiscard bool + narrowFields bool + + groups []SettingsGroup + fields []SettingField + etag string + credentialETag string + pendingRestart bool + groupCursor int + rowCursor int + drafts map[string]SettingUpdate + configSecretDrafts map[string]ConfigSecretUpdate + credentialDrafts map[string]CredentialUpdate + status string + statusIsError bool + requestID uint64 + + editing bool + editKey string + editOptions []string + editOption int + editor textinput.Model +} + +func newSettingsState() settingsState { + input := textinput.New() + input.CharLimit = 2048 + input.SetWidth(48) + return settingsState{ + drafts: make(map[string]SettingUpdate), + configSecretDrafts: make(map[string]ConfigSecretUpdate), + credentialDrafts: make(map[string]CredentialUpdate), + editor: input, + } +} + +func (s settingsState) dirty() bool { + return len(s.drafts) > 0 || len(s.configSecretDrafts) > 0 || len(s.credentialDrafts) > 0 +} + +// settingsShortcutAvailable reports whether comma is a shell command in the +// current interaction state. Active text inputs own printable characters. +func (m Model) settingsShortcutAvailable() bool { + if m.modal != modalNone { + return false + } + switch m.mode { + case modePeople: + if m.peopleState.form.overlay != peopleOverlayNone || m.peopleState.searchActive { + return false + } + if (m.peopleState.level == peopleLevelMessage || + m.peopleState.level == peopleLevelActivityMessage) && m.detailSearchActive { + return false + } + return m.peopleState.level != peopleLevelMeetingDetail || !m.meetingState.detailSearchActive + case modeMeetings: + return !m.meetingState.searchActive && + (m.meetingState.level != meetingLevelDetail || !m.meetingState.detailSearchActive) + case modeTexts: + return !m.inlineSearchActive && + (m.textState.level != textLevelDetail || !m.detailSearchActive) + default: + return !m.inlineSearchActive && + (m.level != levelMessageDetail || !m.detailSearchActive) + } +} + +func (s settingsState) currentGroup() string { + if s.groupCursor < 0 || s.groupCursor >= len(s.groups) { + return "" + } + return s.groups[s.groupCursor].ID +} + +func (s settingsState) currentFields() []SettingField { + group := s.currentGroup() + result := make([]SettingField, 0) + for _, field := range s.fields { + if field.Group == group { + result = append(result, field) + } + } + return result +} + +func (s settingsState) selectedField() (SettingField, bool) { + fields := s.currentFields() + if s.rowCursor < 0 || s.rowCursor >= len(fields) { + return SettingField{}, false + } + return fields[s.rowCursor], true +} + +type settingsLoadedMsg struct { + snapshot SettingsSnapshot + err error + requestID uint64 +} + +type settingsSavedMsg struct { + snapshot SettingsSnapshot + err error + requestID uint64 + conflict bool + partial bool + savedKeys []string + reloadErr error +} + +func (m Model) openSettings() (tea.Model, tea.Cmd) { + m.settings = newSettingsState() + m.settings.active = true + m.settings.loading = true + m.settingsRequestID++ + m.settings.requestID = m.settingsRequestID + requestID := m.settingsRequestID + backend := m.settingsBackend + if backend == nil { + return m, func() tea.Msg { + return settingsLoadedMsg{err: errors.New("settings are unavailable"), requestID: requestID} + } + } + return m, func() tea.Msg { + snapshot, err := backend.LoadSettings(context.Background()) + return settingsLoadedMsg{snapshot: snapshot, err: err, requestID: requestID} + } +} + +func (m Model) handleSettingsLoaded(msg settingsLoadedMsg) (tea.Model, tea.Cmd) { + if !m.settings.active || msg.requestID != m.settings.requestID { + return m, nil + } + m.settings.loading = false + if msg.err != nil { + m.settings.status = "Could not load settings: " + msg.err.Error() + m.settings.statusIsError = true + return m, nil + } + m.applySettingsSnapshot(msg.snapshot, false) + return m, nil +} + +func (m *Model) applySettingsSnapshot(snapshot SettingsSnapshot, preserveDrafts bool) { + previousDrafts := m.settings.drafts + previousConfigSecretDrafts := m.settings.configSecretDrafts + previousCredentialDrafts := m.settings.credentialDrafts + fields := make([]SettingField, 0, len(snapshot.Fields)) + availableGroups := make(map[string]bool) + for _, field := range snapshot.Fields { + if strings.HasPrefix(field.Key, "web.") { + continue + } + fields = append(fields, field) + availableGroups[field.Group] = true + } + + groups := make([]SettingsGroup, 0, len(snapshot.Groups)) + seenGroups := make(map[string]bool) + for _, group := range snapshot.Groups { + if !availableGroups[group.ID] || seenGroups[group.ID] { + continue + } + groups = append(groups, group) + seenGroups[group.ID] = true + } + for _, field := range fields { + if seenGroups[field.Group] { + continue + } + groups = append(groups, SettingsGroup{ID: field.Group, Label: settingsGroupLabel(field.Group)}) + seenGroups[field.Group] = true + } + + m.settings.groups = groups + m.settings.fields = fields + m.settings.etag = snapshot.ETag + m.settings.credentialETag = snapshot.CredentialETag + m.settings.pendingRestart = snapshot.PendingRestart + m.settings.loading = false + m.settings.saving = false + m.settings.confirmDiscard = false + m.settings.editing = false + if m.settings.groupCursor >= len(groups) { + m.settings.groupCursor = max(len(groups)-1, 0) + } + m.clampSettingsRow() + + if !preserveDrafts { + m.settings.drafts = make(map[string]SettingUpdate) + m.settings.configSecretDrafts = make(map[string]ConfigSecretUpdate) + m.settings.credentialDrafts = make(map[string]CredentialUpdate) + m.settings.status = "" + m.settings.statusIsError = false + return + } + + editable := make(map[string]SettingField, len(fields)) + for _, field := range fields { + if !field.ReadOnly { + editable[field.Key] = field + } + } + m.settings.drafts = make(map[string]SettingUpdate, len(previousDrafts)) + m.settings.configSecretDrafts = make(map[string]ConfigSecretUpdate, len(previousConfigSecretDrafts)) + m.settings.credentialDrafts = make(map[string]CredentialUpdate, len(previousCredentialDrafts)) + for key, draft := range previousDrafts { + if _, ok := editable[key]; ok { + m.settings.drafts[key] = draft + } + } + for key, draft := range previousConfigSecretDrafts { + field, ok := editable[key] + if ok && field.Kind == SettingKindSecret && settingCredentialID(field) == "" { + m.settings.configSecretDrafts[key] = draft + } + } + for key, draft := range previousCredentialDrafts { + field, ok := editable[key] + if ok && field.Kind == SettingKindSecret && settingCredentialID(field) == draft.CredentialID { + m.settings.credentialDrafts[key] = draft + } + } + m.settings.status = "Settings changed elsewhere. Drafts kept; review and save again." + m.settings.statusIsError = true +} + +func settingsGroupLabel(group string) string { + if group == "" { + return "Other" + } + words := strings.Fields(strings.NewReplacer("_", " ", "-", " ", ".", " ").Replace(group)) + for i := range words { + words[i] = strings.ToUpper(words[i][:1]) + words[i][1:] + } + return strings.Join(words, " ") +} + +func (m *Model) clampSettingsRow() { + fields := m.settings.currentFields() + if len(fields) == 0 { + m.settings.rowCursor = 0 + return + } + m.settings.rowCursor = min(max(m.settings.rowCursor, 0), len(fields)-1) +} + +func (m Model) saveSettings() (tea.Model, tea.Cmd) { + if m.settings.saving || !m.settings.dirty() { + if !m.settings.dirty() { + m.settings.status = "No unsaved changes." + m.settings.statusIsError = false + } + return m, nil + } + updates := m.orderedSettingsUpdates() + configSecrets := m.orderedConfigSecretUpdates() + credentials := m.orderedCredentialUpdates() + backend := m.settingsBackend + if backend == nil { + m.settings.status = "Settings are unavailable." + m.settings.statusIsError = true + return m, nil + } + m.settings.saving = true + m.settings.confirmDiscard = false + m.settingsRequestID++ + m.settings.requestID = m.settingsRequestID + requestID := m.settingsRequestID + request := SettingsSaveRequest{ + ConfigETag: m.settings.etag, + CredentialETag: m.settings.credentialETag, + Updates: updates, + ConfigSecrets: configSecrets, + Credentials: credentials, + } + return m, func() tea.Msg { + snapshot, err := backend.SaveSettings(context.Background(), request) + var conflict *SettingsConflictError + var partial *SettingsPartialSaveError + if !errors.As(err, &conflict) && !errors.As(err, &partial) { + return settingsSavedMsg{snapshot: snapshot, err: err, requestID: requestID} + } + latest, reloadErr := backend.LoadSettings(context.Background()) + msg := settingsSavedMsg{ + err: err, requestID: requestID, conflict: true, + snapshot: latest, reloadErr: reloadErr, + } + if partial != nil { + msg.conflict = false + msg.partial = true + msg.savedKeys = append([]string(nil), partial.SavedKeys...) + } + return msg + } +} + +func (m Model) orderedSettingsUpdates() []SettingUpdate { + updates := make([]SettingUpdate, 0, len(m.settings.drafts)) + seen := make(map[string]bool, len(m.settings.drafts)) + for _, field := range m.settings.fields { + if update, ok := m.settings.drafts[field.Key]; ok { + updates = append(updates, update) + seen[field.Key] = true + } + } + keys := make([]string, 0) + for key := range m.settings.drafts { + if !seen[key] { + keys = append(keys, key) + } + } + sort.Strings(keys) + for _, key := range keys { + updates = append(updates, m.settings.drafts[key]) + } + return updates +} + +func (m Model) orderedCredentialUpdates() []CredentialUpdate { + updates := make([]CredentialUpdate, 0, len(m.settings.credentialDrafts)) + seen := make(map[string]bool, len(m.settings.credentialDrafts)) + for _, field := range m.settings.fields { + if update, ok := m.settings.credentialDrafts[field.Key]; ok { + updates = append(updates, update) + seen[field.Key] = true + } + } + keys := make([]string, 0) + for key := range m.settings.credentialDrafts { + if !seen[key] { + keys = append(keys, key) + } + } + sort.Strings(keys) + for _, key := range keys { + updates = append(updates, m.settings.credentialDrafts[key]) + } + return updates +} + +func (m Model) orderedConfigSecretUpdates() []ConfigSecretUpdate { + updates := make([]ConfigSecretUpdate, 0, len(m.settings.configSecretDrafts)) + seen := make(map[string]bool, len(m.settings.configSecretDrafts)) + for _, field := range m.settings.fields { + if update, ok := m.settings.configSecretDrafts[field.Key]; ok { + updates = append(updates, update) + seen[field.Key] = true + } + } + keys := make([]string, 0) + for key := range m.settings.configSecretDrafts { + if !seen[key] { + keys = append(keys, key) + } + } + sort.Strings(keys) + for _, key := range keys { + updates = append(updates, m.settings.configSecretDrafts[key]) + } + return updates +} + +func (m Model) handleSettingsSaved(msg settingsSavedMsg) (tea.Model, tea.Cmd) { + if !m.settings.active || msg.requestID != m.settings.requestID { + return m, nil + } + m.settings.saving = false + if msg.partial { + m.dropSavedSettingsDrafts(msg.savedKeys) + if msg.reloadErr == nil { + m.applySettingsSnapshot(msg.snapshot, true) + m.dropSavedSettingsDrafts(msg.savedKeys) + } + m.settings.status = "Some settings were saved. Remaining drafts kept; review and save again." + if msg.reloadErr != nil { + m.settings.status += " The latest settings could not be reloaded." + } + m.settings.statusIsError = true + return m, nil + } + if msg.conflict { + if msg.reloadErr != nil { + m.settings.status = "Settings changed elsewhere; drafts kept, but the latest settings could not be loaded." + m.settings.statusIsError = true + return m, nil + } + m.applySettingsSnapshot(msg.snapshot, true) + return m, nil + } + if msg.err != nil { + m.settings.status = "Could not save settings: " + m.redactSettingsSecrets(msg.err.Error()) + m.settings.statusIsError = true + return m, nil + } + m.applySettingsSnapshot(msg.snapshot, false) + m.settings.status = "Settings saved." + m.settings.statusIsError = false + return m, nil +} + +func (m Model) redactSettingsSecrets(message string) string { + secrets := make([]string, 0, len(m.settings.configSecretDrafts)+len(m.settings.credentialDrafts)) + for key, draft := range m.settings.configSecretDrafts { + field, ok := m.settingFieldByKey(key) + if !ok || field.Kind != SettingKindSecret || draft.Value == "" { + continue + } + secrets = append(secrets, draft.Value) + } + for key, draft := range m.settings.credentialDrafts { + field, ok := m.settingFieldByKey(key) + if !ok || field.Kind != SettingKindSecret || draft.Value == "" { + continue + } + secrets = append(secrets, draft.Value) + } + sort.SliceStable(secrets, func(i, j int) bool { + return len(secrets[i]) > len(secrets[j]) + }) + for _, secret := range secrets { + message = strings.ReplaceAll(message, secret, "[redacted]") + } + return message +} + +func (m Model) settingFieldByKey(key string) (SettingField, bool) { + for _, field := range m.settings.fields { + if field.Key == key { + return field, true + } + } + return SettingField{}, false +} + +func (m *Model) setSettingsDraft(field SettingField, update SettingUpdate) { + if m.settings.drafts == nil { + m.settings.drafts = make(map[string]SettingUpdate) + } + if reflect.DeepEqual(field.Value, update.Value) { + delete(m.settings.drafts, field.Key) + } else { + m.settings.drafts[field.Key] = update + } +} + +func (m *Model) setCredentialDraft(field SettingField, update CredentialUpdate) { + if m.settings.credentialDrafts == nil { + m.settings.credentialDrafts = make(map[string]CredentialUpdate) + } + m.settings.credentialDrafts[field.Key] = update +} + +func (m *Model) setConfigSecretDraft(field SettingField, update ConfigSecretUpdate) { + if m.settings.configSecretDrafts == nil { + m.settings.configSecretDrafts = make(map[string]ConfigSecretUpdate) + } + m.settings.configSecretDrafts[field.Key] = update +} + +func settingCredentialID(field SettingField) string { + return strings.TrimSpace(field.CredentialID) +} + +func (m *Model) dropSavedSettingsDrafts(keys []string) { + for _, key := range keys { + delete(m.settings.drafts, key) + delete(m.settings.configSecretDrafts, key) + delete(m.settings.credentialDrafts, key) + } +} + +func settingValueText(value *SettingValue) string { + if value == nil { + return "" + } + switch { + case value.String != nil: + return *value.String + case value.Integer != nil: + return strconv.Itoa(*value.Integer) + case value.Number != nil: + return strconv.FormatFloat(*value.Number, 'g', -1, 64) + case value.Boolean != nil: + return strconv.FormatBool(*value.Boolean) + case value.Strings != nil: + return strings.Join(*value.Strings, ", ") + default: + return "" + } +} + +func settingUpdateText(update SettingUpdate) string { + return settingValueText(update.Value) +} + +func parseSettingInput(field SettingField, raw string) (*SettingValue, error) { + trimmed := strings.TrimSpace(raw) + if field.Validation.Required && trimmed == "" { + return nil, errors.New("a value is required") + } + var value SettingValue + switch field.Kind { + case SettingKindString: + value.String = &raw + case SettingKindInteger: + parsed, err := strconv.Atoi(trimmed) + if err != nil { + return nil, errors.New("enter a whole number") + } + value.Integer = &parsed + if err := validateSettingNumber(field.Validation, float64(parsed)); err != nil { + return nil, err + } + case SettingKindNumber: + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return nil, errors.New("enter a number") + } + value.Number = &parsed + if err := validateSettingNumber(field.Validation, parsed); err != nil { + return nil, err + } + case SettingKindStringArray: + items := make([]string, 0) + for item := range strings.SplitSeq(raw, ",") { + if item = strings.TrimSpace(item); item != "" { + items = append(items, item) + } + } + value.Strings = &items + default: + return nil, fmt.Errorf("unsupported setting kind %q", field.Kind) + } + return &value, nil +} + +func validateSettingNumber(validation SettingValidation, value float64) error { + if validation.Minimum != nil && value < *validation.Minimum { + return fmt.Errorf("value must be at least %s", strconv.FormatFloat(*validation.Minimum, 'g', -1, 64)) + } + if validation.Maximum != nil && value > *validation.Maximum { + return fmt.Errorf("value must be at most %s", strconv.FormatFloat(*validation.Maximum, 'g', -1, 64)) + } + return nil +} diff --git a/internal/tui/settings_test.go b/internal/tui/settings_test.go new file mode 100644 index 000000000..d97837ce5 --- /dev/null +++ b/internal/tui/settings_test.go @@ -0,0 +1,746 @@ +package tui + +import ( + "context" + "errors" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSettingsShortcutPreservesAndRestoresContentNavigation(t *testing.T) { + assertions := assert.New(t) + backend := &fakeSettingsBackend{loads: []SettingsSnapshot{settingsFixture()}} + model := NewBuilder().Build() + model.settingsBackend = backend + model.mode = modePeople + model.peopleState.level = peopleLevelDirectory + model.peopleState.cursor = 4 + + model, load := sendKey(t, model, key(',')) + require.NotNil(t, load) + assertions.True(model.settings.active) + assertions.Equal(modePeople, model.mode) + assertions.Equal(peopleLevelDirectory, model.peopleState.level) + assertions.Equal(4, model.peopleState.cursor) + + model = sendSettingsMsg(t, model, load()) + model, _ = sendKey(t, model, keyEsc()) + + assertions.False(model.settings.active) + assertions.Equal(modePeople, model.mode) + assertions.Equal(peopleLevelDirectory, model.peopleState.level) + assertions.Equal(4, model.peopleState.cursor) +} + +func TestSettingsLoadGenerationSurvivesCloseAndReopen(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + model := NewBuilder().Build() + model.settingsBackend = &fakeSettingsBackend{} + + model, firstLoad := sendKey(t, model, key(',')) + requirements.NotNil(firstLoad) + firstRequestID := model.settings.requestID + model, _ = sendKey(t, model, keyEsc()) + requirements.False(model.settings.active) + + model, secondLoad := sendKey(t, model, key(',')) + requirements.NotNil(secondLoad) + secondRequestID := model.settings.requestID + requirements.Greater(secondRequestID, firstRequestID) + + stale := settingsFixture() + stale.Fields[0].Label = "Stale setting" + model = sendSettingsMsg(t, model, settingsLoadedMsg{ + snapshot: stale, requestID: firstRequestID, + }) + assertions.True(model.settings.loading) + assertions.Empty(model.settings.fields) + + fresh := settingsFixture() + fresh.Fields[0].Label = "Fresh setting" + model = sendSettingsMsg(t, model, settingsLoadedMsg{ + snapshot: fresh, requestID: secondRequestID, + }) + assertions.False(model.settings.loading) + requirements.Len(model.settings.fields, 1) + assertions.Equal("Fresh setting", model.settings.fields[0].Label) + + value := "sql" + model.settings.drafts["analytics.engine"] = SettingUpdate{ + Key: "analytics.engine", Value: &SettingValue{String: &value}, + } + model, save := sendKey(t, model, tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + requirements.NotNil(save) + saveRequestID := model.settings.requestID + requirements.Greater(saveRequestID, secondRequestID) + model = sendSettingsMsg(t, model, settingsSavedMsg{ + snapshot: fresh, requestID: saveRequestID, + }) + model, _ = sendKey(t, model, keyEsc()) + model, thirdLoad := sendKey(t, model, key(',')) + requirements.NotNil(thirdLoad) + assertions.Greater(model.settings.requestID, saveRequestID) +} + +func TestSettingsShortcutAppearsInEveryModeHelp(t *testing.T) { + for _, mode := range []tuiMode{modeEmail, modeTexts, modeMeetings, modePeople} { + model := NewBuilder().WithSize(100, 80).Build() + model.mode = mode + assert.Contains(t, stripANSI(model.renderHelpModal()), ", Open Settings") + } +} + +func TestSettingsShortcutDoesNotStealCommaFromActiveTextInputs(t *testing.T) { + t.Run("email search", func(t *testing.T) { + model := NewBuilder().Build() + model, _ = sendKey(t, model, key('/')) + require.True(t, model.inlineSearchActive) + + model, _ = sendKey(t, model, key(',')) + + assert.False(t, model.settings.active) + assert.Equal(t, ",", model.searchInput.Value()) + }) + + t.Run("meeting search", func(t *testing.T) { + model := NewBuilder().Build() + model.mode = modeMeetings + model, _ = sendKey(t, model, key('/')) + require.True(t, model.meetingState.searchActive) + + model, _ = sendKey(t, model, key(',')) + + assert.False(t, model.settings.active) + assert.Equal(t, ",", model.meetingState.searchInput.Value()) + }) + + t.Run("people search", func(t *testing.T) { + model := NewBuilder().Build() + model.mode = modePeople + model, _ = sendKey(t, model, key('/')) + require.True(t, model.peopleState.searchActive) + + model, _ = sendKey(t, model, key(',')) + + assert.False(t, model.settings.active) + assert.Equal(t, ",", model.peopleState.searchInput.Value()) + }) + + t.Run("message detail search", func(t *testing.T) { + model := NewBuilder().Build() + model.level = levelMessageDetail + model, _ = sendKey(t, model, key('/')) + require.True(t, model.detailSearchActive) + + model, _ = sendKey(t, model, key(',')) + + assert.False(t, model.settings.active) + assert.Equal(t, ",", model.detailSearchInput.Value()) + }) + + t.Run("attribute editor", func(t *testing.T) { + model := NewBuilder().Build() + model.mode = modePeople + model.peopleState.form = newPeopleFieldForm() + + model, _ = sendKey(t, model, key(',')) + + assert.False(t, model.settings.active) + assert.Equal(t, ",", model.peopleState.form.nameInput.Value()) + }) +} + +func TestSettingsRendersAboveFrozenContentTransitionAndRestoresIt(t *testing.T) { + backend := &fakeSettingsBackend{loads: []SettingsSnapshot{settingsFixture()}} + model := NewBuilder().WithSize(100, 24).Build() + model.settingsBackend = backend + model.transitionBuffer = "exact frozen content frame" + + model, load := sendKey(t, model, key(',')) + model = sendSettingsMsg(t, model, load()) + settingsView := model.View().Content + assert.Contains(t, settingsView, "Settings") + assert.NotContains(t, settingsView, "exact frozen content frame") + + model, _ = sendKey(t, model, keyEsc()) + assert.Equal(t, "exact frozen content frame", model.View().Content) +} + +func TestSettingsOmitsBrowserOnlyFields(t *testing.T) { + assertions := assert.New(t) + snapshot := SettingsSnapshot{ + ETag: "etag-1", + Groups: []SettingsGroup{ + {ID: "browser", Label: "Browser"}, + {ID: "server", Label: "Server"}, + }, + Fields: []SettingField{ + { + Key: "web.theme", Group: "browser", Label: "Theme", + Kind: SettingKindString, Value: stringSettingValue("dark"), + }, + { + Key: "server.bind_addr", Group: "server", Label: "Bind address", + Kind: SettingKindString, Value: stringSettingValue("127.0.0.1"), + }, + }, + } + model := loadedSettingsModel(t, snapshot) + + view := model.renderView() + assertions.Contains(view, "Server") + assertions.Contains(view, "Bind address") + assertions.NotContains(view, "web.theme") + assertions.NotContains(view, "Theme") + assertions.NotContains(view, "Browser") +} + +func TestSettingsWideAndNarrowNavigationUseTheSameDraft(t *testing.T) { + assertions := assert.New(t) + snapshot := SettingsSnapshot{ + ETag: "etag-1", + Groups: []SettingsGroup{ + {ID: "archive", Label: "Archive"}, + {ID: "search", Label: "Search"}, + }, + Fields: []SettingField{ + { + Key: "analytics.engine", Group: "archive", Label: "Analytics engine", + Kind: SettingKindString, Value: stringSettingValue("auto"), Options: []string{"auto", "sql"}, + }, + { + Key: "vector.enabled", Group: "search", Label: "Semantic search", + Kind: SettingKindBoolean, Value: boolSettingValue(false), + }, + }, + } + model := loadedSettingsModel(t, snapshot) + assertions.Contains(model.renderView(), "Archive") + assertions.Contains(model.renderView(), "Analytics engine") + + model, _ = sendKey(t, model, key('l')) + assertions.Equal("search", model.settings.currentGroup()) + model, _ = sendKey(t, model, key(' ')) + require.True(t, model.settings.dirty()) + + model = resizeModel(t, model, 60, 24) + model.settings.narrowFields = false + categories := model.renderView() + assertions.Contains(categories, "Categories") + assertions.NotContains(categories, "Semantic search") + model, _ = sendKey(t, model, keyEnter()) + assertions.Contains(model.renderView(), "Semantic search") + assertions.Contains(model.renderView(), "enabled") +} + +func TestSettingsRendersPendingRestartAndFieldMetadata(t *testing.T) { + assertions := assert.New(t) + snapshot := SettingsSnapshot{ + ETag: "etag-1", + PendingRestart: true, + Groups: []SettingsGroup{{ID: "archive", Label: "Archive"}}, + Fields: []SettingField{{ + Key: "analytics.builder_threads", Group: "archive", Label: "Builder threads", + Description: "Threads used for cache builds.", Kind: SettingKindInteger, + Value: intSettingValue(2), ReadOnly: true, RestartRequired: true, + Validation: SettingValidation{Hint: "Between 1 and 16."}, + }}, + } + model := loadedSettingsModel(t, snapshot) + + view := model.renderView() + assertions.Contains(view, "Pending restart") + assertions.Contains(view, "Threads used for cache builds") + assertions.Contains(view, "read-only") + assertions.Contains(view, "restart required") + assertions.Contains(view, "Validation: Between 1 and 16") + model, _ = sendKey(t, model, keyEnter()) + assertions.False(model.settings.editing) + assertions.False(model.settings.dirty()) +} + +func TestSettingsSelectEditorAndSavePublishTypedDraft(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + initial := SettingsSnapshot{ + ETag: "etag-old", + Groups: []SettingsGroup{{ID: "archive", Label: "Archive"}}, + Fields: []SettingField{{ + Key: "analytics.engine", Group: "archive", Label: "Analytics engine", + Kind: SettingKindString, Value: stringSettingValue("auto"), + Options: []string{"auto", "sql", "duckdb"}, RestartRequired: true, + }}, + } + saved := initial + saved.ETag = "etag-new" + saved.PendingRestart = true + saved.Fields = append([]SettingField(nil), initial.Fields...) + saved.Fields[0].Value = stringSettingValue("sql") + backend := &fakeSettingsBackend{loads: []SettingsSnapshot{initial}, save: saved} + model := loadedSettingsModelWithBackend(t, backend) + + model, _ = sendKey(t, model, keyEnter()) + assertions.True(model.settings.editing) + model, _ = sendKey(t, model, key('l')) + model, _ = sendKey(t, model, keyEnter()) + assertions.True(model.settings.dirty()) + assertions.Contains(model.renderView(), "sql") + + model, save := sendKey(t, model, tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + requirements.NotNil(save) + model = sendSettingsMsg(t, model, save()) + + assertions.False(model.settings.dirty()) + assertions.Equal("etag-new", model.settings.etag) + assertions.True(model.settings.pendingRestart) + assertions.Contains(model.renderView(), "Settings saved") + requirements.Len(backend.saves, 1) + requirements.Len(backend.saves[0].Updates, 1) + assertions.Equal("analytics.engine", backend.saves[0].Updates[0].Key) + requirements.NotNil(backend.saves[0].Updates[0].Value.String) + assertions.Equal("sql", *backend.saves[0].Updates[0].Value.String) + assertions.Empty(backend.saves[0].Credentials) +} + +func TestSettingsInvalidNumberKeepsEditorAndDraftsUnchanged(t *testing.T) { + minimum := 1.0 + snapshot := SettingsSnapshot{ + ETag: "etag-1", + Groups: []SettingsGroup{{ID: "archive", Label: "Archive"}}, + Fields: []SettingField{{ + Key: "analytics.builder_threads", Group: "archive", Label: "Builder threads", + Kind: SettingKindInteger, Validation: SettingValidation{ + Hint: "At least one thread.", Minimum: &minimum, + }, + }}, + } + model := loadedSettingsModel(t, snapshot) + model, _ = sendKey(t, model, keyEnter()) + model, _ = sendKey(t, model, key('0')) + model, _ = sendKey(t, model, keyEnter()) + + assert.True(t, model.settings.editing) + assert.False(t, model.settings.dirty()) + assert.Contains(t, model.renderView(), "Invalid value: value must be at least 1") +} + +func TestSettingsSecretInputAndDraftNeverRenderSecretBytes(t *testing.T) { + assertions := assert.New(t) + const secret = "credential-must-never-render" + snapshot := SettingsSnapshot{ + ETag: "etag-1", + Groups: []SettingsGroup{{ID: "search", Label: "Search"}}, + Fields: []SettingField{{ + Key: "vector.embeddings.api_key", Group: "search", Label: "Embedding API key", + Kind: SettingKindSecret, CredentialID: "vector.embeddings", + Secret: &SecretSettingState{Configured: true, Source: "environment"}, + }}, + } + model := loadedSettingsModel(t, snapshot) + assertions.Contains(model.settingsFooter(), "[x] Clear") + + model, _ = sendKey(t, model, keyEnter()) + for _, r := range secret { + model, _ = sendKey(t, model, key(r)) + assertions.NotContains(model.renderView(), secret) + } + editingView := model.renderView() + assertions.NotContains(editingView, secret) + assertions.Contains(editingView, "*") + + model, _ = sendKey(t, model, keyEnter()) + committedView := model.renderView() + assertions.NotContains(committedView, secret) + assertions.Contains(committedView, "configured") + assertions.Contains(committedView, "environment") +} + +func TestSettingsProviderCredentialClearExplainsEnvironmentFallback(t *testing.T) { + model := loadedSettingsModel(t, SettingsSnapshot{ + ETag: "config-current", + CredentialETag: "credentials-current", + Groups: []SettingsGroup{{ID: "search", Label: "Search"}}, + Fields: []SettingField{{ + Key: "vector.embeddings.api_key", Group: "search", Label: "Embedding API key", + Kind: SettingKindSecret, CredentialID: "vector.embeddings", + Secret: &SecretSettingState{Configured: true, Source: "environment"}, + }}, + }) + + model, _ = sendKey(t, model, key('x')) + field, ok := model.settings.selectedField() + require.True(t, ok) + display := model.secretSettingDisplay(field) + assert.Contains(t, display, "environment may remain") + assert.NotContains(t, display, "not configured after save") +} + +func TestSettingsLegacyConfigSecretUsesConfigLaneAndCtrlSSavesActiveEditor(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + const secret = "config-secret-must-never-render" + initial := SettingsSnapshot{ + ETag: "config-old", + CredentialETag: "credentials-current", + Groups: []SettingsGroup{{ID: "integrations", Label: "Integrations"}}, + Fields: []SettingField{{ + Key: "integrations.tasks.api_key", Group: "integrations", Label: "Tasks API key", + Kind: SettingKindSecret, Secret: &SecretSettingState{Configured: false, Source: "none"}, + }}, + } + saved := initial + saved.ETag = "config-new" + saved.Fields = append([]SettingField(nil), initial.Fields...) + saved.Fields[0].Secret = &SecretSettingState{Configured: true, Source: "stored"} + backend := &fakeSettingsBackend{loads: []SettingsSnapshot{initial}, save: saved} + model := loadedSettingsModelWithBackend(t, backend) + + model, _ = sendKey(t, model, keyEnter()) + for _, r := range secret { + model, _ = sendKey(t, model, key(r)) + } + model, save := sendKey(t, model, tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + requirements.NotNil(save) + model = sendSettingsMsg(t, model, save()) + + assertions.False(model.settings.editing) + assertions.False(model.settings.dirty()) + assertions.NotContains(model.renderView(), secret) + requirements.Len(backend.saves, 1) + assertions.Equal("config-old", backend.saves[0].ConfigETag) + assertions.Empty(backend.saves[0].Updates) + assertions.Empty(backend.saves[0].Credentials) + requirements.Len(backend.saves[0].ConfigSecrets, 1) + assertions.Equal(ConfigSecretUpdate{ + Key: "integrations.tasks.api_key", Action: "set", Value: secret, + }, backend.saves[0].ConfigSecrets[0]) +} + +func TestSettingsErrorRedactionHandlesOverlappingSecretDrafts(t *testing.T) { + const ( + configSecret = "shared-secret-prefix" + providerSecret = "shared-secret-prefix-provider-tail" + ) + model := loadedSettingsModel(t, SettingsSnapshot{ + Groups: []SettingsGroup{{ID: "integrations", Label: "Integrations"}}, + Fields: []SettingField{ + { + Key: "integrations.tasks.api_key", Group: "integrations", Label: "Tasks API key", + Kind: SettingKindSecret, Secret: &SecretSettingState{}, + }, + { + Key: "vector.embeddings.api_key", Group: "integrations", Label: "Embedding API key", + Kind: SettingKindSecret, CredentialID: "vector.embeddings", Secret: &SecretSettingState{}, + }, + }, + }) + model.settings.configSecretDrafts["integrations.tasks.api_key"] = ConfigSecretUpdate{ + Key: "integrations.tasks.api_key", Action: "set", Value: configSecret, + } + model.settings.credentialDrafts["vector.embeddings.api_key"] = CredentialUpdate{ + Key: "vector.embeddings.api_key", CredentialID: "vector.embeddings", + Action: "set", Value: providerSecret, + } + + redacted := model.redactSettingsSecrets("save failed: " + providerSecret + " and " + configSecret) + + assert.Equal(t, "save failed: [redacted] and [redacted]", redacted) +} + +func TestSettingsEscapeRequiresDiscardConfirmationWhenDirty(t *testing.T) { + assertions := assert.New(t) + snapshot := SettingsSnapshot{ + ETag: "etag-1", + Groups: []SettingsGroup{{ID: "archive", Label: "Archive"}}, + Fields: []SettingField{{ + Key: "analytics.auto_build_cache", Group: "archive", Label: "Build cache automatically", + Kind: SettingKindBoolean, Value: boolSettingValue(true), + }}, + } + model := loadedSettingsModel(t, snapshot) + model.mode = modeMeetings + model.meetingState.cursor = 3 + + model, _ = sendKey(t, model, key(' ')) + assertions.True(model.settings.dirty()) + assertions.Contains(model.renderView(), "Settings *") + + model, _ = sendKey(t, model, keyEsc()) + assertions.True(model.settings.active) + assertions.True(model.settings.confirmDiscard) + assertions.Contains(model.renderView(), "Discard unsaved changes") + + model, _ = sendKey(t, model, keyEnter()) + assertions.True(model.settings.active) + assertions.True(model.settings.confirmDiscard) + + model, _ = sendKey(t, model, key('y')) + assertions.False(model.settings.active) + assertions.Equal(modeMeetings, model.mode) + assertions.Equal(3, model.meetingState.cursor) +} + +func TestSettingsEscapeCannotCloseWhileSaveIsInProgress(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + model := loadedSettingsModel(t, SettingsSnapshot{ + ETag: "etag-1", + Groups: []SettingsGroup{{ID: "archive", Label: "Archive"}}, + Fields: []SettingField{{ + Key: "analytics.auto_build_cache", Group: "archive", Label: "Build cache automatically", + Kind: SettingKindBoolean, Value: boolSettingValue(true), + }}, + }) + model, _ = sendKey(t, model, key(' ')) + requirements.True(model.settings.dirty()) + model, save := sendKey(t, model, tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + requirements.NotNil(save) + requirements.True(model.settings.saving) + + model, _ = sendKey(t, model, keyEsc()) + + assertions.True(model.settings.active) + assertions.True(model.settings.saving) + assertions.False(model.settings.confirmDiscard) + assertions.Contains(model.renderView(), "Save in progress") +} + +func TestSettingsCategoriesScrollToKeepCursorVisible(t *testing.T) { + model := NewBuilder().WithSize(60, 10).Build() + model.settings.groups = []SettingsGroup{ + {ID: "one", Label: "Category one"}, + {ID: "two", Label: "Category two"}, + {ID: "three", Label: "Category three"}, + {ID: "four", Label: "Category four"}, + {ID: "five", Label: "Category five"}, + {ID: "six", Label: "Category six"}, + } + model.settings.groupCursor = 5 + + view := stripANSI(model.renderSettingsCategories(30, 4)) + + assert.Contains(t, view, "Category six") + assert.NotContains(t, view, "Category one") +} + +func TestSettingsConflictReloadsLatestETagAndRetainsDrafts(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + initial := SettingsSnapshot{ + ETag: "etag-old", + Groups: []SettingsGroup{{ID: "archive", Label: "Archive"}}, + Fields: []SettingField{{ + Key: "analytics.auto_build_cache", Group: "archive", Label: "Build cache automatically", + Kind: SettingKindBoolean, Value: boolSettingValue(true), + }}, + } + latest := initial + latest.ETag = "etag-latest" + backend := &fakeSettingsBackend{ + loads: []SettingsSnapshot{initial, latest}, + saveErr: &SettingsConflictError{Scope: SettingsConflictConfig, Err: errors.New("stale settings")}, + } + model := loadedSettingsModelWithBackend(t, backend) + model, _ = sendKey(t, model, key(' ')) + requirements.True(model.settings.dirty()) + + model, save := sendKey(t, model, tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + requirements.NotNil(save) + model = sendSettingsMsg(t, model, save()) + + assertions.Equal("etag-latest", model.settings.etag) + assertions.True(model.settings.dirty(), "the user's local draft must survive conflict recovery") + assertions.Contains(model.renderView(), "Drafts kept") + requirements.Len(backend.saves, 1) + assertions.Equal("etag-old", backend.saves[0].ConfigETag) + assertions.Len(backend.saves[0].Updates, 1) + assertions.Empty(backend.saves[0].Credentials) +} + +func TestSettingsCredentialConflictUsesCredentialETagAndRetainsSecretDraft(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + const secret = "stale-credential-draft-must-not-render" + initial := SettingsSnapshot{ + ETag: "config-etag", + CredentialETag: "credential-old", + Groups: []SettingsGroup{{ID: "search", Label: "Search"}}, + Fields: []SettingField{{ + Key: "vector.embeddings.api_key", Group: "search", Label: "Embedding API key", + Kind: SettingKindSecret, CredentialID: "vector.embeddings", + Secret: &SecretSettingState{Configured: false}, + }}, + } + latest := initial + latest.CredentialETag = "credential-latest" + backend := &fakeSettingsBackend{ + loads: []SettingsSnapshot{initial, latest}, + saveErr: &SettingsConflictError{ + Scope: SettingsConflictCredentials, + Err: errors.New("credential store changed"), + }, + } + model := loadedSettingsModelWithBackend(t, backend) + model, _ = sendKey(t, model, keyEnter()) + for _, r := range secret { + model, _ = sendKey(t, model, key(r)) + } + model, _ = sendKey(t, model, keyEnter()) + + model, save := sendKey(t, model, tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + requirements.NotNil(save) + model = sendSettingsMsg(t, model, save()) + + assertions.Equal("credential-latest", model.settings.credentialETag) + assertions.True(model.settings.dirty()) + assertions.NotContains(model.renderView(), secret) + assertions.Contains(model.renderView(), "Drafts kept") + requirements.Len(backend.saves, 1) + assertions.Equal("config-etag", backend.saves[0].ConfigETag) + assertions.Equal("credential-old", backend.saves[0].CredentialETag) + assertions.Empty(backend.saves[0].Updates, "secret writes must not use PATCH settings updates") + requirements.Len(backend.saves[0].Credentials, 1) + assertions.Equal("vector.embeddings.api_key", backend.saves[0].Credentials[0].Key) + assertions.Equal("vector.embeddings", backend.saves[0].Credentials[0].CredentialID) +} + +func TestSettingsPartialSaveDropsSavedDraftsAndKeepsFailedCredentialDraft(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + const secret = "partial-save-secret-must-not-render" + initial := SettingsSnapshot{ + ETag: "config-old", + CredentialETag: "credential-old", + Groups: []SettingsGroup{ + {ID: "archive", Label: "Archive"}, + {ID: "search", Label: "Search"}, + }, + Fields: []SettingField{ + { + Key: "analytics.auto_build_cache", Group: "archive", Label: "Build cache automatically", + Kind: SettingKindBoolean, Value: boolSettingValue(true), + }, + { + Key: "vector.embeddings.api_key", Group: "search", Label: "Embedding API key", + Kind: SettingKindSecret, CredentialID: "vector.embeddings", + Secret: &SecretSettingState{Configured: false}, + }, + }, + } + latest := initial + latest.ETag = "config-latest" + latest.Fields = append([]SettingField(nil), initial.Fields...) + latest.Fields[0].Value = boolSettingValue(false) + backend := &fakeSettingsBackend{ + loads: []SettingsSnapshot{initial, latest}, + saveErr: &SettingsPartialSaveError{ + SavedKeys: []string{"analytics.auto_build_cache"}, + Err: errors.New("credential store unavailable"), + }, + } + model := loadedSettingsModelWithBackend(t, backend) + model, _ = sendKey(t, model, key(' ')) + model, _ = sendKey(t, model, key('l')) + model, _ = sendKey(t, model, keyEnter()) + for _, r := range secret { + model, _ = sendKey(t, model, key(r)) + } + model, _ = sendKey(t, model, keyEnter()) + requirements.True(model.settings.dirty()) + + model, save := sendKey(t, model, tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + requirements.NotNil(save) + model = sendSettingsMsg(t, model, save()) + + assertions.NotContains(model.renderView(), secret) + assertions.Contains(model.renderView(), "Some settings were saved") + assertions.Contains(model.renderView(), "Remaining drafts kept") + assertions.NotContains(model.settings.drafts, "analytics.auto_build_cache") + assertions.Contains(model.settings.credentialDrafts, "vector.embeddings.api_key") + requirements.Len(backend.saves, 1) + assertions.Len(backend.saves[0].Updates, 1) + assertions.Len(backend.saves[0].Credentials, 1) +} + +type fakeSettingsBackend struct { + loads []SettingsSnapshot + loadIndex int + loadErr error + saveErr error + save SettingsSnapshot + saves []fakeSettingsSave +} + +type fakeSettingsSave struct { + SettingsSaveRequest +} + +func (b *fakeSettingsBackend) LoadSettings(context.Context) (SettingsSnapshot, error) { + if b.loadErr != nil { + return SettingsSnapshot{}, b.loadErr + } + if len(b.loads) == 0 { + return SettingsSnapshot{}, nil + } + index := min(b.loadIndex, len(b.loads)-1) + b.loadIndex++ + return b.loads[index], nil +} + +func (b *fakeSettingsBackend) SaveSettings( + _ context.Context, + request SettingsSaveRequest, +) (SettingsSnapshot, error) { + b.saves = append(b.saves, fakeSettingsSave{SettingsSaveRequest: request}) + if b.saveErr != nil { + return SettingsSnapshot{}, b.saveErr + } + return b.save, nil +} + +func settingsFixture() SettingsSnapshot { + return SettingsSnapshot{ + ETag: "etag-1", + Groups: []SettingsGroup{{ID: "archive", Label: "Archive"}}, + Fields: []SettingField{{ + Key: "analytics.engine", Group: "archive", Label: "Analytics engine", + Kind: SettingKindString, Value: stringSettingValue("auto"), + }}, + } +} + +func loadedSettingsModel(t *testing.T, snapshot SettingsSnapshot) Model { + t.Helper() + return loadedSettingsModelWithBackend(t, &fakeSettingsBackend{loads: []SettingsSnapshot{snapshot}}) +} + +func loadedSettingsModelWithBackend(t *testing.T, backend *fakeSettingsBackend) Model { + t.Helper() + model := NewBuilder().WithSize(120, 30).Build() + model.settingsBackend = backend + model, load := sendKey(t, model, key(',')) + require.NotNil(t, load) + return sendSettingsMsg(t, model, load()) +} + +func sendSettingsMsg(t *testing.T, model Model, msg tea.Msg) Model { + t.Helper() + updated, _ := model.Update(msg) + return asModel(t, updated) +} + +func stringSettingValue(value string) *SettingValue { + return &SettingValue{String: &value} +} + +func boolSettingValue(value bool) *SettingValue { + return &SettingValue{Boolean: &value} +} + +func intSettingValue(value int) *SettingValue { + return &SettingValue{Integer: &value} +} diff --git a/internal/tui/settings_view.go b/internal/tui/settings_view.go new file mode 100644 index 000000000..688df2a04 --- /dev/null +++ b/internal/tui/settings_view.go @@ -0,0 +1,231 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "go.kenn.io/msgvault/internal/textutil" +) + +func (m Model) renderSettingsView() string { + title := "Settings" + if m.settings.dirty() { + title += " *" + } + header := m.styles.titleBar.Render(title) + if m.settings.confirmDiscard { + return strings.Join([]string{ + header, + "", + m.styles.modalTitle.Render("Discard unsaved changes?"), + "", + "Your settings drafts have not been saved.", + "", + "[Y] Discard and return [N/Esc] Keep editing", + }, "\n") + } + + lines := []string{header} + if m.settings.pendingRestart { + lines = append(lines, m.styles.flash.Render("Pending restart — saved changes take effect after the daemon restarts.")) + } + if m.settings.loading { + return strings.Join(append(lines, "", m.styles.loading.Render("Loading settings…"), "", "[Esc] Back"), "\n") + } + + availableHeight := max(m.height-len(lines)-3, 4) + if m.settingsIsNarrow() { + if m.settings.narrowFields { + lines = append(lines, m.renderSettingsFields(max(m.width, 20), availableHeight)) + } else { + lines = append(lines, m.renderSettingsCategories(max(m.width, 20), availableHeight)) + } + } else { + categoryWidth := min(max(m.width/4, 20), 30) + fieldWidth := max(m.width-categoryWidth-3, 30) + categories := m.renderSettingsCategories(categoryWidth, availableHeight) + fields := m.renderSettingsFields(fieldWidth, availableHeight) + lines = append(lines, lipgloss.JoinHorizontal(lipgloss.Top, categories, " │ ", fields)) + } + + if m.settings.status != "" { + status := textutil.SanitizeTerminalMultiline(m.settings.status) + if m.settings.statusIsError { + status = m.styles.err.Render(status) + } else { + status = m.styles.flash.Render(status) + } + lines = append(lines, status) + } + lines = append(lines, m.settingsFooter()) + return strings.Join(lines, "\n") +} + +func (m Model) renderSettingsCategories(width, height int) string { + var sb strings.Builder + sb.WriteString(m.styles.tableHeader.Render("Categories")) + sb.WriteString("\n") + if len(m.settings.groups) == 0 { + sb.WriteString("No terminal settings available") + return sb.String() + } + maxRows := max(height-1, 1) + start := 0 + if m.settings.groupCursor >= maxRows { + start = m.settings.groupCursor - maxRows + 1 + } + end := min(start+maxRows, len(m.settings.groups)) + for i := start; i < end; i++ { + group := m.settings.groups[i] + cursor := " " + if i == m.settings.groupCursor { + cursor = "▶ " + } + label := textutil.SanitizeTerminal(group.Label) + if label == "" { + label = settingsGroupLabel(group.ID) + } + line := truncateRunes(cursor+label, max(width-1, 1)) + if i == m.settings.groupCursor { + line = m.styles.cursorRow.Render(padRight(line, max(width-1, 1))) + } + sb.WriteString(line) + if i+1 < end { + sb.WriteString("\n") + } + } + if m.settingsIsNarrow() { + sb.WriteString("\n\n[↑/↓] Category [Enter/l] Open [Esc] Back") + } + return sb.String() +} + +func (m Model) renderSettingsFields(width, height int) string { + var sb strings.Builder + groupLabel := settingsGroupLabel(m.settings.currentGroup()) + if m.settings.groupCursor >= 0 && m.settings.groupCursor < len(m.settings.groups) { + if label := strings.TrimSpace(m.settings.groups[m.settings.groupCursor].Label); label != "" { + groupLabel = textutil.SanitizeTerminal(label) + } + } + sb.WriteString(m.styles.tableHeader.Render(groupLabel)) + sb.WriteString("\n") + + fields := m.settings.currentFields() + if len(fields) == 0 { + sb.WriteString("No settings in this category") + return sb.String() + } + maxRows := max(min(len(fields), height/2), 1) + start := 0 + if m.settings.rowCursor >= maxRows { + start = m.settings.rowCursor - maxRows + 1 + } + end := min(start+maxRows, len(fields)) + for i := start; i < end; i++ { + field := fields[i] + cursor := " " + if i == m.settings.rowCursor { + cursor = "▶ " + } + dirty := " " + _, configDirty := m.settings.drafts[field.Key] + _, configSecretDirty := m.settings.configSecretDrafts[field.Key] + _, credentialDirty := m.settings.credentialDrafts[field.Key] + if configDirty || configSecretDirty || credentialDirty { + dirty = "*" + } + label := textutil.SanitizeTerminal(field.Label) + if label == "" { + label = field.Key + } + value := m.settingsFieldValue(field) + labelWidth := max(min(width/2, 34), 12) + line := fmt.Sprintf("%s%s %-*s %s", cursor, dirty, labelWidth, truncateRunes(label, labelWidth), value) + line = truncateRunes(line, max(width-1, 1)) + if i == m.settings.rowCursor { + line = m.styles.cursorRow.Render(padRight(line, max(width-1, 1))) + } + sb.WriteString(line) + sb.WriteString("\n") + } + + selected, ok := m.settings.selectedField() + if !ok { + return strings.TrimSuffix(sb.String(), "\n") + } + sb.WriteString("\n") + description := textutil.SanitizeTerminalMultiline(selected.Description) + if description != "" { + sb.WriteString(truncateRunes(description, max(width-1, 1))) + sb.WriteString("\n") + } + _, _ = fmt.Fprintf(&sb, "Key: %s\n", textutil.SanitizeTerminal(selected.Key)) + metadata := make([]string, 0, 3) + if selected.ReadOnly { + metadata = append(metadata, "read-only") + } + if selected.RestartRequired { + metadata = append(metadata, "restart required") + } + if len(selected.Options) > 0 { + metadata = append(metadata, "options: "+strings.Join(selected.Options, " / ")) + } + if len(metadata) > 0 { + _, _ = fmt.Fprintf(&sb, "[%s]\n", strings.Join(metadata, "] [")) + } + if hint := strings.TrimSpace(selected.Validation.Hint); hint != "" { + _, _ = fmt.Fprintf(&sb, "Validation: %s\n", textutil.SanitizeTerminal(hint)) + } + if selected.Kind == SettingKindSecret && selected.Secret != nil && selected.Secret.Source != "" { + _, _ = fmt.Fprintf(&sb, "Credential source: %s\n", textutil.SanitizeTerminal(selected.Secret.Source)) + } + return strings.TrimSuffix(sb.String(), "\n") +} + +func (m Model) settingsFieldValue(field SettingField) string { + if m.settings.editing && m.settings.editKey == field.Key { + if len(m.settings.editOptions) > 0 { + if m.settings.editOption >= 0 && m.settings.editOption < len(m.settings.editOptions) { + return "‹ " + textutil.SanitizeTerminal(m.settings.editOptions[m.settings.editOption]) + " ›" + } + return "" + } + return m.settings.editor.View() + } + if field.Kind == SettingKindSecret { + return m.secretSettingDisplay(field) + } + value := m.currentSettingText(field) + if field.Kind == SettingKindBoolean { + if value == "true" { + return "[x] enabled" + } + return "[ ] disabled" + } + if value == "" { + return "—" + } + return textutil.SanitizeTerminal(value) +} + +func (m Model) settingsFooter() string { + if m.settings.saving { + return "Saving settings…" + } + if m.settings.editing { + if len(m.settings.editOptions) > 0 { + return "[h/l] Choose [Enter] Use [Ctrl+S] Use & save [Esc] Cancel" + } + return "[Enter] Use value [Ctrl+S] Use & save [Esc] Cancel" + } + if m.settingsIsNarrow() && !m.settings.narrowFields { + return "[j/k] Category [Enter/l] Open [Esc] Back" + } + if field, ok := m.settings.selectedField(); ok && + field.Kind == SettingKindSecret && !field.ReadOnly { + return "[j/k] Row [h/l] Category [Enter] Set secret [x] Clear [Ctrl+S] Save [Esc] Back" + } + return "[j/k] Row [h/l] Category [Enter] Edit [Space] Toggle [Ctrl+S] Save [Esc] Back" +} diff --git a/internal/tui/text_keys.go b/internal/tui/text_keys.go index 45ff7d9c9..63e750c5f 100644 --- a/internal/tui/text_keys.go +++ b/internal/tui/text_keys.go @@ -199,7 +199,7 @@ func (m Model) handleTextDetailKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { case keyNameEsc, keyNameBackspace: return m.textGoBack() - case "left", "h", "right", "l", "T": + case "left", "h", keyNameRight, "l", "T": return m, nil } } @@ -271,7 +271,7 @@ func (m Model) handleTextInlineSearchKeys( m.searchInput.SetValue("") return m, nil - case "ctrl+c": + case keyNameCtrlC: m.quitting = true return m, tea.Quit diff --git a/internal/tui/view.go b/internal/tui/view.go index 521a994b6..d705ba4cf 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1224,6 +1224,7 @@ var rawHelpLines = []string{ " f Filter (attachments, deleted)", " e Browse attachments (in message view)", " m Cycle Email/Texts/Meetings/People", + " , Open Settings", " q Quit", "", "[↑/↓] Scroll [Any other key] Close", @@ -1249,6 +1250,7 @@ var meetingHelpLines = []string{ "", "Other", " m Cycle Email/Texts/Meetings/People", + " , Open Settings", " ? Show this help", " q Quit", "", @@ -1275,6 +1277,7 @@ var peopleHelpLines = []string{ "", "Other", " m Cycle Email/Texts/Meetings/People", + " , Open Settings", " ? Show this help", " q Quit", "", diff --git a/internal/vector/config.go b/internal/vector/config.go index 057cfe4cf..0470ff5d6 100644 --- a/internal/vector/config.go +++ b/internal/vector/config.go @@ -173,16 +173,6 @@ func (m MultimodalConfig) ImageQueriesEnabled() bool { return m.Enabled && m.configuredImageQueries() } -// APIKey resolves the configured key without making its presence an -// enablement signal. Callers must pass the independent enablement and consent -// gates before using the returned value. -func (m MultimodalConfig) APIKey() string { - if m.APIKeyEnv == "" { - return "" - } - return lookupEnv(m.APIKeyEnv) -} - // EmbeddingsConfig configures the external embedding endpoint used to convert // message text into vectors. type EmbeddingsConfig struct { @@ -228,7 +218,16 @@ func (e EmbeddingsConfig) Validate() error { } u, err := url.Parse(e.Endpoint) if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { - return fmt.Errorf("vector.embeddings.endpoint: must be an http or https URL with a host (got %q)", e.Endpoint) + return errors.New("vector.embeddings.endpoint: must be an http or https URL with a host") + } + if u.User != nil { + return errors.New("vector.embeddings.endpoint: must not contain credentials") + } + if u.RawQuery != "" { + return errors.New("vector.embeddings.endpoint: must not contain a query") + } + if u.Fragment != "" { + return errors.New("vector.embeddings.endpoint: must not contain a fragment") } if e.Model == "" { return fmt.Errorf("vector.embeddings.model: required (the index generation fingerprint is %q, which is ambiguous without a model name)", e.Fingerprint()) @@ -690,12 +689,3 @@ func (c *Config) ApplyDefaults() { // StripQuotesEnabled / StripSignaturesEnabled helpers resolve the // effective value. } - -// APIKey resolves the API key from the env var named in APIKeyEnv. -// Returns "" if APIKeyEnv is empty or the variable is unset. -func (e EmbeddingsConfig) APIKey() string { - if e.APIKeyEnv == "" { - return "" - } - return lookupEnv(e.APIKeyEnv) -} diff --git a/internal/vector/config_test.go b/internal/vector/config_test.go index 4fffc9948..12abb1568 100644 --- a/internal/vector/config_test.go +++ b/internal/vector/config_test.go @@ -108,6 +108,27 @@ func TestConfig_Validate(t *testing.T) { } } +func TestEmbeddingsConfigValidateRejectsCredentialBearingEndpointComponents(t *testing.T) { + tests := []struct { + name string + endpoint string + }{ + {name: "userinfo", endpoint: "https://user:password@host.example/v1"}, + {name: "query", endpoint: "https://host.example/v1?api_key=secret"}, + {name: "fragment", endpoint: "https://host.example/v1#secret"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validConfig().Embeddings + cfg.Endpoint = tt.endpoint + err := cfg.Validate() + require.Error(t, err) + assert.NotContains(t, err.Error(), "password") + assert.NotContains(t, err.Error(), "secret") + }) + } +} + func validConfig() Config { return Config{ Enabled: true, @@ -689,11 +710,10 @@ func TestMultimodalFingerprintChangesForPolicyAndScope(t *testing.T) { func TestVoyageKeyDoesNotEnableMultimodal(t *testing.T) { assert := assert.New(t) - t.Setenv("VOYAGE_API_KEY", "synthetic-key") var cfg Config cfg.ApplyDefaults() - assert.NotEmpty(cfg.Multimodal.APIKey()) + assert.Equal("VOYAGE_API_KEY", cfg.Multimodal.APIKeyEnv) assert.False(cfg.Multimodal.Enabled) assert.False(cfg.AnyLaneEnabled()) assert.False(cfg.Multimodal.ImagesEnabled()) diff --git a/internal/vector/env.go b/internal/vector/env.go deleted file mode 100644 index 248cc5a08..000000000 --- a/internal/vector/env.go +++ /dev/null @@ -1,5 +0,0 @@ -package vector - -import "os" - -var lookupEnv = os.Getenv diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index bec8ca1d6..e0009d80c 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -183,6 +183,288 @@ func TestGeneratedPersonMergeRoundTrip(t *testing.T) { assert.Equal(`"person-7-r4"`, response.Headers200.ETag) } +func TestGeneratedCardDAVConflictAndPublicationRoundTrips(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + switch requests { + case 1: + assert.Equal(http.MethodPost, r.Method) + assert.Equal("/api/v1/carddav/conflicts/7/resolve", r.URL.Path) + var body generated.CardDAVResolveRequest + if !assert.NoError(json.NewDecoder(r.Body).Decode(&body)) { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + assert.Equal(generated.KeepRemote, body.Choice) + _, _ = w.Write([]byte(`{"id":7,"status":"resolved","resolution":"keep_remote"}`)) + case 2: + assert.Equal(http.MethodPost, r.Method) + assert.Equal("/api/v1/carddav/publications/11", r.URL.Path) + _, _ = w.Write([]byte(`{"person_id":11,"state":"published","desired":true,"address_book":{"id":2,"name":"Personal"}}`)) + case 3: + assert.Equal(http.MethodDelete, r.Method) + assert.Equal("/api/v1/carddav/publications/11", r.URL.Path) + _, _ = w.Write([]byte(`{"person_id":11,"state":"unpublished","desired":false,"address_book":{"id":2,"name":"Personal"}}`)) + default: + assert.Fail("unexpected request", "request %d", requests) + } + })) + t.Cleanup(server.Close) + client, err := New(server.URL) + require.NoError(err) + + resolved, err := client.ResolveCardDAVConflictWithResponse(t.Context(), &generated.ResolveCardDAVConflictRequestOptions{ + PathParams: &generated.ResolveCardDAVConflictPath{ID: 7}, + Body: &generated.ResolveCardDAVConflictBody{ + Choice: generated.KeepRemote, + }, + }) + require.NoError(err) + require.NotNil(resolved.JSON200) + assert.Equal(generated.CardDAVConflictResolutionResponseResolutionKeepRemote, resolved.JSON200.Resolution) + + published, err := client.PublishCardDAVPersonWithResponse(t.Context(), &generated.PublishCardDAVPersonRequestOptions{ + PathParams: &generated.PublishCardDAVPersonPath{PersonID: 11}, + }) + require.NoError(err) + require.NotNil(published.JSON200) + require.NotNil(published.JSON200.AddressBook) + assert.Equal(int64(2), published.JSON200.AddressBook.ID) + + unpublished, err := client.UnpublishCardDAVPersonWithResponse(t.Context(), &generated.UnpublishCardDAVPersonRequestOptions{ + PathParams: &generated.UnpublishCardDAVPersonPath{PersonID: 11}, + }) + require.NoError(err) + require.NotNil(unpublished.JSON200) + assert.False(unpublished.JSON200.Desired) + assert.Equal(3, requests) +} + +func TestOperationGeneratedClientDecodesSafeStatusListAndDetail(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + requests := 0 + statusRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/operations/status": + assert.Equal(http.MethodGet, r.Method) + statusRequests++ + if statusRequests > 1 { + _, _ = w.Write([]byte(`{"lanes":[]}`)) + return + } + _, _ = w.Write([]byte(`{"lanes":[ + {"kind":"person_enrichment","lane":"person_facts","configured":false,"history_availability":"unavailable","unavailable_code":"person_enrichment_history_unavailable","supported_actions":[]}, + {"kind":"visual_embedding","lane":"visual_attachments","configured":true,"history_availability":"unavailable","unavailable_code":"visual_embedding_history_unavailable","related_status":"getVisualAttachmentStatus","supported_actions":["visual_resume"]} + ]}`)) + case "/api/v1/operations/runs": + assert.Equal(http.MethodGet, r.Method) + if r.URL.Query().Get("kind") == "" { + _, _ = w.Write([]byte(`{"runs":[],"unavailable_kinds":[]}`)) + return + } + assert.Equal("source_sync", r.URL.Query().Get("kind")) + assert.Equal("messages", r.URL.Query().Get("lane")) + assert.Equal("succeeded", r.URL.Query().Get("state")) + assert.Equal("25", r.URL.Query().Get("limit")) + assert.Equal("opaque-current-page", r.URL.Query().Get("cursor")) + _, _ = w.Write([]byte(`{"runs":[ + {"id":"1.safe-source-run","kind":"source_sync","lane":"messages","state":"succeeded","trigger":"scheduled","started_at":"2026-08-29T10:00:00Z","finished_at":"2026-08-29T10:00:02Z","counters":[{"name":"processed","unit":"messages","value":4}]}, + {"id":"1.safe-carddav-run","kind":"carddav_sync","lane":"contacts","state":"failed","trigger":"manual","started_at":"2026-08-29T11:00:00Z","finished_at":"2026-08-29T11:00:01Z","counters":[{"name":"books","unit":"books","value":1}],"error":{"code":"authentication_failed","message":"CardDAV authentication failed."}} + ],"next_cursor":"opaque-next-page","unavailable_kinds":[{"kind":"message_embedding","lane":"messages","unavailable_code":"message_embedding_history_unavailable"}]}`)) + case "/api/v1/operations/runs/1.safe-source-run": + assert.Equal(http.MethodGet, r.Method) + _, _ = w.Write([]byte(`{"id":"1.safe-source-run","kind":"source_sync","lane":"messages","state":"succeeded","trigger":"scheduled","started_at":"2026-08-29T10:00:00Z","finished_at":"2026-08-29T10:00:02Z","counters":[]}`)) + default: + assert.Fail("unexpected request", r.URL.Path) + } + })) + t.Cleanup(server.Close) + client, err := New(server.URL) + require.NoError(err) + + status, err := client.GetOperationStatusWithResponse(t.Context()) + require.NoError(err) + require.NotNil(status.JSON200) + require.Len(status.JSON200.Lanes, 2) + assert.Equal("person_enrichment", string(status.JSON200.Lanes[0].Kind)) + assert.NotNil(status.JSON200.Lanes[0].SupportedActions) + assert.Empty(status.JSON200.Lanes[0].SupportedActions) + require.NotNil(status.JSON200.Lanes[1].RelatedStatus) + assert.Equal(generated.GetVisualAttachmentStatus, *status.JSON200.Lanes[1].RelatedStatus) + assert.Equal(generated.VisualResume, status.JSON200.Lanes[1].SupportedActions[0]) + + kind := generated.ListOperationRunsQueryKindSourceSync + lane := generated.ListOperationRunsQueryLaneMessages + state := generated.ListOperationRunsQueryStateSucceeded + limit := int64(25) + cursor := "opaque-current-page" + list, err := client.ListOperationRunsWithResponse(t.Context(), &generated.ListOperationRunsRequestOptions{Query: &generated.ListOperationRunsQuery{ + Kind: &kind, Lane: &lane, State: &state, Limit: &limit, Cursor: &cursor, + }}) + require.NoError(err) + require.NotNil(list.JSON200) + require.Len(list.JSON200.Runs, 2) + assert.Equal(time.Date(2026, time.August, 29, 10, 0, 0, 0, time.UTC), list.JSON200.Runs[0].StartedAt) + require.NotNil(list.JSON200.Runs[0].Trigger) + assert.Equal("scheduled", string(*list.JSON200.Runs[0].Trigger)) + require.Len(list.JSON200.Runs[0].Counters, 1) + assert.Equal(generated.Processed, list.JSON200.Runs[0].Counters[0].Name) + require.NotNil(list.JSON200.Runs[1].ErrorData) + assert.Equal(generated.OperationPublicErrorCodeAuthenticationFailed, list.JSON200.Runs[1].ErrorData.Code) + assert.Equal("CardDAV authentication failed.", list.JSON200.Runs[1].ErrorData.Message) + assert.Equal("opaque-next-page", *list.JSON200.NextCursor) + require.Len(list.JSON200.UnavailableKinds, 1) + assert.Equal("message_embedding", string(list.JSON200.UnavailableKinds[0].Kind)) + + detail, err := client.GetOperationRunWithResponse(t.Context(), &generated.GetOperationRunRequestOptions{ + PathParams: &generated.GetOperationRunPath{ID: "1.safe-source-run"}, + }) + require.NoError(err) + require.NotNil(detail.JSON200) + assert.Equal("source_sync", string(detail.JSON200.Kind)) + assert.NotNil(detail.JSON200.Counters) + assert.Empty(detail.JSON200.Counters) + + emptyStatus, err := client.GetOperationStatusWithResponse(t.Context()) + require.NoError(err) + require.NotNil(emptyStatus.JSON200) + assert.NotNil(emptyStatus.JSON200.Lanes) + assert.Empty(emptyStatus.JSON200.Lanes) + emptyList, err := client.ListOperationRunsWithResponse(t.Context(), &generated.ListOperationRunsRequestOptions{}) + require.NoError(err) + require.NotNil(emptyList.JSON200) + assert.NotNil(emptyList.JSON200.Runs) + assert.Empty(emptyList.JSON200.Runs) + assert.NotNil(emptyList.JSON200.UnavailableKinds) + assert.Empty(emptyList.JSON200.UnavailableKinds) + assert.Equal(5, requests) + + encoded, err := json.Marshal(struct { + Status *generated.OperationStatusResponse `json:"status"` + List *generated.OperationRunsResponse `json:"list"` + Detail *generated.OperationRunDetail `json:"detail"` + }{Status: status.JSON200, List: list.JSON200, Detail: detail.JSON200}) + require.NoError(err) + for _, forbidden := range []string{ + `"method"`, `"path"`, `"action"`, `"url"`, `"href"`, `"vcard"`, `"credentials"`, + `"fingerprint"`, `"provider"`, `"account"`, `"message_id"`, `"person_id"`, `"source_id"`, + `"raw_error"`, `"evidence"`, `"usage"`, `"cost"`, `"args"`, + } { + assert.NotContains(string(encoded), forbidden) + } +} + +func TestGeneratedCardDAVConflictListAndDetailDecodeSafeContract(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + createdAt := time.Date(2026, time.August, 28, 9, 10, 11, 0, time.UTC) + updatedAt := time.Date(2026, time.August, 28, 10, 11, 12, 0, time.UTC) + resolvedAt := time.Date(2026, time.August, 28, 11, 12, 13, 0, time.UTC) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/carddav/conflicts": + assert.Equal(http.MethodGet, r.Method) + _, _ = w.Write([]byte(`{"conflicts":[{"id":7,"address_book":{"id":2,"name":"Personal"},"status":"unresolved","local_state":"deleted","remote_state":"present","allowed_resolutions":["keep_local","keep_remote"],"updated_at":"2026-08-28T10:11:12Z"}]}`)) + case "/api/v1/carddav/conflicts/8": + assert.Equal(http.MethodGet, r.Method) + _, _ = w.Write([]byte(`{"id":8,"address_book":{"id":3,"name":"Archive"},"status":"resolved","resolution":"keep_local","base":{"state":"unavailable","emails":[],"phones":[]},"local":{"state":"present","display_name":"Alice","emails":["alice@example.test"],"phones":["+12025550123"],"truncated":true},"remote":{"state":"deleted","emails":[],"phones":[]},"allowed_resolutions":[],"created_at":"2026-08-28T09:10:11Z","updated_at":"2026-08-28T10:11:12Z","resolved_at":"2026-08-28T11:12:13Z"}`)) + default: + assert.Fail("unexpected request", r.URL.Path) + } + })) + t.Cleanup(server.Close) + client, err := New(server.URL) + require.NoError(err) + + list, err := client.ListCardDAVConflictsWithResponse(t.Context()) + require.NoError(err) + require.NotNil(list.JSON200) + require.NotNil(list.JSON200.Conflicts) + require.Len(list.JSON200.Conflicts, 1) + require.NoError(list.JSON200.Validate()) + item := list.JSON200.Conflicts[0] + assert.Equal(int64(7), item.ID) + assert.Equal(int64(2), item.AddressBook.ID) + assert.Equal("Personal", item.AddressBook.Name) + assert.Equal(generated.CardDAVConflictResponseStatusUnresolved, item.Status) + assert.Equal(generated.CardDAVConflictResponseLocalStateDeleted, item.LocalState) + assert.Equal(generated.CardDAVConflictResponseRemoteStatePresent, item.RemoteState) + assert.Equal([]generated.CardDAVConflictResponseAllowedResolutions{ + generated.CardDAVConflictResponseAllowedResolutionsKeepLocal, + generated.CardDAVConflictResponseAllowedResolutionsKeepRemote, + }, item.AllowedResolutions) + assert.Equal(updatedAt, item.UpdatedAt) + + detail, err := client.GetCardDAVConflictWithResponse(t.Context(), &generated.GetCardDAVConflictRequestOptions{ + PathParams: &generated.GetCardDAVConflictPath{ID: 8}, + }) + require.NoError(err) + require.NotNil(detail.JSON200) + assert.Equal(int64(8), detail.JSON200.ID) + assert.Equal(int64(3), detail.JSON200.AddressBook.ID) + assert.Equal("Archive", detail.JSON200.AddressBook.Name) + assert.Equal(generated.CardDAVConflictDetailResponseStatusResolved, detail.JSON200.Status) + require.NotNil(detail.JSON200.Resolution) + assert.Equal(generated.CardDAVConflictDetailResponseResolutionKeepLocal, *detail.JSON200.Resolution) + assert.Equal(generated.CardDAVContactSummaryResponseStateUnavailable, detail.JSON200.Base.State) + assert.NotNil(detail.JSON200.Base.Emails) + assert.NotNil(detail.JSON200.Base.Phones) + assert.Equal(generated.CardDAVContactSummaryResponseStatePresent, detail.JSON200.Local.State) + require.NotNil(detail.JSON200.Local.DisplayName) + assert.Equal("Alice", *detail.JSON200.Local.DisplayName) + assert.Equal([]string{"alice@example.test"}, detail.JSON200.Local.Emails) + assert.Equal([]string{"+12025550123"}, detail.JSON200.Local.Phones) + require.NotNil(detail.JSON200.Local.Truncated) + assert.True(*detail.JSON200.Local.Truncated) + assert.Equal(generated.CardDAVContactSummaryResponseStateDeleted, detail.JSON200.Remote.State) + assert.NotNil(detail.JSON200.Remote.Emails) + assert.NotNil(detail.JSON200.Remote.Phones) + assert.NotNil(detail.JSON200.AllowedResolutions) + assert.Empty(detail.JSON200.AllowedResolutions) + assert.Equal(createdAt, detail.JSON200.CreatedAt) + assert.Equal(updatedAt, detail.JSON200.UpdatedAt) + require.NotNil(detail.JSON200.ResolvedAt) + assert.Equal(resolvedAt, *detail.JSON200.ResolvedAt) + require.NoError(detail.JSON200.Validate()) + encoded, err := json.Marshal(detail.JSON200) + require.NoError(err) + assert.Contains(string(encoded), `"allowed_resolutions":[]`) +} + +func TestGeneratedCardDAVConflictDecodesTyped409(t *testing.T) { + require := require.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/carddav/conflicts/7/resolve", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"carddav_conflict_stale","message":"CardDAV conflict changed; refresh before trying again"}`)) + })) + t.Cleanup(server.Close) + client, err := New(server.URL) + require.NoError(err) + + response, err := client.ResolveCardDAVConflictWithResponse(t.Context(), &generated.ResolveCardDAVConflictRequestOptions{ + PathParams: &generated.ResolveCardDAVConflictPath{ID: 7}, + Body: &generated.ResolveCardDAVConflictBody{ + Choice: generated.KeepLocal, + }, + }) + require.Error(err) + require.NotNil(response) + require.NotNil(response.JSON409) + assert.Equal(t, "carddav_conflict_stale", response.JSON409.ErrorData) +} + func TestGeneratedPersonMergeSnapshotPreservesArbitraryJSON(t *testing.T) { want := `{ "version":1, diff --git a/pkg/client/generated/client.go b/pkg/client/generated/client.go index 8e5d72b91..9cb1b683b 100644 --- a/pkg/client/generated/client.go +++ b/pkg/client/generated/client.go @@ -143,6 +143,14 @@ type ClientInterface interface { PublishCardDAVPerson(ctx context.Context, options *PublishCardDAVPersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PublishCardDAVPersonResponse, error) PublishCardDAVPersonWithResponse(ctx context.Context, options *PublishCardDAVPersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PublishCardDAVPersonResp, error) + // ListCardDAVRuns List CardDAV synchronization runs + ListCardDAVRuns(ctx context.Context, options *ListCardDAVRunsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListCardDAVRunsResponse, error) + ListCardDAVRunsWithResponse(ctx context.Context, options *ListCardDAVRunsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListCardDAVRunsResp, error) + + // GetCardDAVStatus Get CardDAV synchronization status + GetCardDAVStatus(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetCardDAVStatusResponse, error) + GetCardDAVStatusWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetCardDAVStatusResp, error) + // SyncCardDAV Trigger CardDAV synchronization SyncCardDAV(ctx context.Context, options *SyncCardDAVRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SyncCardDAVResponse, error) SyncCardDAVWithResponse(ctx context.Context, options *SyncCardDAVRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SyncCardDAVResp, error) @@ -523,6 +531,18 @@ type ClientInterface interface { GetVisualAttachmentStatus(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetVisualAttachmentStatusResponse, error) GetVisualAttachmentStatusWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetVisualAttachmentStatusResp, error) + // ListOperationRuns List normalized operation history + ListOperationRuns(ctx context.Context, options *ListOperationRunsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListOperationRunsResponse, error) + ListOperationRunsWithResponse(ctx context.Context, options *ListOperationRunsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListOperationRunsResp, error) + + // GetOperationRun Get one normalized operation run + GetOperationRun(ctx context.Context, options *GetOperationRunRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetOperationRunResponse, error) + GetOperationRunWithResponse(ctx context.Context, options *GetOperationRunRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetOperationRunResp, error) + + // GetOperationStatus Get normalized operation lane status + GetOperationStatus(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetOperationStatusResponse, error) + GetOperationStatusWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetOperationStatusResp, error) + // ListOrganizations List organizations ListOrganizations(ctx context.Context, options *ListOrganizationsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListOrganizationsResponse, error) ListOrganizationsWithResponse(ctx context.Context, options *ListOrganizationsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListOrganizationsResp, error) @@ -611,6 +631,10 @@ type ClientInterface interface { CreatePerson(ctx context.Context, options *CreatePersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreatePersonResponseJSON, error) CreatePersonWithResponse(ctx context.Context, options *CreatePersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreatePersonResp, error) + // ListDirectoryPeople Query durable people for the Directory + ListDirectoryPeople(ctx context.Context, options *ListDirectoryPeopleRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListDirectoryPeopleResponse, error) + ListDirectoryPeopleWithResponse(ctx context.Context, options *ListDirectoryPeopleRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListDirectoryPeopleResp, error) + // SearchPeople Search durable people semantically SearchPeople(ctx context.Context, options *SearchPeopleRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchPeopleResponse, error) SearchPeopleWithResponse(ctx context.Context, options *SearchPeopleRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchPeopleResp, error) @@ -691,6 +715,10 @@ type ClientInterface interface { ListPersonMerges(ctx context.Context, options *ListPersonMergesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonMergesResponse, error) ListPersonMergesWithResponse(ctx context.Context, options *ListPersonMergesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonMergesResp, error) + // GetPersonNetwork Get a bounded curated person network + GetPersonNetwork(ctx context.Context, options *GetPersonNetworkRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonNetworkResponse, error) + GetPersonNetworkWithResponse(ctx context.Context, options *GetPersonNetworkRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonNetworkResp, error) + // AppendPersonNote Append to a person's notes AppendPersonNote(ctx context.Context, options *AppendPersonNoteRequestOptions, reqEditors ...runtime.RequestEditorFn) (*AppendPersonNoteResponse, error) AppendPersonNoteWithResponse(ctx context.Context, options *AppendPersonNoteRequestOptions, reqEditors ...runtime.RequestEditorFn) (*AppendPersonNoteResp, error) @@ -859,6 +887,18 @@ type ClientInterface interface { PatchSettings(ctx context.Context, options *PatchSettingsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchSettingsResponse, error) PatchSettingsWithResponse(ctx context.Context, options *PatchSettingsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchSettingsResp, error) + // PutSettingsPersonEnrichmentProvider Create or update one named person-enrichment provider + PutSettingsPersonEnrichmentProvider(ctx context.Context, options *PutSettingsPersonEnrichmentProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPersonEnrichmentProviderResponse, error) + PutSettingsPersonEnrichmentProviderWithResponse(ctx context.Context, options *PutSettingsPersonEnrichmentProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPersonEnrichmentProviderResp, error) + + // DeleteSettingsProviderCredential Clear a stored provider credential + DeleteSettingsProviderCredential(ctx context.Context, options *DeleteSettingsProviderCredentialRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteSettingsProviderCredentialResponse, error) + DeleteSettingsProviderCredentialWithResponse(ctx context.Context, options *DeleteSettingsProviderCredentialRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteSettingsProviderCredentialResp, error) + + // PutSettingsProviderCredential Set a write-only provider credential + PutSettingsProviderCredential(ctx context.Context, options *PutSettingsProviderCredentialRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsProviderCredentialResponse, error) + PutSettingsProviderCredentialWithResponse(ctx context.Context, options *PutSettingsProviderCredentialRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsProviderCredentialResp, error) + // ListSourceStatus List source sync status ListSourceStatus(ctx context.Context, options *ListSourceStatusRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListSourceStatusResponse, error) ListSourceStatusWithResponse(ctx context.Context, options *ListSourceStatusRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListSourceStatusResp, error) @@ -2631,6 +2671,131 @@ func (c *Client) PublishCardDAVPerson(ctx context.Context, options *PublishCardD return responseParser(ctx, resp) } +// ListCardDAVRuns List CardDAV synchronization runs +func (c *Client) ListCardDAVRuns(ctx context.Context, options *ListCardDAVRunsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListCardDAVRunsResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/carddav/runs", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListCardDAVRunsResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListCardDAVRunsErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListCardDAVRunsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListCardDAVRunsResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListCardDAVRunsResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/carddav/runs") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// GetCardDAVStatus Get CardDAV synchronization status +func (c *Client) GetCardDAVStatus(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetCardDAVStatusResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/carddav/status", + Method: "GET", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetCardDAVStatusResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(GetCardDAVStatusErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetCardDAVStatusErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(GetCardDAVStatusResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetCardDAVStatusResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/carddav/status") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + // SyncCardDAV Trigger CardDAV synchronization func (c *Client) SyncCardDAV(ctx context.Context, options *SyncCardDAVRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SyncCardDAVResponse, error) { var err error @@ -8426,11 +8591,11 @@ func (c *Client) GetVisualAttachmentStatus(ctx context.Context, reqEditors ...ru return responseParser(ctx, resp) } -// ListOrganizations List organizations -func (c *Client) ListOrganizations(ctx context.Context, options *ListOrganizationsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListOrganizationsResponse, error) { +// ListOperationRuns List normalized operation history +func (c *Client) ListOperationRuns(ctx context.Context, options *ListOperationRunsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListOperationRunsResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/operations/runs", Method: "GET", Options: options, } @@ -8440,10 +8605,10 @@ func (c *Client) ListOrganizations(ctx context.Context, options *ListOrganizatio return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*ListOrganizationsResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListOperationRunsResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 200 { - target := new(ListOrganizationsErrorResponse) + target := new(ListOperationRunsErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -8451,7 +8616,7 @@ func (c *Client) ListOrganizations(ctx context.Context, options *ListOrganizatio StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListOrganizationsErrorResponse", + TargetType: "ListOperationRunsErrorResponse", Body: bodyBytes, Err: err, } @@ -8464,7 +8629,7 @@ func (c *Client) ListOrganizations(ctx context.Context, options *ListOrganizatio return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(ListOrganizationsResponse) + target := new(ListOperationRunsResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -8474,7 +8639,7 @@ func (c *Client) ListOrganizations(ctx context.Context, options *ListOrganizatio StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListOrganizationsResponse", + TargetType: "ListOperationRunsResponse", Body: bodyBytes, Err: err, } @@ -8482,21 +8647,20 @@ func (c *Client) ListOrganizations(ctx context.Context, options *ListOrganizatio return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/operations/runs") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// CreateOrganization Create an organization -func (c *Client) CreateOrganization(ctx context.Context, options *CreateOrganizationRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreateOrganizationResponse, error) { +// GetOperationRun Get one normalized operation run +func (c *Client) GetOperationRun(ctx context.Context, options *GetOperationRunRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetOperationRunResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations", - Method: "POST", - Options: options, - ContentType: "application/json", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/operations/runs/{id}", + Method: "GET", + Options: options, } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -8504,10 +8668,10 @@ func (c *Client) CreateOrganization(ctx context.Context, options *CreateOrganiza return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*CreateOrganizationResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetOperationRunResponse, error) { bodyBytes := resp.Content - if resp.StatusCode != 201 { - target := new(CreateOrganizationErrorResponse) + if resp.StatusCode != 200 { + target := new(GetOperationRunErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -8515,7 +8679,7 @@ func (c *Client) CreateOrganization(ctx context.Context, options *CreateOrganiza StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "CreateOrganizationErrorResponse", + TargetType: "GetOperationRunErrorResponse", Body: bodyBytes, Err: err, } @@ -8528,7 +8692,7 @@ func (c *Client) CreateOrganization(ctx context.Context, options *CreateOrganiza return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(CreateOrganizationResponse) + target := new(GetOperationRunResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -8538,7 +8702,7 @@ func (c *Client) CreateOrganization(ctx context.Context, options *CreateOrganiza StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "CreateOrganizationResponse", + TargetType: "GetOperationRunResponse", Body: bodyBytes, Err: err, } @@ -8546,20 +8710,19 @@ func (c *Client) CreateOrganization(ctx context.Context, options *CreateOrganiza return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/operations/runs/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// DeleteOrganization Delete an organization without employment records -func (c *Client) DeleteOrganization(ctx context.Context, options *DeleteOrganizationRequestOptions, reqEditors ...runtime.RequestEditorFn) (*struct{}, error) { +// GetOperationStatus Get normalized operation lane status +func (c *Client) GetOperationStatus(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetOperationStatusResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations/{id}", - Method: "DELETE", - Options: options, + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/operations/status", + Method: "GET", } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -8567,10 +8730,10 @@ func (c *Client) DeleteOrganization(ctx context.Context, options *DeleteOrganiza return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*struct{}, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetOperationStatusResponse, error) { bodyBytes := resp.Content - if resp.StatusCode != 204 { - target := new(DeleteOrganizationErrorResponse) + if resp.StatusCode != 200 { + target := new(GetOperationStatusErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -8578,7 +8741,7 @@ func (c *Client) DeleteOrganization(ctx context.Context, options *DeleteOrganiza StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "DeleteOrganizationErrorResponse", + TargetType: "GetOperationStatusErrorResponse", Body: bodyBytes, Err: err, } @@ -8591,21 +8754,36 @@ func (c *Client) DeleteOrganization(ctx context.Context, options *DeleteOrganiza return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - return nil, nil + target := new(GetOperationStatusResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetOperationStatusResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations/{id}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/operations/status") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// GetOrganization Get an organization -func (c *Client) GetOrganization(ctx context.Context, options *GetOrganizationRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetOrganizationResponse, error) { +// ListOrganizations List organizations +func (c *Client) ListOrganizations(ctx context.Context, options *ListOrganizationsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListOrganizationsResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations/{id}", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations", Method: "GET", Options: options, } @@ -8615,10 +8793,10 @@ func (c *Client) GetOrganization(ctx context.Context, options *GetOrganizationRe return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*GetOrganizationResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListOrganizationsResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 200 { - target := new(GetOrganizationErrorResponse) + target := new(ListOrganizationsErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -8626,7 +8804,7 @@ func (c *Client) GetOrganization(ctx context.Context, options *GetOrganizationRe StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetOrganizationErrorResponse", + TargetType: "ListOrganizationsErrorResponse", Body: bodyBytes, Err: err, } @@ -8639,7 +8817,7 @@ func (c *Client) GetOrganization(ctx context.Context, options *GetOrganizationRe return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(GetOrganizationResponse) + target := new(ListOrganizationsResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -8649,7 +8827,7 @@ func (c *Client) GetOrganization(ctx context.Context, options *GetOrganizationRe StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetOrganizationResponse", + TargetType: "ListOrganizationsResponse", Body: bodyBytes, Err: err, } @@ -8657,19 +8835,19 @@ func (c *Client) GetOrganization(ctx context.Context, options *GetOrganizationRe return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations/{id}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// PatchOrganization Replace an organization's mutable fields -func (c *Client) PatchOrganization(ctx context.Context, options *PatchOrganizationRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchOrganizationResponse, error) { +// CreateOrganization Create an organization +func (c *Client) CreateOrganization(ctx context.Context, options *CreateOrganizationRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreateOrganizationResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations/{id}", - Method: "PATCH", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations", + Method: "POST", Options: options, ContentType: "application/json", } @@ -8679,10 +8857,10 @@ func (c *Client) PatchOrganization(ctx context.Context, options *PatchOrganizati return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*PatchOrganizationResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*CreateOrganizationResponse, error) { bodyBytes := resp.Content - if resp.StatusCode != 200 { - target := new(PatchOrganizationErrorResponse) + if resp.StatusCode != 201 { + target := new(CreateOrganizationErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -8690,7 +8868,7 @@ func (c *Client) PatchOrganization(ctx context.Context, options *PatchOrganizati StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchOrganizationErrorResponse", + TargetType: "CreateOrganizationErrorResponse", Body: bodyBytes, Err: err, } @@ -8703,7 +8881,7 @@ func (c *Client) PatchOrganization(ctx context.Context, options *PatchOrganizati return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(PatchOrganizationResponse) + target := new(CreateOrganizationResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -8713,7 +8891,7 @@ func (c *Client) PatchOrganization(ctx context.Context, options *PatchOrganizati StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchOrganizationResponse", + TargetType: "CreateOrganizationResponse", Body: bodyBytes, Err: err, } @@ -8721,19 +8899,19 @@ func (c *Client) PatchOrganization(ctx context.Context, options *PatchOrganizati return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations/{id}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// ListOrganizationAttributes List organization typed attributes -func (c *Client) ListOrganizationAttributes(ctx context.Context, options *ListOrganizationAttributesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListOrganizationAttributesResponse, error) { +// DeleteOrganization Delete an organization without employment records +func (c *Client) DeleteOrganization(ctx context.Context, options *DeleteOrganizationRequestOptions, reqEditors ...runtime.RequestEditorFn) (*struct{}, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations/{id}/attributes", - Method: "GET", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations/{id}", + Method: "DELETE", Options: options, } @@ -8742,10 +8920,10 @@ func (c *Client) ListOrganizationAttributes(ctx context.Context, options *ListOr return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*ListOrganizationAttributesResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*struct{}, error) { bodyBytes := resp.Content - if resp.StatusCode != 200 { - target := new(ListOrganizationAttributesErrorResponse) + if resp.StatusCode != 204 { + target := new(DeleteOrganizationErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -8753,7 +8931,7 @@ func (c *Client) ListOrganizationAttributes(ctx context.Context, options *ListOr StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListOrganizationAttributesErrorResponse", + TargetType: "DeleteOrganizationErrorResponse", Body: bodyBytes, Err: err, } @@ -8766,39 +8944,23 @@ func (c *Client) ListOrganizationAttributes(ctx context.Context, options *ListOr return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(ListOrganizationAttributesResponse) - // Handle empty response body gracefully - if len(bodyBytes) == 0 { - return target, nil - } - if err = json.Unmarshal(bodyBytes, target); err != nil { - return nil, &runtime.ResponseDecodeError{ - StatusCode: resp.StatusCode, - ContentType: resp.Headers.Get("Content-Type"), - ContentLength: len(bodyBytes), - TargetType: "ListOrganizationAttributesResponse", - Body: bodyBytes, - Err: err, - } - } - return target, nil + return nil, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations/{id}/attributes") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// SetOrganizationAttribute Set an organization typed attribute -func (c *Client) SetOrganizationAttribute(ctx context.Context, options *SetOrganizationAttributeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetOrganizationAttributeResponseJSON, error) { +// GetOrganization Get an organization +func (c *Client) GetOrganization(ctx context.Context, options *GetOrganizationRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetOrganizationResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations/{id}/attributes", - Method: "POST", - Options: options, - ContentType: "application/json", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations/{id}", + Method: "GET", + Options: options, } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -8806,7 +8968,198 @@ func (c *Client) SetOrganizationAttribute(ctx context.Context, options *SetOrgan return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*SetOrganizationAttributeResponseJSON, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetOrganizationResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(GetOrganizationErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetOrganizationErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(GetOrganizationResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetOrganizationResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// PatchOrganization Replace an organization's mutable fields +func (c *Client) PatchOrganization(ctx context.Context, options *PatchOrganizationRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchOrganizationResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations/{id}", + Method: "PATCH", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*PatchOrganizationResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(PatchOrganizationErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PatchOrganizationErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(PatchOrganizationResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PatchOrganizationResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// ListOrganizationAttributes List organization typed attributes +func (c *Client) ListOrganizationAttributes(ctx context.Context, options *ListOrganizationAttributesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListOrganizationAttributesResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations/{id}/attributes", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListOrganizationAttributesResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListOrganizationAttributesErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListOrganizationAttributesErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListOrganizationAttributesResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListOrganizationAttributesResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations/{id}/attributes") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// SetOrganizationAttribute Set an organization typed attribute +func (c *Client) SetOrganizationAttribute(ctx context.Context, options *SetOrganizationAttributeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetOrganizationAttributeResponseJSON, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations/{id}/attributes", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*SetOrganizationAttributeResponseJSON, error) { bodyBytes := resp.Content if resp.StatusCode != 201 { target := new(SetOrganizationAttributeErrorResponse) @@ -9793,6 +10146,69 @@ func (c *Client) CreatePerson(ctx context.Context, options *CreatePersonRequestO return responseParser(ctx, resp) } +// ListDirectoryPeople Query durable people for the Directory +func (c *Client) ListDirectoryPeople(ctx context.Context, options *ListDirectoryPeopleRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListDirectoryPeopleResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/directory", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListDirectoryPeopleResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListDirectoryPeopleErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListDirectoryPeopleErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListDirectoryPeopleResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListDirectoryPeopleResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/directory") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + // SearchPeople Search durable people semantically func (c *Client) SearchPeople(ctx context.Context, options *SearchPeopleRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchPeopleResponse, error) { var err error @@ -11044,6 +11460,69 @@ func (c *Client) ListPersonMerges(ctx context.Context, options *ListPersonMerges return responseParser(ctx, resp) } +// GetPersonNetwork Get a bounded curated person network +func (c *Client) GetPersonNetwork(ctx context.Context, options *GetPersonNetworkRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonNetworkResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/network", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetPersonNetworkResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(GetPersonNetworkErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonNetworkErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(GetPersonNetworkResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonNetworkResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/network") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + // AppendPersonNote Append to a person's notes func (c *Client) AppendPersonNote(ctx context.Context, options *AppendPersonNoteRequestOptions, reqEditors ...runtime.RequestEditorFn) (*AppendPersonNoteResponse, error) { var err error @@ -13645,6 +14124,197 @@ func (c *Client) PatchSettings(ctx context.Context, options *PatchSettingsReques return responseParser(ctx, resp) } +// PutSettingsPersonEnrichmentProvider Create or update one named person-enrichment provider +func (c *Client) PutSettingsPersonEnrichmentProvider(ctx context.Context, options *PutSettingsPersonEnrichmentProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPersonEnrichmentProviderResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/person-enrichment/providers/{name}", + Method: "PUT", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*PutSettingsPersonEnrichmentProviderResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(PutSettingsPersonEnrichmentProviderErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPersonEnrichmentProviderErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(PutSettingsPersonEnrichmentProviderResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPersonEnrichmentProviderResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/person-enrichment/providers/{name}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// DeleteSettingsProviderCredential Clear a stored provider credential +func (c *Client) DeleteSettingsProviderCredential(ctx context.Context, options *DeleteSettingsProviderCredentialRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteSettingsProviderCredentialResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/provider-credentials/{credential_id}", + Method: "DELETE", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*DeleteSettingsProviderCredentialResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(DeleteSettingsProviderCredentialErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsProviderCredentialErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(DeleteSettingsProviderCredentialResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsProviderCredentialResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/provider-credentials/{credential_id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// PutSettingsProviderCredential Set a write-only provider credential +func (c *Client) PutSettingsProviderCredential(ctx context.Context, options *PutSettingsProviderCredentialRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsProviderCredentialResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/provider-credentials/{credential_id}", + Method: "PUT", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*PutSettingsProviderCredentialResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(PutSettingsProviderCredentialErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsProviderCredentialErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(PutSettingsProviderCredentialResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsProviderCredentialResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/provider-credentials/{credential_id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + // ListSourceStatus List source sync status func (c *Client) ListSourceStatus(ctx context.Context, options *ListSourceStatusRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListSourceStatusResponse, error) { var err error diff --git a/pkg/client/generated/client_options.go b/pkg/client/generated/client_options.go index 45b8dcfa7..961da9750 100644 --- a/pkg/client/generated/client_options.go +++ b/pkg/client/generated/client_options.go @@ -984,6 +984,50 @@ func (o *PublishCardDAVPersonRequestOptions) GetHeader() (map[string]string, err return nil, nil } +// ListCardDAVRunsRequestOptions is the options needed to make a request to ListCardDAVRuns. +type ListCardDAVRunsRequestOptions struct { + Query *ListCardDAVRunsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListCardDAVRunsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListCardDAVRunsRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *ListCardDAVRunsRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListCardDAVRunsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListCardDAVRunsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + // SyncCardDAVRequestOptions is the options needed to make a request to SyncCardDAV. type SyncCardDAVRequestOptions struct { Body *SyncCardDAVBody @@ -4860,6 +4904,94 @@ func (o *RetryVisualAttachmentOwnerRequestOptions) GetHeader() (map[string]strin return nil, nil } +// ListOperationRunsRequestOptions is the options needed to make a request to ListOperationRuns. +type ListOperationRunsRequestOptions struct { + Query *ListOperationRunsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListOperationRunsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListOperationRunsRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *ListOperationRunsRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListOperationRunsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListOperationRunsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// GetOperationRunRequestOptions is the options needed to make a request to GetOperationRun. +type GetOperationRunRequestOptions struct { + PathParams *GetOperationRunPath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetOperationRunRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetOperationRunRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetOperationRunRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetOperationRunRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetOperationRunRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + // ListOrganizationsRequestOptions is the options needed to make a request to ListOrganizations. type ListOrganizationsRequestOptions struct { Query *ListOrganizationsQuery @@ -5910,6 +6042,50 @@ func (o *CreatePersonRequestOptions) GetHeader() (map[string]string, error) { return nil, nil } +// ListDirectoryPeopleRequestOptions is the options needed to make a request to ListDirectoryPeople. +type ListDirectoryPeopleRequestOptions struct { + Query *ListDirectoryPeopleQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListDirectoryPeopleRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListDirectoryPeopleRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *ListDirectoryPeopleRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListDirectoryPeopleRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListDirectoryPeopleRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + // SearchPeopleRequestOptions is the options needed to make a request to SearchPeople. type SearchPeopleRequestOptions struct { Body *SearchPeopleBody @@ -6961,6 +7137,59 @@ func (o *ListPersonMergesRequestOptions) GetHeader() (map[string]string, error) return nil, nil } +// GetPersonNetworkRequestOptions is the options needed to make a request to GetPersonNetwork. +type GetPersonNetworkRequestOptions struct { + PathParams *GetPersonNetworkPath + Query *GetPersonNetworkQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetPersonNetworkRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetPersonNetworkRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetPersonNetworkRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetPersonNetworkRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetPersonNetworkRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + // AppendPersonNoteRequestOptions is the options needed to make a request to AppendPersonNote. type AppendPersonNoteRequestOptions struct { PathParams *AppendPersonNotePath @@ -8831,6 +9060,183 @@ func (o *PatchSettingsRequestOptions) GetHeader() (map[string]string, error) { return runtime.AsMap[string](o.Header) } +// PutSettingsPersonEnrichmentProviderRequestOptions is the options needed to make a request to PutSettingsPersonEnrichmentProvider. +type PutSettingsPersonEnrichmentProviderRequestOptions struct { + PathParams *PutSettingsPersonEnrichmentProviderPath + Body *PutSettingsPersonEnrichmentProviderBody + Header *PutSettingsPersonEnrichmentProviderHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *PutSettingsPersonEnrichmentProviderRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *PutSettingsPersonEnrichmentProviderRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *PutSettingsPersonEnrichmentProviderRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *PutSettingsPersonEnrichmentProviderRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *PutSettingsPersonEnrichmentProviderRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// DeleteSettingsProviderCredentialRequestOptions is the options needed to make a request to DeleteSettingsProviderCredential. +type DeleteSettingsProviderCredentialRequestOptions struct { + PathParams *DeleteSettingsProviderCredentialPath + Header *DeleteSettingsProviderCredentialHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *DeleteSettingsProviderCredentialRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *DeleteSettingsProviderCredentialRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *DeleteSettingsProviderCredentialRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *DeleteSettingsProviderCredentialRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *DeleteSettingsProviderCredentialRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// PutSettingsProviderCredentialRequestOptions is the options needed to make a request to PutSettingsProviderCredential. +type PutSettingsProviderCredentialRequestOptions struct { + PathParams *PutSettingsProviderCredentialPath + Body *PutSettingsProviderCredentialBody + Header *PutSettingsProviderCredentialHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *PutSettingsProviderCredentialRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *PutSettingsProviderCredentialRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *PutSettingsProviderCredentialRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *PutSettingsProviderCredentialRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *PutSettingsProviderCredentialRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + // ListSourceStatusRequestOptions is the options needed to make a request to ListSourceStatus. type ListSourceStatusRequestOptions struct { Query *ListSourceStatusQuery diff --git a/pkg/client/generated/client_with_response.go b/pkg/client/generated/client_with_response.go index 5bd7a468d..70169e358 100644 --- a/pkg/client/generated/client_with_response.go +++ b/pkg/client/generated/client_with_response.go @@ -2420,6 +2420,182 @@ func (c *Client) PublishCardDAVPersonWithResponse(ctx context.Context, options * } } +// ListCardDAVRuns List CardDAV synchronization runs +func (c *Client) ListCardDAVRunsWithResponse(ctx context.Context, options *ListCardDAVRunsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListCardDAVRunsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/carddav/runs", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/carddav/runs") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ListCardDAVRunsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ListCardDAVRunsResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListCardDAVRunsResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(ListCardDAVRunsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListCardDAVRunsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(ListCardDAVRunsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListCardDAVRunsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(ListCardDAVRunsErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListCardDAVRunsErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetCardDAVStatus Get CardDAV synchronization status +func (c *Client) GetCardDAVStatusWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetCardDAVStatusResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/carddav/status", + Method: "GET", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/carddav/status") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetCardDAVStatusResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetCardDAVStatusResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetCardDAVStatusResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 500: + out.JSON500 = new(GetCardDAVStatusErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetCardDAVStatusErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetCardDAVStatusErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetCardDAVStatusErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers503 = &GetCardDAVStatusResp503Headers{ + RetryAfter: resp.Headers.Get("Retry-After"), + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + // SyncCardDAV Trigger CardDAV synchronization func (c *Client) SyncCardDAVWithResponse(ctx context.Context, options *SyncCardDAVRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SyncCardDAVResp, error) { var err error @@ -10052,11 +10228,11 @@ func (c *Client) GetVisualAttachmentStatusWithResponse(ctx context.Context, reqE } } -// ListOrganizations List organizations -func (c *Client) ListOrganizationsWithResponse(ctx context.Context, options *ListOrganizationsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListOrganizationsResp, error) { +// ListOperationRuns List normalized operation history +func (c *Client) ListOperationRunsWithResponse(ctx context.Context, options *ListOperationRunsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListOperationRunsResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/operations/runs", Method: "GET", Options: options, } @@ -10066,12 +10242,12 @@ func (c *Client) ListOrganizationsWithResponse(ctx context.Context, options *Lis return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/operations/runs") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &ListOrganizationsResp{ + out := &ListOperationRunsResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -10079,7 +10255,7 @@ func (c *Client) ListOrganizationsWithResponse(ctx context.Context, options *Lis switch resp.StatusCode { case 200: - out.JSON200 = new(ListOrganizationsResponse) + out.JSON200 = new(ListOperationRunsResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -10087,7 +10263,7 @@ func (c *Client) ListOrganizationsWithResponse(ctx context.Context, options *Lis StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListOrganizationsResponse", + TargetType: "ListOperationRunsResponse", Body: bodyBytes, Err: err, } @@ -10095,7 +10271,7 @@ func (c *Client) ListOrganizationsWithResponse(ctx context.Context, options *Lis } return out, nil case 400: - out.JSON400 = new(ListOrganizationsErrorResponse) + out.JSON400 = new(ListOperationRunsErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { @@ -10103,7 +10279,23 @@ func (c *Client) ListOrganizationsWithResponse(ctx context.Context, options *Lis StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListOrganizationsErrorResponse", + TargetType: "ListOperationRunsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(ListOperationRunsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListOperationRunsErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -10111,7 +10303,7 @@ func (c *Client) ListOrganizationsWithResponse(ctx context.Context, options *Lis } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(ListOrganizationsErrorResponseJSON) + out.JSON503 = new(ListOperationRunsErrorResponseJSON503) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -10119,7 +10311,7 @@ func (c *Client) ListOrganizationsWithResponse(ctx context.Context, options *Lis StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListOrganizationsErrorResponseJSON", + TargetType: "ListOperationRunsErrorResponseJSON503", Body: bodyBytes, Err: err, } @@ -10131,14 +10323,13 @@ func (c *Client) ListOrganizationsWithResponse(ctx context.Context, options *Lis } } -// CreateOrganization Create an organization -func (c *Client) CreateOrganizationWithResponse(ctx context.Context, options *CreateOrganizationRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreateOrganizationResp, error) { +// GetOperationRun Get one normalized operation run +func (c *Client) GetOperationRunWithResponse(ctx context.Context, options *GetOperationRunRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetOperationRunResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations", - Method: "POST", - Options: options, - ContentType: "application/json", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/operations/runs/{id}", + Method: "GET", + Options: options, } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -10146,40 +10337,36 @@ func (c *Client) CreateOrganizationWithResponse(ctx context.Context, options *Cr return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/operations/runs/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &CreateOrganizationResp{ + out := &GetOperationRunResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, } switch resp.StatusCode { - case 201: - out.JSON201 = new(CreateOrganizationResponse) + case 200: + out.JSON200 = new(GetOperationRunResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON201); err != nil { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { return out, &runtime.ResponseDecodeError{ StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "CreateOrganizationResponse", + TargetType: "GetOperationRunResponse", Body: bodyBytes, Err: err, } } } - out.Headers201 = &CreateOrganizationResp201Headers{ - ETag: resp.Headers.Get("ETag"), - Location: resp.Headers.Get("Location"), - } return out, nil case 400: - out.JSON400 = new(CreateOrganizationErrorResponse) + out.JSON400 = new(GetOperationRunErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { @@ -10187,7 +10374,39 @@ func (c *Client) CreateOrganizationWithResponse(ctx context.Context, options *Cr StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "CreateOrganizationErrorResponse", + TargetType: "GetOperationRunErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetOperationRunErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetOperationRunErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(GetOperationRunErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetOperationRunErrorResponseJSON500", Body: bodyBytes, Err: err, } @@ -10195,7 +10414,7 @@ func (c *Client) CreateOrganizationWithResponse(ctx context.Context, options *Cr } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(CreateOrganizationErrorResponseJSON) + out.JSON503 = new(GetOperationRunErrorResponseJSON503) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -10203,7 +10422,7 @@ func (c *Client) CreateOrganizationWithResponse(ctx context.Context, options *Cr StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "CreateOrganizationErrorResponseJSON", + TargetType: "GetOperationRunErrorResponseJSON503", Body: bodyBytes, Err: err, } @@ -10215,13 +10434,12 @@ func (c *Client) CreateOrganizationWithResponse(ctx context.Context, options *Cr } } -// DeleteOrganization Delete an organization without employment records -func (c *Client) DeleteOrganizationWithResponse(ctx context.Context, options *DeleteOrganizationRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteOrganizationResp, error) { +// GetOperationStatus Get normalized operation lane status +func (c *Client) GetOperationStatusWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetOperationStatusResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations/{id}", - Method: "DELETE", - Options: options, + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/operations/status", + Method: "GET", } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -10229,46 +10447,258 @@ func (c *Client) DeleteOrganizationWithResponse(ctx context.Context, options *De return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations/{id}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/operations/status") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &DeleteOrganizationResp{ + out := &GetOperationStatusResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, } switch resp.StatusCode { - case 204: - return out, nil - case 400: - out.JSON400 = new(DeleteOrganizationErrorResponse) + case 200: + out.JSON200 = new(GetOperationStatusResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { return out, &runtime.ResponseDecodeError{ StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "DeleteOrganizationErrorResponse", + TargetType: "GetOperationStatusResponse", Body: bodyBytes, Err: err, } } } + return out, nil + case 500: return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) - case 404: - out.JSON404 = new(DeleteOrganizationErrorResponseJSON) - bodyBytes := resp.Content - if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { - return out, &runtime.ResponseDecodeError{ - StatusCode: resp.StatusCode, - ContentType: resp.Headers.Get("Content-Type"), - ContentLength: len(bodyBytes), - TargetType: "DeleteOrganizationErrorResponseJSON", + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// ListOrganizations List organizations +func (c *Client) ListOrganizationsWithResponse(ctx context.Context, options *ListOrganizationsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListOrganizationsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ListOrganizationsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ListOrganizationsResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListOrganizationsResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(ListOrganizationsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListOrganizationsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(ListOrganizationsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListOrganizationsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// CreateOrganization Create an organization +func (c *Client) CreateOrganizationWithResponse(ctx context.Context, options *CreateOrganizationRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreateOrganizationResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &CreateOrganizationResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 201: + out.JSON201 = new(CreateOrganizationResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON201); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CreateOrganizationResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers201 = &CreateOrganizationResp201Headers{ + ETag: resp.Headers.Get("ETag"), + Location: resp.Headers.Get("Location"), + } + return out, nil + case 400: + out.JSON400 = new(CreateOrganizationErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CreateOrganizationErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(CreateOrganizationErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CreateOrganizationErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// DeleteOrganization Delete an organization without employment records +func (c *Client) DeleteOrganizationWithResponse(ctx context.Context, options *DeleteOrganizationRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteOrganizationResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/organizations/{id}", + Method: "DELETE", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/organizations/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &DeleteOrganizationResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 204: + return out, nil + case 400: + out.JSON400 = new(DeleteOrganizationErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteOrganizationErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(DeleteOrganizationErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteOrganizationErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -12277,14 +12707,13 @@ func (c *Client) CreatePersonWithResponse(ctx context.Context, options *CreatePe } } -// SearchPeople Search durable people semantically -func (c *Client) SearchPeopleWithResponse(ctx context.Context, options *SearchPeopleRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchPeopleResp, error) { +// ListDirectoryPeople Query durable people for the Directory +func (c *Client) ListDirectoryPeopleWithResponse(ctx context.Context, options *ListDirectoryPeopleRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListDirectoryPeopleResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/search", - Method: "POST", - Options: options, - ContentType: "application/json", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/directory", + Method: "GET", + Options: options, } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -12292,12 +12721,12 @@ func (c *Client) SearchPeopleWithResponse(ctx context.Context, options *SearchPe return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/search") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/directory") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &SearchPeopleResp{ + out := &ListDirectoryPeopleResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -12305,7 +12734,7 @@ func (c *Client) SearchPeopleWithResponse(ctx context.Context, options *SearchPe switch resp.StatusCode { case 200: - out.JSON200 = new(SearchPeopleResponse) + out.JSON200 = new(ListDirectoryPeopleResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -12313,15 +12742,31 @@ func (c *Client) SearchPeopleWithResponse(ctx context.Context, options *SearchPe StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "SearchPeopleResponse", + TargetType: "ListDirectoryPeopleResponse", Body: bodyBytes, Err: err, } } } return out, nil + case 400: + out.JSON400 = new(ListDirectoryPeopleErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListDirectoryPeopleErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(SearchPeopleErrorResponse) + out.JSON503 = new(ListDirectoryPeopleErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -12329,7 +12774,7 @@ func (c *Client) SearchPeopleWithResponse(ctx context.Context, options *SearchPe StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "SearchPeopleErrorResponse", + TargetType: "ListDirectoryPeopleErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -12341,12 +12786,76 @@ func (c *Client) SearchPeopleWithResponse(ctx context.Context, options *SearchPe } } -// DeletePerson Delete a durable person profile -func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeletePersonResp, error) { +// SearchPeople Search durable people semantically +func (c *Client) SearchPeopleWithResponse(ctx context.Context, options *SearchPeopleRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchPeopleResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}", - Method: "DELETE", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/search", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/search") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &SearchPeopleResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(SearchPeopleResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SearchPeopleResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 503: + out.JSON503 = new(SearchPeopleErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SearchPeopleErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// DeletePerson Delete a durable person profile +func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeletePersonResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}", + Method: "DELETE", Options: options, } @@ -14466,6 +14975,101 @@ func (c *Client) ListPersonMergesWithResponse(ctx context.Context, options *List } } +// GetPersonNetwork Get a bounded curated person network +func (c *Client) GetPersonNetworkWithResponse(ctx context.Context, options *GetPersonNetworkRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonNetworkResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/network", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/network") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetPersonNetworkResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetPersonNetworkResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonNetworkResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(GetPersonNetworkErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonNetworkErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetPersonNetworkErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonNetworkErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetPersonNetworkErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonNetworkErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + // AppendPersonNote Append to a person's notes func (c *Client) AppendPersonNoteWithResponse(ctx context.Context, options *AppendPersonNoteRequestOptions, reqEditors ...runtime.RequestEditorFn) (*AppendPersonNoteResp, error) { var err error @@ -18262,7 +18866,8 @@ func (c *Client) GetSettingsWithResponse(ctx context.Context, reqEditors ...runt } } out.Headers200 = &GetSettingsResp200Headers{ - ETag: resp.Headers.Get("ETag"), + CredentialETag: resp.Headers.Get("Credential-ETag"), + ETag: resp.Headers.Get("ETag"), } return out, nil case 500: @@ -18315,7 +18920,8 @@ func (c *Client) PatchSettingsWithResponse(ctx context.Context, options *PatchSe } } out.Headers200 = &PatchSettingsResp200Headers{ - ETag: resp.Headers.Get("ETag"), + CredentialETag: resp.Headers.Get("Credential-ETag"), + ETag: resp.Headers.Get("ETag"), } return out, nil case 400: @@ -18403,6 +19009,414 @@ func (c *Client) PatchSettingsWithResponse(ctx context.Context, options *PatchSe } } +// PutSettingsPersonEnrichmentProvider Create or update one named person-enrichment provider +func (c *Client) PutSettingsPersonEnrichmentProviderWithResponse(ctx context.Context, options *PutSettingsPersonEnrichmentProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPersonEnrichmentProviderResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/person-enrichment/providers/{name}", + Method: "PUT", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/person-enrichment/providers/{name}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &PutSettingsPersonEnrichmentProviderResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(PutSettingsPersonEnrichmentProviderResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPersonEnrichmentProviderResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &PutSettingsPersonEnrichmentProviderResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(PutSettingsPersonEnrichmentProviderErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPersonEnrichmentProviderErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(PutSettingsPersonEnrichmentProviderErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPersonEnrichmentProviderErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(PutSettingsPersonEnrichmentProviderErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPersonEnrichmentProviderErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(PutSettingsPersonEnrichmentProviderErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPersonEnrichmentProviderErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 422: + out.JSON422 = new(PutSettingsPersonEnrichmentProviderErrorResponseJSON422) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPersonEnrichmentProviderErrorResponseJSON422", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(PutSettingsPersonEnrichmentProviderErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPersonEnrichmentProviderErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// DeleteSettingsProviderCredential Clear a stored provider credential +func (c *Client) DeleteSettingsProviderCredentialWithResponse(ctx context.Context, options *DeleteSettingsProviderCredentialRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteSettingsProviderCredentialResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/provider-credentials/{credential_id}", + Method: "DELETE", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/provider-credentials/{credential_id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &DeleteSettingsProviderCredentialResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(DeleteSettingsProviderCredentialResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsProviderCredentialResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &DeleteSettingsProviderCredentialResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(DeleteSettingsProviderCredentialErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsProviderCredentialErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(DeleteSettingsProviderCredentialErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsProviderCredentialErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(DeleteSettingsProviderCredentialErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsProviderCredentialErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 422: + out.JSON422 = new(DeleteSettingsProviderCredentialErrorResponseJSON422) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsProviderCredentialErrorResponseJSON422", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(DeleteSettingsProviderCredentialErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsProviderCredentialErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// PutSettingsProviderCredential Set a write-only provider credential +func (c *Client) PutSettingsProviderCredentialWithResponse(ctx context.Context, options *PutSettingsProviderCredentialRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsProviderCredentialResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/provider-credentials/{credential_id}", + Method: "PUT", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/provider-credentials/{credential_id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &PutSettingsProviderCredentialResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(PutSettingsProviderCredentialResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsProviderCredentialResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &PutSettingsProviderCredentialResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(PutSettingsProviderCredentialErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsProviderCredentialErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(PutSettingsProviderCredentialErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsProviderCredentialErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(PutSettingsProviderCredentialErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsProviderCredentialErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 422: + out.JSON422 = new(PutSettingsProviderCredentialErrorResponseJSON422) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsProviderCredentialErrorResponseJSON422", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(PutSettingsProviderCredentialErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsProviderCredentialErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + // ListSourceStatus List source sync status func (c *Client) ListSourceStatusWithResponse(ctx context.Context, options *ListSourceStatusRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListSourceStatusResp, error) { var err error diff --git a/pkg/client/generated/enums.go b/pkg/client/generated/enums.go index 699b502da..9c18fd65e 100644 --- a/pkg/client/generated/enums.go +++ b/pkg/client/generated/enums.go @@ -81,6 +81,215 @@ func (c CandidateClassification) Validate() error { } } +type CardDAVConflictDetailResponseResolution string + +const ( + CardDAVConflictDetailResponseResolutionKeepLocal CardDAVConflictDetailResponseResolution = "keep_local" + CardDAVConflictDetailResponseResolutionKeepRemote CardDAVConflictDetailResponseResolution = "keep_remote" +) + +// Validate checks if the CardDAVConflictDetailResponseResolution value is valid +func (c CardDAVConflictDetailResponseResolution) Validate() error { + switch c { + case CardDAVConflictDetailResponseResolutionKeepLocal, CardDAVConflictDetailResponseResolutionKeepRemote: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVConflictDetailResponseResolution value, got: %v", c)) + } +} + +type CardDAVConflictDetailResponseStatus string + +const ( + CardDAVConflictDetailResponseStatusResolved CardDAVConflictDetailResponseStatus = "resolved" + CardDAVConflictDetailResponseStatusUnresolved CardDAVConflictDetailResponseStatus = "unresolved" +) + +// Validate checks if the CardDAVConflictDetailResponseStatus value is valid +func (c CardDAVConflictDetailResponseStatus) Validate() error { + switch c { + case CardDAVConflictDetailResponseStatusResolved, CardDAVConflictDetailResponseStatusUnresolved: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVConflictDetailResponseStatus value, got: %v", c)) + } +} + +type CardDAVConflictDetailResponseAllowedResolutions string + +const ( + CardDAVConflictDetailResponseAllowedResolutionsKeepLocal CardDAVConflictDetailResponseAllowedResolutions = "keep_local" + CardDAVConflictDetailResponseAllowedResolutionsKeepRemote CardDAVConflictDetailResponseAllowedResolutions = "keep_remote" +) + +// Validate checks if the CardDAVConflictDetailResponseAllowedResolutions value is valid +func (c CardDAVConflictDetailResponseAllowedResolutions) Validate() error { + switch c { + case CardDAVConflictDetailResponseAllowedResolutionsKeepLocal, CardDAVConflictDetailResponseAllowedResolutionsKeepRemote: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVConflictDetailResponseAllowedResolutions value, got: %v", c)) + } +} + +type CardDAVConflictResolutionResponseResolution string + +const ( + CardDAVConflictResolutionResponseResolutionKeepLocal CardDAVConflictResolutionResponseResolution = "keep_local" + CardDAVConflictResolutionResponseResolutionKeepRemote CardDAVConflictResolutionResponseResolution = "keep_remote" +) + +// Validate checks if the CardDAVConflictResolutionResponseResolution value is valid +func (c CardDAVConflictResolutionResponseResolution) Validate() error { + switch c { + case CardDAVConflictResolutionResponseResolutionKeepLocal, CardDAVConflictResolutionResponseResolutionKeepRemote: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVConflictResolutionResponseResolution value, got: %v", c)) + } +} + +type CardDAVConflictResolutionResponseStatus string + +const ( + CardDAVConflictResolutionResponseStatusResolved CardDAVConflictResolutionResponseStatus = "resolved" +) + +// Validate checks if the CardDAVConflictResolutionResponseStatus value is valid +func (c CardDAVConflictResolutionResponseStatus) Validate() error { + switch c { + case CardDAVConflictResolutionResponseStatusResolved: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVConflictResolutionResponseStatus value, got: %v", c)) + } +} + +type CardDAVConflictResponseLocalState string + +const ( + CardDAVConflictResponseLocalStateDeleted CardDAVConflictResponseLocalState = "deleted" + CardDAVConflictResponseLocalStatePresent CardDAVConflictResponseLocalState = "present" + CardDAVConflictResponseLocalStateUnavailable CardDAVConflictResponseLocalState = "unavailable" +) + +// Validate checks if the CardDAVConflictResponseLocalState value is valid +func (c CardDAVConflictResponseLocalState) Validate() error { + switch c { + case CardDAVConflictResponseLocalStateDeleted, CardDAVConflictResponseLocalStatePresent, CardDAVConflictResponseLocalStateUnavailable: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVConflictResponseLocalState value, got: %v", c)) + } +} + +type CardDAVConflictResponseRemoteState string + +const ( + CardDAVConflictResponseRemoteStateDeleted CardDAVConflictResponseRemoteState = "deleted" + CardDAVConflictResponseRemoteStatePresent CardDAVConflictResponseRemoteState = "present" + CardDAVConflictResponseRemoteStateUnavailable CardDAVConflictResponseRemoteState = "unavailable" +) + +// Validate checks if the CardDAVConflictResponseRemoteState value is valid +func (c CardDAVConflictResponseRemoteState) Validate() error { + switch c { + case CardDAVConflictResponseRemoteStateDeleted, CardDAVConflictResponseRemoteStatePresent, CardDAVConflictResponseRemoteStateUnavailable: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVConflictResponseRemoteState value, got: %v", c)) + } +} + +type CardDAVConflictResponseStatus string + +const ( + CardDAVConflictResponseStatusResolved CardDAVConflictResponseStatus = "resolved" + CardDAVConflictResponseStatusUnresolved CardDAVConflictResponseStatus = "unresolved" +) + +// Validate checks if the CardDAVConflictResponseStatus value is valid +func (c CardDAVConflictResponseStatus) Validate() error { + switch c { + case CardDAVConflictResponseStatusResolved, CardDAVConflictResponseStatusUnresolved: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVConflictResponseStatus value, got: %v", c)) + } +} + +type CardDAVConflictResponseAllowedResolutions string + +const ( + CardDAVConflictResponseAllowedResolutionsKeepLocal CardDAVConflictResponseAllowedResolutions = "keep_local" + CardDAVConflictResponseAllowedResolutionsKeepRemote CardDAVConflictResponseAllowedResolutions = "keep_remote" +) + +// Validate checks if the CardDAVConflictResponseAllowedResolutions value is valid +func (c CardDAVConflictResponseAllowedResolutions) Validate() error { + switch c { + case CardDAVConflictResponseAllowedResolutionsKeepLocal, CardDAVConflictResponseAllowedResolutionsKeepRemote: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVConflictResponseAllowedResolutions value, got: %v", c)) + } +} + +type CardDAVContactSummaryResponseState string + +const ( + CardDAVContactSummaryResponseStateDeleted CardDAVContactSummaryResponseState = "deleted" + CardDAVContactSummaryResponseStatePresent CardDAVContactSummaryResponseState = "present" + CardDAVContactSummaryResponseStateUnavailable CardDAVContactSummaryResponseState = "unavailable" +) + +// Validate checks if the CardDAVContactSummaryResponseState value is valid +func (c CardDAVContactSummaryResponseState) Validate() error { + switch c { + case CardDAVContactSummaryResponseStateDeleted, CardDAVContactSummaryResponseStatePresent, CardDAVContactSummaryResponseStateUnavailable: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVContactSummaryResponseState value, got: %v", c)) + } +} + +type CardDAVPublicationResponsePendingOperation string + +const ( + CardDAVPublicationResponsePendingOperationCreate CardDAVPublicationResponsePendingOperation = "create" + CardDAVPublicationResponsePendingOperationDelete CardDAVPublicationResponsePendingOperation = "delete" + CardDAVPublicationResponsePendingOperationUpdate CardDAVPublicationResponsePendingOperation = "update" +) + +// Validate checks if the CardDAVPublicationResponsePendingOperation value is valid +func (c CardDAVPublicationResponsePendingOperation) Validate() error { + switch c { + case CardDAVPublicationResponsePendingOperationCreate, CardDAVPublicationResponsePendingOperationDelete, CardDAVPublicationResponsePendingOperationUpdate: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVPublicationResponsePendingOperation value, got: %v", c)) + } +} + +type CardDAVPublicationResponseState string + +const ( + CardDAVPublicationResponseStateConflict CardDAVPublicationResponseState = "conflict" + CardDAVPublicationResponseStatePending CardDAVPublicationResponseState = "pending" + CardDAVPublicationResponseStatePublished CardDAVPublicationResponseState = "published" + CardDAVPublicationResponseStateUnpublished CardDAVPublicationResponseState = "unpublished" +) + +// Validate checks if the CardDAVPublicationResponseState value is valid +func (c CardDAVPublicationResponseState) Validate() error { + switch c { + case CardDAVPublicationResponseStateConflict, CardDAVPublicationResponseStatePending, CardDAVPublicationResponseStatePublished, CardDAVPublicationResponseStateUnpublished: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVPublicationResponseState value, got: %v", c)) + } +} + type CardDAVResolveRequestChoice string const ( @@ -98,6 +307,86 @@ func (c CardDAVResolveRequestChoice) Validate() error { } } +type CardDAVRunResponseErrorCode string + +const ( + AuthenticationFailed CardDAVRunResponseErrorCode = "authentication_failed" + Cancelled CardDAVRunResponseErrorCode = "cancelled" + DaemonRestarted CardDAVRunResponseErrorCode = "daemon_restarted" + RetryAfter CardDAVRunResponseErrorCode = "retry_after" + SafetyLimit CardDAVRunResponseErrorCode = "safety_limit" + SyncFailed CardDAVRunResponseErrorCode = "sync_failed" + UnsafeErrorRedacted CardDAVRunResponseErrorCode = "unsafe_error_redacted" + UpstreamFailed CardDAVRunResponseErrorCode = "upstream_failed" +) + +// Validate checks if the CardDAVRunResponseErrorCode value is valid +func (c CardDAVRunResponseErrorCode) Validate() error { + switch c { + case AuthenticationFailed, Cancelled, DaemonRestarted, RetryAfter, SafetyLimit, SyncFailed, UnsafeErrorRedacted, UpstreamFailed: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVRunResponseErrorCode value, got: %v", c)) + } +} + +type CardDAVRunResponseState string + +const ( + CardDAVRunResponseStateCancelled CardDAVRunResponseState = "cancelled" + Failed CardDAVRunResponseState = "failed" + Partial CardDAVRunResponseState = "partial" + Running CardDAVRunResponseState = "running" + Succeeded CardDAVRunResponseState = "succeeded" +) + +// Validate checks if the CardDAVRunResponseState value is valid +func (c CardDAVRunResponseState) Validate() error { + switch c { + case CardDAVRunResponseStateCancelled, Failed, Partial, Running, Succeeded: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVRunResponseState value, got: %v", c)) + } +} + +type CardDAVRunResponseTrigger string + +const ( + Manual CardDAVRunResponseTrigger = "manual" + Scheduled CardDAVRunResponseTrigger = "scheduled" +) + +// Validate checks if the CardDAVRunResponseTrigger value is valid +func (c CardDAVRunResponseTrigger) Validate() error { + switch c { + case Manual, Scheduled: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVRunResponseTrigger value, got: %v", c)) + } +} + +type CardDAVStatusResponseRepairReason string + +const ( + AccountMissing CardDAVStatusResponseRepairReason = "account_missing" + CredentialMismatch CardDAVStatusResponseRepairReason = "credential_mismatch" + CredentialMissing CardDAVStatusResponseRepairReason = "credential_missing" + CredentialUnavailable CardDAVStatusResponseRepairReason = "credential_unavailable" + RuntimeUnavailable CardDAVStatusResponseRepairReason = "runtime_unavailable" +) + +// Validate checks if the CardDAVStatusResponseRepairReason value is valid +func (c CardDAVStatusResponseRepairReason) Validate() error { + switch c { + case AccountMissing, CredentialMismatch, CredentialMissing, CredentialUnavailable, RuntimeUnavailable: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CardDAVStatusResponseRepairReason value, got: %v", c)) + } +} + type CreateAttributeDefinitionRequestCardinality string const ( @@ -591,6 +880,428 @@ func (m MeetingImportResponseStatus) Validate() error { } } +type NetworkEdgeKind string + +const ( + NetworkEdgeKindEmployment NetworkEdgeKind = "employment" + Relationship NetworkEdgeKind = "relationship" +) + +// Validate checks if the NetworkEdgeKind value is valid +func (n NetworkEdgeKind) Validate() error { + switch n { + case NetworkEdgeKindEmployment, Relationship: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid NetworkEdgeKind value, got: %v", n)) + } +} + +type NetworkNodeKind string + +const ( + NetworkNodeKindOrganization NetworkNodeKind = "organization" + NetworkNodeKindPerson NetworkNodeKind = "person" +) + +// Validate checks if the NetworkNodeKind value is valid +func (n NetworkNodeKind) Validate() error { + switch n { + case NetworkNodeKindOrganization, NetworkNodeKindPerson: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid NetworkNodeKind value, got: %v", n)) + } +} + +type OperationLaneStatusHistoryAvailability string + +const ( + Available OperationLaneStatusHistoryAvailability = "available" + Unavailable OperationLaneStatusHistoryAvailability = "unavailable" +) + +// Validate checks if the OperationLaneStatusHistoryAvailability value is valid +func (o OperationLaneStatusHistoryAvailability) Validate() error { + switch o { + case Available, Unavailable: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationLaneStatusHistoryAvailability value, got: %v", o)) + } +} + +type OperationLaneStatusKind string + +const ( + CarddavSync OperationLaneStatusKind = "carddav_sync" + DocumentEmbedding OperationLaneStatusKind = "document_embedding" + DocumentExtraction OperationLaneStatusKind = "document_extraction" + MessageEmbedding OperationLaneStatusKind = "message_embedding" + PersonEmbedding OperationLaneStatusKind = "person_embedding" + PersonEnrichment OperationLaneStatusKind = "person_enrichment" + PersonSweep OperationLaneStatusKind = "person_sweep" + SourceSync OperationLaneStatusKind = "source_sync" + VisualEmbedding OperationLaneStatusKind = "visual_embedding" +) + +// Validate checks if the OperationLaneStatusKind value is valid +func (o OperationLaneStatusKind) Validate() error { + switch o { + case CarddavSync, DocumentEmbedding, DocumentExtraction, MessageEmbedding, PersonEmbedding, PersonEnrichment, PersonSweep, SourceSync, VisualEmbedding: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationLaneStatusKind value, got: %v", o)) + } +} + +type OperationLaneStatusLane string + +const ( + Contacts OperationLaneStatusLane = "contacts" + Documents OperationLaneStatusLane = "documents" + Messages OperationLaneStatusLane = "messages" + PersonFacts OperationLaneStatusLane = "person_facts" + VisualAttachments OperationLaneStatusLane = "visual_attachments" +) + +// Validate checks if the OperationLaneStatusLane value is valid +func (o OperationLaneStatusLane) Validate() error { + switch o { + case Contacts, Documents, Messages, PersonFacts, VisualAttachments: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationLaneStatusLane value, got: %v", o)) + } +} + +type OperationLaneStatusRelatedStatus string + +const ( + GetCardDAVStatus OperationLaneStatusRelatedStatus = "getCardDAVStatus" + GetDocumentIndexStatus OperationLaneStatusRelatedStatus = "getDocumentIndexStatus" + GetDocumentVectorStatus OperationLaneStatusRelatedStatus = "getDocumentVectorStatus" + GetVisualAttachmentStatus OperationLaneStatusRelatedStatus = "getVisualAttachmentStatus" + ListSourceStatus OperationLaneStatusRelatedStatus = "listSourceStatus" +) + +// Validate checks if the OperationLaneStatusRelatedStatus value is valid +func (o OperationLaneStatusRelatedStatus) Validate() error { + switch o { + case GetCardDAVStatus, GetDocumentIndexStatus, GetDocumentVectorStatus, GetVisualAttachmentStatus, ListSourceStatus: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationLaneStatusRelatedStatus value, got: %v", o)) + } +} + +type OperationLaneStatusSupportedActions string + +const ( + OperationLaneStatusSupportedActionsCarddavSync OperationLaneStatusSupportedActions = "carddav_sync" + VisualBuild OperationLaneStatusSupportedActions = "visual_build" + VisualResume OperationLaneStatusSupportedActions = "visual_resume" +) + +// Validate checks if the OperationLaneStatusSupportedActions value is valid +func (o OperationLaneStatusSupportedActions) Validate() error { + switch o { + case OperationLaneStatusSupportedActionsCarddavSync, VisualBuild, VisualResume: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationLaneStatusSupportedActions value, got: %v", o)) + } +} + +type OperationPublicCounterName string + +const ( + Added OperationPublicCounterName = "added" + Attempted OperationPublicCounterName = "attempted" + Books OperationPublicCounterName = "books" + Created OperationPublicCounterName = "created" + ItemErrors OperationPublicCounterName = "item_errors" + OperationPublicCounterNameFailed OperationPublicCounterName = "failed" + OperationPublicCounterNameSucceeded OperationPublicCounterName = "succeeded" + Processed OperationPublicCounterName = "processed" + ProjectedWrites OperationPublicCounterName = "projected_writes" + Removed OperationPublicCounterName = "removed" + Updated OperationPublicCounterName = "updated" +) + +// Validate checks if the OperationPublicCounterName value is valid +func (o OperationPublicCounterName) Validate() error { + switch o { + case Added, Attempted, Books, Created, ItemErrors, OperationPublicCounterNameFailed, OperationPublicCounterNameSucceeded, Processed, ProjectedWrites, Removed, Updated: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationPublicCounterName value, got: %v", o)) + } +} + +type OperationPublicCounterUnit string + +const ( + OperationPublicCounterUnitBooks OperationPublicCounterUnit = "books" + OperationPublicCounterUnitContacts OperationPublicCounterUnit = "contacts" + OperationPublicCounterUnitMessages OperationPublicCounterUnit = "messages" + People OperationPublicCounterUnit = "people" + Writes OperationPublicCounterUnit = "writes" +) + +// Validate checks if the OperationPublicCounterUnit value is valid +func (o OperationPublicCounterUnit) Validate() error { + switch o { + case OperationPublicCounterUnitBooks, OperationPublicCounterUnitContacts, OperationPublicCounterUnitMessages, People, Writes: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationPublicCounterUnit value, got: %v", o)) + } +} + +type OperationPublicErrorCode string + +const ( + ArchiveGap OperationPublicErrorCode = "archive_gap" + Budget OperationPublicErrorCode = "budget" + CarddavSyncFailed OperationPublicErrorCode = "carddav_sync_failed" + Internal OperationPublicErrorCode = "internal" + InvalidOutput OperationPublicErrorCode = "invalid_output" + LeaseLost OperationPublicErrorCode = "lease_lost" + OperationPublicErrorCodeAuthenticationFailed OperationPublicErrorCode = "authentication_failed" + OperationPublicErrorCodeCancelled OperationPublicErrorCode = "cancelled" + OperationPublicErrorCodeDaemonRestarted OperationPublicErrorCode = "daemon_restarted" + OperationPublicErrorCodeRetryAfter OperationPublicErrorCode = "retry_after" + OperationPublicErrorCodeSafetyLimit OperationPublicErrorCode = "safety_limit" + OperationPublicErrorCodeSyncFailed OperationPublicErrorCode = "sync_failed" + OperationPublicErrorCodeUnsafeErrorRedacted OperationPublicErrorCode = "unsafe_error_redacted" + OperationPublicErrorCodeUpstreamFailed OperationPublicErrorCode = "upstream_failed" + PersonSweepFailed OperationPublicErrorCode = "person_sweep_failed" + Policy OperationPublicErrorCode = "policy" + ProviderHTTP OperationPublicErrorCode = "provider_http" + RateLimited OperationPublicErrorCode = "rate_limited" + SourceSyncFailed OperationPublicErrorCode = "source_sync_failed" + Timeout OperationPublicErrorCode = "timeout" +) + +// Validate checks if the OperationPublicErrorCode value is valid +func (o OperationPublicErrorCode) Validate() error { + switch o { + case ArchiveGap, Budget, CarddavSyncFailed, Internal, InvalidOutput, LeaseLost, OperationPublicErrorCodeAuthenticationFailed, OperationPublicErrorCodeCancelled, OperationPublicErrorCodeDaemonRestarted, OperationPublicErrorCodeRetryAfter, OperationPublicErrorCodeSafetyLimit, OperationPublicErrorCodeSyncFailed, OperationPublicErrorCodeUnsafeErrorRedacted, OperationPublicErrorCodeUpstreamFailed, PersonSweepFailed, Policy, ProviderHTTP, RateLimited, SourceSyncFailed, Timeout: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationPublicErrorCode value, got: %v", o)) + } +} + +type OperationRunDetailKind string + +const ( + OperationRunDetailKindCarddavSync OperationRunDetailKind = "carddav_sync" + OperationRunDetailKindDocumentEmbedding OperationRunDetailKind = "document_embedding" + OperationRunDetailKindDocumentExtraction OperationRunDetailKind = "document_extraction" + OperationRunDetailKindMessageEmbedding OperationRunDetailKind = "message_embedding" + OperationRunDetailKindPersonEmbedding OperationRunDetailKind = "person_embedding" + OperationRunDetailKindPersonEnrichment OperationRunDetailKind = "person_enrichment" + OperationRunDetailKindPersonSweep OperationRunDetailKind = "person_sweep" + OperationRunDetailKindSourceSync OperationRunDetailKind = "source_sync" + OperationRunDetailKindVisualEmbedding OperationRunDetailKind = "visual_embedding" +) + +// Validate checks if the OperationRunDetailKind value is valid +func (o OperationRunDetailKind) Validate() error { + switch o { + case OperationRunDetailKindCarddavSync, OperationRunDetailKindDocumentEmbedding, OperationRunDetailKindDocumentExtraction, OperationRunDetailKindMessageEmbedding, OperationRunDetailKindPersonEmbedding, OperationRunDetailKindPersonEnrichment, OperationRunDetailKindPersonSweep, OperationRunDetailKindSourceSync, OperationRunDetailKindVisualEmbedding: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationRunDetailKind value, got: %v", o)) + } +} + +type OperationRunDetailLane string + +const ( + OperationRunDetailLaneContacts OperationRunDetailLane = "contacts" + OperationRunDetailLaneDocuments OperationRunDetailLane = "documents" + OperationRunDetailLaneMessages OperationRunDetailLane = "messages" + OperationRunDetailLanePersonFacts OperationRunDetailLane = "person_facts" + OperationRunDetailLaneVisualAttachments OperationRunDetailLane = "visual_attachments" +) + +// Validate checks if the OperationRunDetailLane value is valid +func (o OperationRunDetailLane) Validate() error { + switch o { + case OperationRunDetailLaneContacts, OperationRunDetailLaneDocuments, OperationRunDetailLaneMessages, OperationRunDetailLanePersonFacts, OperationRunDetailLaneVisualAttachments: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationRunDetailLane value, got: %v", o)) + } +} + +type OperationRunDetailState string + +const ( + OperationRunDetailStateCancelled OperationRunDetailState = "cancelled" + OperationRunDetailStateFailed OperationRunDetailState = "failed" + OperationRunDetailStatePartial OperationRunDetailState = "partial" + OperationRunDetailStateRunning OperationRunDetailState = "running" + OperationRunDetailStateSucceeded OperationRunDetailState = "succeeded" + Queued OperationRunDetailState = "queued" +) + +// Validate checks if the OperationRunDetailState value is valid +func (o OperationRunDetailState) Validate() error { + switch o { + case OperationRunDetailStateCancelled, OperationRunDetailStateFailed, OperationRunDetailStatePartial, OperationRunDetailStateRunning, OperationRunDetailStateSucceeded, Queued: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationRunDetailState value, got: %v", o)) + } +} + +type OperationRunDetailTrigger string + +const ( + OperationRunDetailTriggerManual OperationRunDetailTrigger = "manual" + OperationRunDetailTriggerScheduled OperationRunDetailTrigger = "scheduled" +) + +// Validate checks if the OperationRunDetailTrigger value is valid +func (o OperationRunDetailTrigger) Validate() error { + switch o { + case OperationRunDetailTriggerManual, OperationRunDetailTriggerScheduled: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationRunDetailTrigger value, got: %v", o)) + } +} + +type OperationRunSummaryKind string + +const ( + OperationRunSummaryKindCarddavSync OperationRunSummaryKind = "carddav_sync" + OperationRunSummaryKindDocumentEmbedding OperationRunSummaryKind = "document_embedding" + OperationRunSummaryKindDocumentExtraction OperationRunSummaryKind = "document_extraction" + OperationRunSummaryKindMessageEmbedding OperationRunSummaryKind = "message_embedding" + OperationRunSummaryKindPersonEmbedding OperationRunSummaryKind = "person_embedding" + OperationRunSummaryKindPersonEnrichment OperationRunSummaryKind = "person_enrichment" + OperationRunSummaryKindPersonSweep OperationRunSummaryKind = "person_sweep" + OperationRunSummaryKindSourceSync OperationRunSummaryKind = "source_sync" + OperationRunSummaryKindVisualEmbedding OperationRunSummaryKind = "visual_embedding" +) + +// Validate checks if the OperationRunSummaryKind value is valid +func (o OperationRunSummaryKind) Validate() error { + switch o { + case OperationRunSummaryKindCarddavSync, OperationRunSummaryKindDocumentEmbedding, OperationRunSummaryKindDocumentExtraction, OperationRunSummaryKindMessageEmbedding, OperationRunSummaryKindPersonEmbedding, OperationRunSummaryKindPersonEnrichment, OperationRunSummaryKindPersonSweep, OperationRunSummaryKindSourceSync, OperationRunSummaryKindVisualEmbedding: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationRunSummaryKind value, got: %v", o)) + } +} + +type OperationRunSummaryLane string + +const ( + OperationRunSummaryLaneContacts OperationRunSummaryLane = "contacts" + OperationRunSummaryLaneDocuments OperationRunSummaryLane = "documents" + OperationRunSummaryLaneMessages OperationRunSummaryLane = "messages" + OperationRunSummaryLanePersonFacts OperationRunSummaryLane = "person_facts" + OperationRunSummaryLaneVisualAttachments OperationRunSummaryLane = "visual_attachments" +) + +// Validate checks if the OperationRunSummaryLane value is valid +func (o OperationRunSummaryLane) Validate() error { + switch o { + case OperationRunSummaryLaneContacts, OperationRunSummaryLaneDocuments, OperationRunSummaryLaneMessages, OperationRunSummaryLanePersonFacts, OperationRunSummaryLaneVisualAttachments: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationRunSummaryLane value, got: %v", o)) + } +} + +type OperationRunSummaryState string + +const ( + OperationRunSummaryStateCancelled OperationRunSummaryState = "cancelled" + OperationRunSummaryStateFailed OperationRunSummaryState = "failed" + OperationRunSummaryStatePartial OperationRunSummaryState = "partial" + OperationRunSummaryStateQueued OperationRunSummaryState = "queued" + OperationRunSummaryStateRunning OperationRunSummaryState = "running" + OperationRunSummaryStateSucceeded OperationRunSummaryState = "succeeded" +) + +// Validate checks if the OperationRunSummaryState value is valid +func (o OperationRunSummaryState) Validate() error { + switch o { + case OperationRunSummaryStateCancelled, OperationRunSummaryStateFailed, OperationRunSummaryStatePartial, OperationRunSummaryStateQueued, OperationRunSummaryStateRunning, OperationRunSummaryStateSucceeded: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationRunSummaryState value, got: %v", o)) + } +} + +type OperationRunSummaryTrigger string + +const ( + OperationRunSummaryTriggerManual OperationRunSummaryTrigger = "manual" + OperationRunSummaryTriggerScheduled OperationRunSummaryTrigger = "scheduled" +) + +// Validate checks if the OperationRunSummaryTrigger value is valid +func (o OperationRunSummaryTrigger) Validate() error { + switch o { + case OperationRunSummaryTriggerManual, OperationRunSummaryTriggerScheduled: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationRunSummaryTrigger value, got: %v", o)) + } +} + +type OperationUnavailableKindKind string + +const ( + OperationUnavailableKindKindCarddavSync OperationUnavailableKindKind = "carddav_sync" + OperationUnavailableKindKindDocumentEmbedding OperationUnavailableKindKind = "document_embedding" + OperationUnavailableKindKindDocumentExtraction OperationUnavailableKindKind = "document_extraction" + OperationUnavailableKindKindMessageEmbedding OperationUnavailableKindKind = "message_embedding" + OperationUnavailableKindKindPersonEmbedding OperationUnavailableKindKind = "person_embedding" + OperationUnavailableKindKindPersonEnrichment OperationUnavailableKindKind = "person_enrichment" + OperationUnavailableKindKindPersonSweep OperationUnavailableKindKind = "person_sweep" + OperationUnavailableKindKindSourceSync OperationUnavailableKindKind = "source_sync" + OperationUnavailableKindKindVisualEmbedding OperationUnavailableKindKind = "visual_embedding" +) + +// Validate checks if the OperationUnavailableKindKind value is valid +func (o OperationUnavailableKindKind) Validate() error { + switch o { + case OperationUnavailableKindKindCarddavSync, OperationUnavailableKindKindDocumentEmbedding, OperationUnavailableKindKindDocumentExtraction, OperationUnavailableKindKindMessageEmbedding, OperationUnavailableKindKindPersonEmbedding, OperationUnavailableKindKindPersonEnrichment, OperationUnavailableKindKindPersonSweep, OperationUnavailableKindKindSourceSync, OperationUnavailableKindKindVisualEmbedding: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationUnavailableKindKind value, got: %v", o)) + } +} + +type OperationUnavailableKindLane string + +const ( + OperationUnavailableKindLaneContacts OperationUnavailableKindLane = "contacts" + OperationUnavailableKindLaneDocuments OperationUnavailableKindLane = "documents" + OperationUnavailableKindLaneMessages OperationUnavailableKindLane = "messages" + OperationUnavailableKindLanePersonFacts OperationUnavailableKindLane = "person_facts" + OperationUnavailableKindLaneVisualAttachments OperationUnavailableKindLane = "visual_attachments" +) + +// Validate checks if the OperationUnavailableKindLane value is valid +func (o OperationUnavailableKindLane) Validate() error { + switch o { + case OperationUnavailableKindLaneContacts, OperationUnavailableKindLaneDocuments, OperationUnavailableKindLaneMessages, OperationUnavailableKindLanePersonFacts, OperationUnavailableKindLaneVisualAttachments: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid OperationUnavailableKindLane value, got: %v", o)) + } +} + type OrganizationAddressBodySource string const ( @@ -873,6 +1584,40 @@ func (p ParticipantCompletionHTTPRowKind) Validate() error { } } +type PersonEnrichmentProviderSettingKind string + +const ( + Exa PersonEnrichmentProviderSettingKind = "exa" + Sixtyfour PersonEnrichmentProviderSettingKind = "sixtyfour" +) + +// Validate checks if the PersonEnrichmentProviderSettingKind value is valid +func (p PersonEnrichmentProviderSettingKind) Validate() error { + switch p { + case Exa, Sixtyfour: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid PersonEnrichmentProviderSettingKind value, got: %v", p)) + } +} + +type PersonEnrichmentProviderUpdateKind string + +const ( + PersonEnrichmentProviderUpdateKindExa PersonEnrichmentProviderUpdateKind = "exa" + PersonEnrichmentProviderUpdateKindSixtyfour PersonEnrichmentProviderUpdateKind = "sixtyfour" +) + +// Validate checks if the PersonEnrichmentProviderUpdateKind value is valid +func (p PersonEnrichmentProviderUpdateKind) Validate() error { + switch p { + case PersonEnrichmentProviderUpdateKindExa, PersonEnrichmentProviderUpdateKindSixtyfour: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid PersonEnrichmentProviderUpdateKind value, got: %v", p)) + } +} + type PersonFileProvenanceDirections string const ( @@ -1095,18 +1840,18 @@ func (s SavedViewStateEnvelopePresentation) Validate() error { type SearchCoverageResponseStatus string const ( - Disabled SearchCoverageResponseStatus = "disabled" - Incomplete SearchCoverageResponseStatus = "incomplete" - Initializing SearchCoverageResponseStatus = "initializing" - SearchCoverageResponseStatusReady SearchCoverageResponseStatus = "ready" - SearchCoverageResponseStatusStale SearchCoverageResponseStatus = "stale" - Unavailable SearchCoverageResponseStatus = "unavailable" + Disabled SearchCoverageResponseStatus = "disabled" + Incomplete SearchCoverageResponseStatus = "incomplete" + Initializing SearchCoverageResponseStatus = "initializing" + SearchCoverageResponseStatusReady SearchCoverageResponseStatus = "ready" + SearchCoverageResponseStatusStale SearchCoverageResponseStatus = "stale" + SearchCoverageResponseStatusUnavailable SearchCoverageResponseStatus = "unavailable" ) // Validate checks if the SearchCoverageResponseStatus value is valid func (s SearchCoverageResponseStatus) Validate() error { switch s { - case Disabled, Incomplete, Initializing, SearchCoverageResponseStatusReady, SearchCoverageResponseStatusStale, Unavailable: + case Disabled, Incomplete, Initializing, SearchCoverageResponseStatusReady, SearchCoverageResponseStatusStale, SearchCoverageResponseStatusUnavailable: return nil default: return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid SearchCoverageResponseStatus value, got: %v", s)) @@ -1130,6 +1875,24 @@ func (s SearchCoverageResponseActions) Validate() error { } } +type SecretSettingStateSource string + +const ( + Environment SecretSettingStateSource = "environment" + None SecretSettingStateSource = "none" + Stored SecretSettingStateSource = "stored" +) + +// Validate checks if the SecretSettingStateSource value is valid +func (s SecretSettingStateSource) Validate() error { + switch s { + case Environment, None, Stored: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid SecretSettingStateSource value, got: %v", s)) + } +} + type SecretSettingUpdateAction string const ( @@ -1210,24 +1973,30 @@ func (s SetPersonAttributeRequestSource) Validate() error { } } -type SettingGroup string +type SettingGroup0 string const ( - Archive SettingGroup = "archive" - Browser SettingGroup = "browser" - Integrations SettingGroup = "integrations" - Search SettingGroup = "search" - Server SettingGroup = "server" - Sources SettingGroup = "sources" + Activity SettingGroup0 = "activity" + Archive SettingGroup0 = "archive" + Attachments SettingGroup0 = "attachments" + Backup SettingGroup0 = "backup" + Browser SettingGroup0 = "browser" + Integrations SettingGroup0 = "integrations" + Logging SettingGroup0 = "logging" + Search SettingGroup0 = "search" + Server SettingGroup0 = "server" + SettingGroup0Enrichment SettingGroup0 = "enrichment" + Sources SettingGroup0 = "sources" + Sync SettingGroup0 = "sync" ) -// Validate checks if the SettingGroup value is valid -func (s SettingGroup) Validate() error { +// Validate checks if the SettingGroup0 value is valid +func (s SettingGroup0) Validate() error { switch s { - case Archive, Browser, Integrations, Search, Server, Sources: + case Activity, Archive, Attachments, Backup, Browser, Integrations, Logging, Search, Server, SettingGroup0Enrichment, Sources, Sync: return nil default: - return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid SettingGroup value, got: %v", s)) + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid SettingGroup0 value, got: %v", s)) } } @@ -1275,6 +2044,89 @@ func (t TaskIntegrationStatusResponseState) Validate() error { } } +type ListOperationRunsQueryKind string + +const ( + ListOperationRunsQueryKindCarddavSync ListOperationRunsQueryKind = "carddav_sync" + ListOperationRunsQueryKindDocumentEmbedding ListOperationRunsQueryKind = "document_embedding" + ListOperationRunsQueryKindDocumentExtraction ListOperationRunsQueryKind = "document_extraction" + ListOperationRunsQueryKindMessageEmbedding ListOperationRunsQueryKind = "message_embedding" + ListOperationRunsQueryKindPersonEmbedding ListOperationRunsQueryKind = "person_embedding" + ListOperationRunsQueryKindPersonEnrichment ListOperationRunsQueryKind = "person_enrichment" + ListOperationRunsQueryKindPersonSweep ListOperationRunsQueryKind = "person_sweep" + ListOperationRunsQueryKindSourceSync ListOperationRunsQueryKind = "source_sync" + ListOperationRunsQueryKindVisualEmbedding ListOperationRunsQueryKind = "visual_embedding" +) + +// Validate checks if the ListOperationRunsQueryKind value is valid +func (l ListOperationRunsQueryKind) Validate() error { + switch l { + case ListOperationRunsQueryKindCarddavSync, ListOperationRunsQueryKindDocumentEmbedding, ListOperationRunsQueryKindDocumentExtraction, ListOperationRunsQueryKindMessageEmbedding, ListOperationRunsQueryKindPersonEmbedding, ListOperationRunsQueryKindPersonEnrichment, ListOperationRunsQueryKindPersonSweep, ListOperationRunsQueryKindSourceSync, ListOperationRunsQueryKindVisualEmbedding: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid ListOperationRunsQueryKind value, got: %v", l)) + } +} + +type ListOperationRunsQueryLane string + +const ( + ListOperationRunsQueryLaneContacts ListOperationRunsQueryLane = "contacts" + ListOperationRunsQueryLaneDocuments ListOperationRunsQueryLane = "documents" + ListOperationRunsQueryLaneMessages ListOperationRunsQueryLane = "messages" + ListOperationRunsQueryLanePersonFacts ListOperationRunsQueryLane = "person_facts" + ListOperationRunsQueryLaneVisualAttachments ListOperationRunsQueryLane = "visual_attachments" +) + +// Validate checks if the ListOperationRunsQueryLane value is valid +func (l ListOperationRunsQueryLane) Validate() error { + switch l { + case ListOperationRunsQueryLaneContacts, ListOperationRunsQueryLaneDocuments, ListOperationRunsQueryLaneMessages, ListOperationRunsQueryLanePersonFacts, ListOperationRunsQueryLaneVisualAttachments: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid ListOperationRunsQueryLane value, got: %v", l)) + } +} + +type ListOperationRunsQueryState string + +const ( + ListOperationRunsQueryStateCancelled ListOperationRunsQueryState = "cancelled" + ListOperationRunsQueryStateFailed ListOperationRunsQueryState = "failed" + ListOperationRunsQueryStatePartial ListOperationRunsQueryState = "partial" + ListOperationRunsQueryStateQueued ListOperationRunsQueryState = "queued" + ListOperationRunsQueryStateRunning ListOperationRunsQueryState = "running" + ListOperationRunsQueryStateSucceeded ListOperationRunsQueryState = "succeeded" +) + +// Validate checks if the ListOperationRunsQueryState value is valid +func (l ListOperationRunsQueryState) Validate() error { + switch l { + case ListOperationRunsQueryStateCancelled, ListOperationRunsQueryStateFailed, ListOperationRunsQueryStatePartial, ListOperationRunsQueryStateQueued, ListOperationRunsQueryStateRunning, ListOperationRunsQueryStateSucceeded: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid ListOperationRunsQueryState value, got: %v", l)) + } +} + +type ListDirectoryPeopleQuerySort string + +const ( + LastContactAsc ListDirectoryPeopleQuerySort = "last_contact_asc" + LastContactDesc ListDirectoryPeopleQuerySort = "last_contact_desc" + ListDirectoryPeopleQuerySortName ListDirectoryPeopleQuerySort = "name" +) + +// Validate checks if the ListDirectoryPeopleQuerySort value is valid +func (l ListDirectoryPeopleQuerySort) Validate() error { + switch l { + case LastContactAsc, LastContactDesc, ListDirectoryPeopleQuerySortName: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid ListDirectoryPeopleQuerySort value, got: %v", l)) + } +} + type SetPersonFactPinPathKind string const ( diff --git a/pkg/client/generated/headers.go b/pkg/client/generated/headers.go index 51c333d9b..b18c3b62d 100644 --- a/pkg/client/generated/headers.go +++ b/pkg/client/generated/headers.go @@ -227,3 +227,30 @@ type PatchSettingsHeaders struct { func (p PatchSettingsHeaders) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(p)) } + +type PutSettingsPersonEnrichmentProviderHeaders struct { + // IfMatch Strong config ETag returned by the latest settings read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (p PutSettingsPersonEnrichmentProviderHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type DeleteSettingsProviderCredentialHeaders struct { + // IfMatch Strong ETag for the provider credential store + IfMatch string `json:"If-Match" validate:"required"` +} + +func (d DeleteSettingsProviderCredentialHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + +type PutSettingsProviderCredentialHeaders struct { + // IfMatch Strong ETag for the provider credential store + IfMatch string `json:"If-Match" validate:"required"` +} + +func (p PutSettingsProviderCredentialHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} diff --git a/pkg/client/generated/paths.go b/pkg/client/generated/paths.go index 80460765f..6f269c528 100644 --- a/pkg/client/generated/paths.go +++ b/pkg/client/generated/paths.go @@ -291,6 +291,15 @@ func (u UnlinkMessageTaskPath) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(u)) } +type GetOperationRunPath struct { + // ID Opaque archive-bound operation run ID + ID string `json:"id" validate:"required"` +} + +func (g GetOperationRunPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(g)) +} + type DeleteOrganizationPath struct { // ID Organization ID ID int64 `json:"id"` @@ -527,6 +536,11 @@ type ListPersonMergesPath struct { ID int64 `json:"id"` } +type GetPersonNetworkPath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + type AppendPersonNotePath struct { // ID Durable person ID ID int64 `json:"id"` @@ -657,6 +671,30 @@ type PatchSavedViewPath struct { ID int64 `json:"id"` } +type PutSettingsPersonEnrichmentProviderPath struct { + Name string `json:"name" validate:"required"` +} + +func (p PutSettingsPersonEnrichmentProviderPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type DeleteSettingsProviderCredentialPath struct { + CredentialID string `json:"credential_id" validate:"required"` +} + +func (d DeleteSettingsProviderCredentialPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + +type PutSettingsProviderCredentialPath struct { + CredentialID string `json:"credential_id" validate:"required"` +} + +func (p PutSettingsProviderCredentialPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + type ListSourceIdentitiesPath struct { // SourceID Source ID SourceID int64 `json:"source_id"` diff --git a/pkg/client/generated/payloads.go b/pkg/client/generated/payloads.go index e22f3ef68..c59fa2f14 100644 --- a/pkg/client/generated/payloads.go +++ b/pkg/client/generated/payloads.go @@ -175,3 +175,7 @@ type SearchVisualAttachmentsBody = VisualTextSearchRequest type GetSearchCoverageBody = SearchCoverageRequest type PatchSettingsBody = SettingsPatchRequest + +type PutSettingsPersonEnrichmentProviderBody = PersonEnrichmentProviderUpdate + +type PutSettingsProviderCredentialBody = ProviderCredentialWriteRequest diff --git a/pkg/client/generated/queries.go b/pkg/client/generated/queries.go index 5ae9fc172..cb9b6cff4 100644 --- a/pkg/client/generated/queries.go +++ b/pkg/client/generated/queries.go @@ -3,6 +3,8 @@ package generated import ( + "time" + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" ) @@ -124,6 +126,18 @@ type ListAttributeDefinitionsQuery struct { IncludeHidden *bool `json:"include_hidden,omitempty"` } +type ListCardDAVRunsQuery struct { + // Limit Maximum runs to return (default 25, max 100) + Limit *int64 `json:"limit,omitempty" validate:"omitempty,gte=1,lte=100"` + + // BeforeID Return runs with IDs lower than this cursor + BeforeID *int64 `json:"before_id,omitempty" validate:"omitempty,gte=1"` +} + +func (l ListCardDAVRunsQuery) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(l)) +} + type GetCLIAttachmentQuery struct { // ContentHash Attachment SHA-256 content hash ContentHash string `json:"content_hash" validate:"required"` @@ -587,6 +601,57 @@ func (g GetMessageInlinePartQuery) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(g)) } +type ListOperationRunsQuery struct { + // Kind Exact operation kind + Kind *ListOperationRunsQueryKind `json:"kind,omitempty"` + + // Lane Exact semantic operation lane + Lane *ListOperationRunsQueryLane `json:"lane,omitempty"` + + // State Exact operation state + State *ListOperationRunsQueryState `json:"state,omitempty"` + + // Limit Maximum runs to return (default 25, max 100) + Limit *int64 `json:"limit,omitempty" validate:"omitempty,gte=1,lte=100"` + + // Cursor Opaque cursor bound to this archive and the exact kind, lane, and state filters + Cursor *string `json:"cursor,omitempty"` +} + +func (l ListOperationRunsQuery) Validate() error { + var errors runtime.ValidationErrors + if l.Kind != nil { + if v, ok := any(l.Kind).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Kind", err) + } + } + } + if l.Lane != nil { + if v, ok := any(l.Lane).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Lane", err) + } + } + } + if l.State != nil { + if v, ok := any(l.State).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("State", err) + } + } + } + if l.Limit != nil { + if err := typesValidator.Var(l.Limit, "omitempty,gte=1,lte=100"); err != nil { + errors = errors.Append("Limit", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type ListOrganizationsQuery struct { // Limit Maximum results Limit *int64 `json:"limit,omitempty"` @@ -631,6 +696,53 @@ type ListOrganizationEmploymentsQuery struct { Offset *int64 `json:"offset,omitempty"` } +type ListDirectoryPeopleQuery struct { + // Q Lexical query over person names, contact points, and organizations + Q *string `json:"q,omitempty"` + + // Cursor Opaque cursor returned by the previous Directory page + Cursor *string `json:"cursor,omitempty"` + + // Limit Maximum rows to return (default 50, max 100) + Limit *int64 `json:"limit,omitempty"` + + // ContactState Current contact state: active or inactive + ContactState *string `json:"contact_state,omitempty"` + + // Category Current person category + Category *string `json:"category,omitempty"` + + // Organization Current organization + Organization *string `json:"organization,omitempty"` + + // PrimaryChannel Primary communication channel + PrimaryChannel *string `json:"primary_channel,omitempty"` + + // LastContactAfter Return people contacted at or after this RFC3339 timestamp + LastContactAfter *time.Time `json:"last_contact_after,omitempty"` + + // LastContactBefore Return people contacted at or before this RFC3339 timestamp + LastContactBefore *time.Time `json:"last_contact_before,omitempty"` + + // Sort Directory order: name, last_contact_desc, or last_contact_asc + Sort *ListDirectoryPeopleQuerySort `json:"sort,omitempty"` +} + +func (l ListDirectoryPeopleQuery) Validate() error { + var errors runtime.ValidationErrors + if l.Sort != nil { + if v, ok := any(l.Sort).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Sort", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type ListPersonAttributesQuery struct { // History Include superseded values History *bool `json:"history,omitempty"` @@ -760,6 +872,18 @@ type ListPersonMergesQuery struct { Offset *int64 `json:"offset,omitempty"` } +type GetPersonNetworkQuery struct { + // Depth Breadth-first depth (default 1, minimum 1, maximum 3) + Depth *int64 `json:"depth,omitempty" validate:"omitempty,gte=1,lte=3"` + + // IncludeEnded Include ended relationships and employment records + IncludeEnded *bool `json:"include_ended,omitempty"` +} + +func (g GetPersonNetworkQuery) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(g)) +} + type AppendPersonNoteQuery struct { // DryRun Validate and preview without writing DryRun *bool `json:"dry_run,omitempty"` diff --git a/pkg/client/generated/responses.go b/pkg/client/generated/responses.go index fdcd87f1d..9e108063b 100644 --- a/pkg/client/generated/responses.go +++ b/pkg/client/generated/responses.go @@ -219,6 +219,20 @@ type PublishCardDAVPersonErrorResponseJSON502 = ErrorResponse type PublishCardDAVPersonErrorResponseJSON503 = ErrorResponse +type ListCardDAVRunsResponse = CardDAVRunsResponse + +type ListCardDAVRunsErrorResponse = ErrorResponse + +type ListCardDAVRunsErrorResponseJSON = ErrorResponse + +type ListCardDAVRunsErrorResponseJSON503 = ErrorResponse + +type GetCardDAVStatusResponse = CardDAVStatusResponse + +type GetCardDAVStatusErrorResponse = ErrorResponse + +type GetCardDAVStatusErrorResponseJSON = ErrorResponse + type SyncCardDAVResponse = SyncResult type SyncCardDAVErrorResponse = ErrorResponse @@ -1411,6 +1425,28 @@ type GetVisualAttachmentStatusResponse = Status type GetVisualAttachmentStatusErrorResponse = ErrorResponse +type ListOperationRunsResponse = OperationRunsResponse + +type ListOperationRunsErrorResponse = ErrorResponse + +type ListOperationRunsErrorResponseJSON = ErrorResponse + +type ListOperationRunsErrorResponseJSON503 = ErrorResponse + +type GetOperationRunResponse = OperationRunDetail + +type GetOperationRunErrorResponse = ErrorResponse + +type GetOperationRunErrorResponseJSON = ErrorResponse + +type GetOperationRunErrorResponseJSON500 = ErrorResponse + +type GetOperationRunErrorResponseJSON503 = ErrorResponse + +type GetOperationStatusResponse = OperationStatusResponse + +type GetOperationStatusErrorResponse = ErrorResponse + type ListOrganizationsResponse = OrganizationsResponse type ListOrganizationsErrorResponse = ErrorResponse @@ -1825,6 +1861,12 @@ type CreatePersonErrorResponse = ErrorResponse type CreatePersonErrorResponseJSON = ErrorResponse +type ListDirectoryPeopleResponse = DirectoryPeopleResponse + +type ListDirectoryPeopleErrorResponse = ErrorResponse + +type ListDirectoryPeopleErrorResponseJSON = ErrorResponse + type SearchPeopleResponse = PersonSearchResponse type SearchPeopleErrorResponse = ErrorResponse @@ -2055,6 +2097,14 @@ type ListPersonMergesErrorResponseJSON = ErrorResponse type ListPersonMergesErrorResponseJSON503 = ErrorResponse +type GetPersonNetworkResponse = PersonNetwork + +type GetPersonNetworkErrorResponse = ErrorResponse + +type GetPersonNetworkErrorResponseJSON = ErrorResponse + +type GetPersonNetworkErrorResponseJSON503 = ErrorResponse + type AppendPersonNoteResponse = PersonAttributeWrite type AppendPersonNoteErrorResponse = ErrorResponse @@ -2573,6 +2623,44 @@ type PatchSettingsErrorResponseJSON422 = ErrorResponse type PatchSettingsErrorResponseJSON428 = ErrorResponse +type PutSettingsPersonEnrichmentProviderResponse = SettingsResponse + +type PutSettingsPersonEnrichmentProviderErrorResponse = ErrorResponse + +type PutSettingsPersonEnrichmentProviderErrorResponseJSON = ErrorResponse + +type PutSettingsPersonEnrichmentProviderErrorResponseJSON409 = ErrorResponse + +type PutSettingsPersonEnrichmentProviderErrorResponseJSON412 = ErrorResponse + +type PutSettingsPersonEnrichmentProviderErrorResponseJSON422 = ErrorResponse + +type PutSettingsPersonEnrichmentProviderErrorResponseJSON428 = ErrorResponse + +type DeleteSettingsProviderCredentialResponse = ProviderCredentialResponse + +type DeleteSettingsProviderCredentialErrorResponse = ErrorResponse + +type DeleteSettingsProviderCredentialErrorResponseJSON = ErrorResponse + +type DeleteSettingsProviderCredentialErrorResponseJSON412 = ErrorResponse + +type DeleteSettingsProviderCredentialErrorResponseJSON422 = ErrorResponse + +type DeleteSettingsProviderCredentialErrorResponseJSON428 = ErrorResponse + +type PutSettingsProviderCredentialResponse = ProviderCredentialResponse + +type PutSettingsProviderCredentialErrorResponse = ErrorResponse + +type PutSettingsProviderCredentialErrorResponseJSON = ErrorResponse + +type PutSettingsProviderCredentialErrorResponseJSON412 = ErrorResponse + +type PutSettingsProviderCredentialErrorResponseJSON422 = ErrorResponse + +type PutSettingsProviderCredentialErrorResponseJSON428 = ErrorResponse + type ListSourceStatusResponse = SourceStatusResponse type ListSourceStatusErrorResponse = ErrorResponse @@ -2942,6 +3030,30 @@ type PublishCardDAVPersonResp struct { Headers503 *PublishCardDAVPersonResp503Headers } +type ListCardDAVRunsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListCardDAVRunsResponse + JSON400 *ListCardDAVRunsErrorResponse + JSON500 *ListCardDAVRunsErrorResponseJSON + JSON503 *ListCardDAVRunsErrorResponseJSON503 +} + +type GetCardDAVStatusResp503Headers struct { + RetryAfter string `header:"Retry-After"` +} + +type GetCardDAVStatusResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetCardDAVStatusResponse + JSON500 *GetCardDAVStatusErrorResponse + JSON503 *GetCardDAVStatusErrorResponseJSON + Headers503 *GetCardDAVStatusResp503Headers +} + type SyncCardDAVResp503Headers struct { RetryAfter string `header:"Retry-After"` } @@ -3825,6 +3937,34 @@ type GetVisualAttachmentStatusResp struct { JSON200 *GetVisualAttachmentStatusResponse } +type ListOperationRunsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListOperationRunsResponse + JSON400 *ListOperationRunsErrorResponse + JSON500 *ListOperationRunsErrorResponseJSON + JSON503 *ListOperationRunsErrorResponseJSON503 +} + +type GetOperationRunResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetOperationRunResponse + JSON400 *GetOperationRunErrorResponse + JSON404 *GetOperationRunErrorResponseJSON + JSON500 *GetOperationRunErrorResponseJSON500 + JSON503 *GetOperationRunErrorResponseJSON503 +} + +type GetOperationStatusResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetOperationStatusResponse +} + type ListOrganizationsResp struct { HTTPResponse *http.Response Body []byte @@ -4092,6 +4232,15 @@ type CreatePersonResp struct { JSON503 *CreatePersonErrorResponseJSON } +type ListDirectoryPeopleResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListDirectoryPeopleResponse + JSON400 *ListDirectoryPeopleErrorResponse + JSON503 *ListDirectoryPeopleErrorResponseJSON +} + type SearchPeopleResp struct { HTTPResponse *http.Response Body []byte @@ -4324,6 +4473,16 @@ type ListPersonMergesResp struct { JSON503 *ListPersonMergesErrorResponseJSON503 } +type GetPersonNetworkResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetPersonNetworkResponse + JSON400 *GetPersonNetworkErrorResponse + JSON404 *GetPersonNetworkErrorResponseJSON + JSON503 *GetPersonNetworkErrorResponseJSON503 +} + type AppendPersonNoteResp struct { HTTPResponse *http.Response Body []byte @@ -4790,7 +4949,8 @@ type FindSimilarMessagesResp struct { } type GetSettingsResp200Headers struct { - ETag string `header:"ETag"` + CredentialETag string `header:"Credential-ETag"` + ETag string `header:"ETag"` } type GetSettingsResp struct { @@ -4802,7 +4962,8 @@ type GetSettingsResp struct { } type PatchSettingsResp200Headers struct { - ETag string `header:"ETag"` + CredentialETag string `header:"Credential-ETag"` + ETag string `header:"ETag"` } type PatchSettingsResp struct { @@ -4818,6 +4979,58 @@ type PatchSettingsResp struct { JSON428 *PatchSettingsErrorResponseJSON428 } +type PutSettingsPersonEnrichmentProviderResp200Headers struct { + ETag string `header:"ETag"` +} + +type PutSettingsPersonEnrichmentProviderResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *PutSettingsPersonEnrichmentProviderResponse + Headers200 *PutSettingsPersonEnrichmentProviderResp200Headers + JSON400 *PutSettingsPersonEnrichmentProviderErrorResponse + JSON404 *PutSettingsPersonEnrichmentProviderErrorResponseJSON + JSON409 *PutSettingsPersonEnrichmentProviderErrorResponseJSON409 + JSON412 *PutSettingsPersonEnrichmentProviderErrorResponseJSON412 + JSON422 *PutSettingsPersonEnrichmentProviderErrorResponseJSON422 + JSON428 *PutSettingsPersonEnrichmentProviderErrorResponseJSON428 +} + +type DeleteSettingsProviderCredentialResp200Headers struct { + ETag string `header:"ETag"` +} + +type DeleteSettingsProviderCredentialResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *DeleteSettingsProviderCredentialResponse + Headers200 *DeleteSettingsProviderCredentialResp200Headers + JSON400 *DeleteSettingsProviderCredentialErrorResponse + JSON404 *DeleteSettingsProviderCredentialErrorResponseJSON + JSON412 *DeleteSettingsProviderCredentialErrorResponseJSON412 + JSON422 *DeleteSettingsProviderCredentialErrorResponseJSON422 + JSON428 *DeleteSettingsProviderCredentialErrorResponseJSON428 +} + +type PutSettingsProviderCredentialResp200Headers struct { + ETag string `header:"ETag"` +} + +type PutSettingsProviderCredentialResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *PutSettingsProviderCredentialResponse + Headers200 *PutSettingsProviderCredentialResp200Headers + JSON400 *PutSettingsProviderCredentialErrorResponse + JSON404 *PutSettingsProviderCredentialErrorResponseJSON + JSON412 *PutSettingsProviderCredentialErrorResponseJSON412 + JSON422 *PutSettingsProviderCredentialErrorResponseJSON422 + JSON428 *PutSettingsProviderCredentialErrorResponseJSON428 +} + type ListSourceStatusResp struct { HTTPResponse *http.Response Body []byte diff --git a/pkg/client/generated/types.go b/pkg/client/generated/types.go index 01371528a..1277a1e29 100644 --- a/pkg/client/generated/types.go +++ b/pkg/client/generated/types.go @@ -869,6 +869,15 @@ func (c CardDAVAccountResponse) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(c)) } +type CardDAVAddressBookIdentityResponse struct { + ID int64 `json:"id" validate:"gte=1"` + Name string `json:"name" validate:"required"` +} + +func (c CardDAVAddressBookIdentityResponse) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(c)) +} + type CardDAVBookResponse struct { ID int64 `json:"id"` LookupSource bool `json:"lookup_source"` @@ -909,44 +918,155 @@ func (c CardDAVBooksResponse) Validate() error { } type CardDAVConflictDetailResponse struct { - AddressBookID int64 `json:"address_book_id"` - Href string `json:"href" validate:"required"` - ID int64 `json:"id"` - LocalTombstone bool `json:"local_tombstone"` - LocalVcard *string `json:"local_vcard,omitempty"` - RemoteTombstone bool `json:"remote_tombstone"` - RemoteVcard *string `json:"remote_vcard,omitempty"` - Status string `json:"status" validate:"required"` + AddressBook CardDAVAddressBookIdentityResponse `json:"address_book"` + AllowedResolutions []CardDAVConflictDetailResponseAllowedResolutions `json:"allowed_resolutions" validate:"required"` + Base CardDAVContactSummaryResponse `json:"base"` + CreatedAt time.Time `json:"created_at" validate:"required"` + ID int64 `json:"id" validate:"gte=1"` + Local CardDAVContactSummaryResponse `json:"local"` + Remote CardDAVContactSummaryResponse `json:"remote"` + Resolution *CardDAVConflictDetailResponseResolution `json:"resolution,omitempty"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` + Status CardDAVConflictDetailResponseStatus `json:"status" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` } func (c CardDAVConflictDetailResponse) Validate() error { - return runtime.ConvertValidatorError(typesValidator.Struct(c)) + var errors runtime.ValidationErrors + if v, ok := any(c.AddressBook).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("AddressBook", err) + } + } + for i, item := range c.AllowedResolutions { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("AllowedResolutions[%d]", i), err) + } + } + } + if v, ok := any(c.Base).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Base", err) + } + } + if err := typesValidator.Var(c.CreatedAt, "required"); err != nil { + errors = errors.Append("CreatedAt", err) + } + if err := typesValidator.Var(c.ID, "gte=1"); err != nil { + errors = errors.Append("ID", err) + } + if v, ok := any(c.Local).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Local", err) + } + } + if v, ok := any(c.Remote).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Remote", err) + } + } + if c.Resolution != nil { + if v, ok := any(c.Resolution).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Resolution", err) + } + } + } + if v, ok := any(c.Status).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Status", err) + } + } + if err := typesValidator.Var(c.UpdatedAt, "required"); err != nil { + errors = errors.Append("UpdatedAt", err) + } + if len(errors) == 0 { + return nil + } + return errors } type CardDAVConflictResolutionResponse struct { - ID int64 `json:"id"` - Status string `json:"status" validate:"required"` + ID int64 `json:"id" validate:"gte=1"` + Resolution CardDAVConflictResolutionResponseResolution `json:"resolution" validate:"required"` + Status CardDAVConflictResolutionResponseStatus `json:"status" validate:"required"` } func (c CardDAVConflictResolutionResponse) Validate() error { - return runtime.ConvertValidatorError(typesValidator.Struct(c)) + var errors runtime.ValidationErrors + if err := typesValidator.Var(c.ID, "gte=1"); err != nil { + errors = errors.Append("ID", err) + } + if v, ok := any(c.Resolution).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Resolution", err) + } + } + if v, ok := any(c.Status).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Status", err) + } + } + if len(errors) == 0 { + return nil + } + return errors } type CardDAVConflictResponse struct { - AddressBookID int64 `json:"address_book_id"` - Href string `json:"href" validate:"required"` - ID int64 `json:"id"` - LocalTombstone bool `json:"local_tombstone"` - RemoteTombstone bool `json:"remote_tombstone"` - Status string `json:"status" validate:"required"` + AddressBook CardDAVAddressBookIdentityResponse `json:"address_book"` + AllowedResolutions []CardDAVConflictResponseAllowedResolutions `json:"allowed_resolutions" validate:"required"` + ID int64 `json:"id" validate:"gte=1"` + LocalState CardDAVConflictResponseLocalState `json:"local_state" validate:"required"` + RemoteState CardDAVConflictResponseRemoteState `json:"remote_state" validate:"required"` + Status CardDAVConflictResponseStatus `json:"status" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` } func (c CardDAVConflictResponse) Validate() error { - return runtime.ConvertValidatorError(typesValidator.Struct(c)) + var errors runtime.ValidationErrors + if v, ok := any(c.AddressBook).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("AddressBook", err) + } + } + for i, item := range c.AllowedResolutions { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("AllowedResolutions[%d]", i), err) + } + } + } + if err := typesValidator.Var(c.ID, "gte=1"); err != nil { + errors = errors.Append("ID", err) + } + if v, ok := any(c.LocalState).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("LocalState", err) + } + } + if v, ok := any(c.RemoteState).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("RemoteState", err) + } + } + if v, ok := any(c.Status).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Status", err) + } + } + if err := typesValidator.Var(c.UpdatedAt, "required"); err != nil { + errors = errors.Append("UpdatedAt", err) + } + if len(errors) == 0 { + return nil + } + return errors } type CardDAVConflictsResponse struct { - Conflicts []CardDAVConflictResponse `json:"conflicts,omitempty" validate:"required"` + Conflicts []CardDAVConflictResponse `json:"conflicts" validate:"required"` } func (c CardDAVConflictsResponse) Validate() error { @@ -964,11 +1084,75 @@ func (c CardDAVConflictsResponse) Validate() error { return errors } +type CardDAVContactSummaryResponse struct { + DisplayName *string `json:"display_name,omitempty"` + Emails []string `json:"emails" validate:"required"` + Phones []string `json:"phones" validate:"required"` + State CardDAVContactSummaryResponseState `json:"state" validate:"required"` + Truncated *bool `json:"truncated,omitempty"` +} + +func (c CardDAVContactSummaryResponse) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(c.Emails, "required"); err != nil { + errors = errors.Append("Emails", err) + } + if err := typesValidator.Var(c.Phones, "required"); err != nil { + errors = errors.Append("Phones", err) + } + if v, ok := any(c.State).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("State", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type CardDAVPublicationResponse struct { - Desired bool `json:"desired"` - Href *string `json:"href,omitempty"` - PendingOperation *string `json:"pending_operation,omitempty"` - PersonID int64 `json:"person_id"` + AddressBook *CardDAVAddressBookIdentityResponse `json:"address_book,omitempty"` + ConflictID *int64 `json:"conflict_id,omitempty" validate:"omitempty,gte=1"` + Desired bool `json:"desired"` + PendingOperation *CardDAVPublicationResponsePendingOperation `json:"pending_operation,omitempty"` + PersonID int64 `json:"person_id" validate:"gte=1"` + State CardDAVPublicationResponseState `json:"state" validate:"required"` +} + +func (c CardDAVPublicationResponse) Validate() error { + var errors runtime.ValidationErrors + if c.AddressBook != nil { + if v, ok := any(c.AddressBook).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("AddressBook", err) + } + } + } + if c.ConflictID != nil { + if err := typesValidator.Var(c.ConflictID, "omitempty,gte=1"); err != nil { + errors = errors.Append("ConflictID", err) + } + } + if c.PendingOperation != nil { + if v, ok := any(c.PendingOperation).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PendingOperation", err) + } + } + } + if err := typesValidator.Var(c.PersonID, "gte=1"); err != nil { + errors = errors.Append("PersonID", err) + } + if v, ok := any(c.State).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("State", err) + } + } + if len(errors) == 0 { + return nil + } + return errors } type CardDAVResolveRequest struct { @@ -988,6 +1172,139 @@ func (c CardDAVResolveRequest) Validate() error { return errors } +type CardDAVRunResponse struct { + Books int64 `json:"books"` + Created int64 `json:"created"` + ErrorCode *CardDAVRunResponseErrorCode `json:"error_code,omitempty"` + ErrorMessage *string `json:"error_message,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + Full bool `json:"full"` + ID int64 `json:"id"` + Removed int64 `json:"removed"` + StartedAt time.Time `json:"started_at" validate:"required"` + State CardDAVRunResponseState `json:"state" validate:"required"` + Trigger CardDAVRunResponseTrigger `json:"trigger" validate:"required"` + Updated int64 `json:"updated"` +} + +func (c CardDAVRunResponse) Validate() error { + var errors runtime.ValidationErrors + if c.ErrorCode != nil { + if v, ok := any(c.ErrorCode).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ErrorCode", err) + } + } + } + if err := typesValidator.Var(c.StartedAt, "required"); err != nil { + errors = errors.Append("StartedAt", err) + } + if v, ok := any(c.State).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("State", err) + } + } + if v, ok := any(c.Trigger).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Trigger", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type CardDAVRunsResponse struct { + NextBeforeID *int64 `json:"next_before_id,omitempty"` + Runs []CardDAVRunResponse `json:"runs" validate:"required"` +} + +func (c CardDAVRunsResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range c.Runs { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Runs[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type CardDAVStatusAccount struct { + BaseURL string `json:"base_url" validate:"required"` + Username string `json:"username" validate:"required"` +} + +func (c CardDAVStatusAccount) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(c)) +} + +type CardDAVStatusResponse struct { + Account *CardDAVStatusAccount `json:"account,omitempty"` + Active *CardDAVRunResponse `json:"active,omitempty"` + Available bool `json:"available"` + Configured bool `json:"configured"` + CredentialConfigured bool `json:"credential_configured"` + Enabled bool `json:"enabled"` + Latest *CardDAVRunResponse `json:"latest,omitempty"` + LatestSuccessful *CardDAVRunResponse `json:"latest_successful,omitempty"` + NextScheduledAt *time.Time `json:"next_scheduled_at,omitempty"` + RepairReason *CardDAVStatusResponseRepairReason `json:"repair_reason,omitempty"` + Schedule string `json:"schedule" validate:"required"` + Scheduled bool `json:"scheduled"` +} + +func (c CardDAVStatusResponse) Validate() error { + var errors runtime.ValidationErrors + if c.Account != nil { + if v, ok := any(c.Account).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Account", err) + } + } + } + if c.Active != nil { + if v, ok := any(c.Active).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Active", err) + } + } + } + if c.Latest != nil { + if v, ok := any(c.Latest).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Latest", err) + } + } + } + if c.LatestSuccessful != nil { + if v, ok := any(c.LatestSuccessful).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("LatestSuccessful", err) + } + } + } + if c.RepairReason != nil { + if v, ok := any(c.RepairReason).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("RepairReason", err) + } + } + } + if err := typesValidator.Var(c.Schedule, "required"); err != nil { + errors = errors.Append("Schedule", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + type CardDAVSyncRequest struct { Full *bool `json:"full,omitempty"` } @@ -2008,6 +2325,41 @@ func (d DeletionTarget) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(d)) } +type DirectoryPeopleResponse struct { + NextCursor *string `json:"next_cursor,omitempty"` + People []DirectoryPersonSummary `json:"people" validate:"required"` +} + +func (d DirectoryPeopleResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range d.People { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("People[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type DirectoryPersonSummary struct { + Categories []string `json:"categories" validate:"required"` + ContactState string `json:"contact_state" validate:"required"` + DisplayName *string `json:"display_name,omitempty"` + ID int64 `json:"id"` + LastContactAt *time.Time `json:"last_contact_at,omitempty"` + Organizations []string `json:"organizations" validate:"required"` + PrimaryChannel *string `json:"primary_channel,omitempty"` + Revision int64 `json:"revision"` +} + +func (d DirectoryPersonSummary) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + type DiscoverError struct { Code string `json:"code" validate:"required"` Message string `json:"message" validate:"required"` @@ -4458,35 +4810,401 @@ type MessageDetail struct { To []string `json:"to,omitempty" validate:"required"` } -func (m MessageDetail) Validate() error { +func (m MessageDetail) Validate() error { + var errors runtime.ValidationErrors + for i, item := range m.Attachments { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Attachments[%d]", i), err) + } + } + } + if err := typesValidator.Var(m.Body, "required"); err != nil { + errors = errors.Append("Body", err) + } + if err := typesValidator.Var(m.From, "required"); err != nil { + errors = errors.Append("From", err) + } + if err := typesValidator.Var(m.Labels, "required"); err != nil { + errors = errors.Append("Labels", err) + } + if err := typesValidator.Var(m.SentAt, "required"); err != nil { + errors = errors.Append("SentAt", err) + } + if err := typesValidator.Var(m.Snippet, "required"); err != nil { + errors = errors.Append("Snippet", err) + } + if err := typesValidator.Var(m.Subject, "required"); err != nil { + errors = errors.Append("Subject", err) + } + if err := typesValidator.Var(m.To, "required"); err != nil { + errors = errors.Append("To", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type MessageListResponse struct { + Messages []MessageSummary `json:"messages,omitempty" validate:"required"` + Page int64 `json:"page"` + PageSize int64 `json:"page_size"` + Total int64 `json:"total"` +} + +func (m MessageListResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range m.Messages { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Messages[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type MessageSummary struct { + Bcc []string `json:"bcc,omitempty"` + Cc []string `json:"cc,omitempty"` + ConversationID *int64 `json:"conversation_id,omitempty"` + DeletedAt *string `json:"deleted_at,omitempty"` + From string `json:"from" validate:"required"` + FromEmail *string `json:"from_email,omitempty"` + FromName *string `json:"from_name,omitempty"` + FromPhone *string `json:"from_phone,omitempty"` + HasAttachments bool `json:"has_attachments"` + ID int64 `json:"id"` + Labels []string `json:"labels,omitempty" validate:"required"` + MessageType *string `json:"message_type,omitempty"` + SentAt string `json:"sent_at" validate:"required"` + SizeBytes int64 `json:"size_bytes"` + Snippet string `json:"snippet" validate:"required"` + SourceID *int64 `json:"source_id,omitempty"` + SourceMessageID *string `json:"source_message_id,omitempty"` + Subject string `json:"subject" validate:"required"` + To []string `json:"to,omitempty" validate:"required"` +} + +func (m MessageSummary) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(m)) +} + +type MutationResult struct { + Name string `json:"name" validate:"required"` + SourceCount *int64 `json:"source_count,omitempty"` +} + +func (m MutationResult) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(m)) +} + +type NetworkEdge struct { + EndDate *string `json:"end_date,omitempty"` + ID string `json:"id" validate:"required"` + Kind NetworkEdgeKind `json:"kind" validate:"required"` + Label string `json:"label" validate:"required"` + RelationshipTypeSlug *string `json:"relationship_type_slug,omitempty"` + SourceNodeID string `json:"source_node_id" validate:"required"` + StartDate *string `json:"start_date,omitempty"` + TargetNodeID string `json:"target_node_id" validate:"required"` +} + +func (n NetworkEdge) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(n.ID, "required"); err != nil { + errors = errors.Append("ID", err) + } + if v, ok := any(n.Kind).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Kind", err) + } + } + if err := typesValidator.Var(n.Label, "required"); err != nil { + errors = errors.Append("Label", err) + } + if err := typesValidator.Var(n.SourceNodeID, "required"); err != nil { + errors = errors.Append("SourceNodeID", err) + } + if err := typesValidator.Var(n.TargetNodeID, "required"); err != nil { + errors = errors.Append("TargetNodeID", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type NetworkNode struct { + EntityID int64 `json:"entity_id"` + Hop int64 `json:"hop"` + ID string `json:"id" validate:"required"` + Kind NetworkNodeKind `json:"kind" validate:"required"` + Label string `json:"label" validate:"required"` +} + +func (n NetworkNode) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(n.ID, "required"); err != nil { + errors = errors.Append("ID", err) + } + if v, ok := any(n.Kind).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Kind", err) + } + } + if err := typesValidator.Var(n.Label, "required"); err != nil { + errors = errors.Append("Label", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type OperationHealth struct { + Busy bool `json:"busy"` + Label *string `json:"label,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` +} + +type OperationLaneStatus struct { + Active *OperationRunSummary `json:"active,omitempty"` + Configured bool `json:"configured"` + HistoryAvailability OperationLaneStatusHistoryAvailability `json:"history_availability" validate:"required"` + Kind OperationLaneStatusKind `json:"kind" validate:"required"` + Lane OperationLaneStatusLane `json:"lane" validate:"required"` + Latest *OperationRunSummary `json:"latest,omitempty"` + LatestSuccessful *OperationRunSummary `json:"latest_successful,omitempty"` + RelatedStatus *OperationLaneStatusRelatedStatus `json:"related_status,omitempty"` + SupportedActions []OperationLaneStatusSupportedActions `json:"supported_actions" validate:"required"` + UnavailableCode *string `json:"unavailable_code,omitempty"` +} + +func (o OperationLaneStatus) Validate() error { + var errors runtime.ValidationErrors + if o.Active != nil { + if v, ok := any(o.Active).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Active", err) + } + } + } + if v, ok := any(o.HistoryAvailability).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("HistoryAvailability", err) + } + } + if v, ok := any(o.Kind).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Kind", err) + } + } + if v, ok := any(o.Lane).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Lane", err) + } + } + if o.Latest != nil { + if v, ok := any(o.Latest).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Latest", err) + } + } + } + if o.LatestSuccessful != nil { + if v, ok := any(o.LatestSuccessful).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("LatestSuccessful", err) + } + } + } + if o.RelatedStatus != nil { + if v, ok := any(o.RelatedStatus).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("RelatedStatus", err) + } + } + } + for i, item := range o.SupportedActions { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("SupportedActions[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type OperationPublicCounter struct { + Name OperationPublicCounterName `json:"name" validate:"required"` + Unit OperationPublicCounterUnit `json:"unit" validate:"required"` + Value int64 `json:"value"` +} + +func (o OperationPublicCounter) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(o.Name).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Name", err) + } + } + if v, ok := any(o.Unit).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Unit", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type OperationPublicError struct { + Code OperationPublicErrorCode `json:"code" validate:"required"` + Message string `json:"message" validate:"required"` +} + +func (o OperationPublicError) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(o.Code).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Code", err) + } + } + if err := typesValidator.Var(o.Message, "required"); err != nil { + errors = errors.Append("Message", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type OperationRunDetail struct { + Counters []OperationPublicCounter `json:"counters" validate:"required"` + ErrorData *OperationPublicError `json:"error,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + ID string `json:"id" validate:"required"` + Kind OperationRunDetailKind `json:"kind" validate:"required"` + Lane OperationRunDetailLane `json:"lane" validate:"required"` + StartedAt time.Time `json:"started_at" validate:"required"` + State OperationRunDetailState `json:"state" validate:"required"` + Trigger *OperationRunDetailTrigger `json:"trigger,omitempty"` +} + +func (o OperationRunDetail) Validate() error { + var errors runtime.ValidationErrors + for i, item := range o.Counters { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Counters[%d]", i), err) + } + } + } + if o.ErrorData != nil { + if v, ok := any(o.ErrorData).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ErrorData", err) + } + } + } + if err := typesValidator.Var(o.ID, "required"); err != nil { + errors = errors.Append("ID", err) + } + if v, ok := any(o.Kind).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Kind", err) + } + } + if v, ok := any(o.Lane).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Lane", err) + } + } + if err := typesValidator.Var(o.StartedAt, "required"); err != nil { + errors = errors.Append("StartedAt", err) + } + if v, ok := any(o.State).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("State", err) + } + } + if o.Trigger != nil { + if v, ok := any(o.Trigger).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Trigger", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type OperationRunSummary struct { + Counters []OperationPublicCounter `json:"counters" validate:"required"` + ErrorData *OperationPublicError `json:"error,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + ID string `json:"id" validate:"required"` + Kind OperationRunSummaryKind `json:"kind" validate:"required"` + Lane OperationRunSummaryLane `json:"lane" validate:"required"` + StartedAt time.Time `json:"started_at" validate:"required"` + State OperationRunSummaryState `json:"state" validate:"required"` + Trigger *OperationRunSummaryTrigger `json:"trigger,omitempty"` +} + +func (o OperationRunSummary) Validate() error { var errors runtime.ValidationErrors - for i, item := range m.Attachments { + for i, item := range o.Counters { if v, ok := any(item).(runtime.Validator); ok { if err := v.Validate(); err != nil { - errors = errors.Append(fmt.Sprintf("Attachments[%d]", i), err) + errors = errors.Append(fmt.Sprintf("Counters[%d]", i), err) } } } - if err := typesValidator.Var(m.Body, "required"); err != nil { - errors = errors.Append("Body", err) + if o.ErrorData != nil { + if v, ok := any(o.ErrorData).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ErrorData", err) + } + } } - if err := typesValidator.Var(m.From, "required"); err != nil { - errors = errors.Append("From", err) + if err := typesValidator.Var(o.ID, "required"); err != nil { + errors = errors.Append("ID", err) } - if err := typesValidator.Var(m.Labels, "required"); err != nil { - errors = errors.Append("Labels", err) + if v, ok := any(o.Kind).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Kind", err) + } } - if err := typesValidator.Var(m.SentAt, "required"); err != nil { - errors = errors.Append("SentAt", err) + if v, ok := any(o.Lane).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Lane", err) + } } - if err := typesValidator.Var(m.Snippet, "required"); err != nil { - errors = errors.Append("Snippet", err) + if err := typesValidator.Var(o.StartedAt, "required"); err != nil { + errors = errors.Append("StartedAt", err) } - if err := typesValidator.Var(m.Subject, "required"); err != nil { - errors = errors.Append("Subject", err) + if v, ok := any(o.State).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("State", err) + } } - if err := typesValidator.Var(m.To, "required"); err != nil { - errors = errors.Append("To", err) + if o.Trigger != nil { + if v, ok := any(o.Trigger).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Trigger", err) + } + } } if len(errors) == 0 { return nil @@ -4494,19 +5212,25 @@ func (m MessageDetail) Validate() error { return errors } -type MessageListResponse struct { - Messages []MessageSummary `json:"messages,omitempty" validate:"required"` - Page int64 `json:"page"` - PageSize int64 `json:"page_size"` - Total int64 `json:"total"` +type OperationRunsResponse struct { + NextCursor *string `json:"next_cursor,omitempty"` + Runs []OperationRunSummary `json:"runs" validate:"required"` + UnavailableKinds []OperationUnavailableKind `json:"unavailable_kinds" validate:"required"` } -func (m MessageListResponse) Validate() error { +func (o OperationRunsResponse) Validate() error { var errors runtime.ValidationErrors - for i, item := range m.Messages { + for i, item := range o.Runs { if v, ok := any(item).(runtime.Validator); ok { if err := v.Validate(); err != nil { - errors = errors.Append(fmt.Sprintf("Messages[%d]", i), err) + errors = errors.Append(fmt.Sprintf("Runs[%d]", i), err) + } + } + } + for i, item := range o.UnavailableKinds { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("UnavailableKinds[%d]", i), err) } } } @@ -4516,45 +5240,50 @@ func (m MessageListResponse) Validate() error { return errors } -type MessageSummary struct { - Bcc []string `json:"bcc,omitempty"` - Cc []string `json:"cc,omitempty"` - ConversationID *int64 `json:"conversation_id,omitempty"` - DeletedAt *string `json:"deleted_at,omitempty"` - From string `json:"from" validate:"required"` - FromEmail *string `json:"from_email,omitempty"` - FromName *string `json:"from_name,omitempty"` - FromPhone *string `json:"from_phone,omitempty"` - HasAttachments bool `json:"has_attachments"` - ID int64 `json:"id"` - Labels []string `json:"labels,omitempty" validate:"required"` - MessageType *string `json:"message_type,omitempty"` - SentAt string `json:"sent_at" validate:"required"` - SizeBytes int64 `json:"size_bytes"` - Snippet string `json:"snippet" validate:"required"` - SourceID *int64 `json:"source_id,omitempty"` - SourceMessageID *string `json:"source_message_id,omitempty"` - Subject string `json:"subject" validate:"required"` - To []string `json:"to,omitempty" validate:"required"` -} - -func (m MessageSummary) Validate() error { - return runtime.ConvertValidatorError(typesValidator.Struct(m)) +type OperationStatusResponse struct { + Lanes []OperationLaneStatus `json:"lanes" validate:"required"` } -type MutationResult struct { - Name string `json:"name" validate:"required"` - SourceCount *int64 `json:"source_count,omitempty"` +func (o OperationStatusResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range o.Lanes { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Lanes[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors } -func (m MutationResult) Validate() error { - return runtime.ConvertValidatorError(typesValidator.Struct(m)) +type OperationUnavailableKind struct { + Kind OperationUnavailableKindKind `json:"kind" validate:"required"` + Lane OperationUnavailableKindLane `json:"lane" validate:"required"` + UnavailableCode string `json:"unavailable_code" validate:"required"` } -type OperationHealth struct { - Busy bool `json:"busy"` - Label *string `json:"label,omitempty"` - StartedAt *time.Time `json:"started_at,omitempty"` +func (o OperationUnavailableKind) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(o.Kind).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Kind", err) + } + } + if v, ok := any(o.Lane).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Lane", err) + } + } + if err := typesValidator.Var(o.UnavailableCode, "required"); err != nil { + errors = errors.Append("UnavailableCode", err) + } + if len(errors) == 0 { + return nil + } + return errors } type Organization struct { @@ -4635,6 +5364,7 @@ type OrganizationAddressBody struct { Region *string `json:"region,omitempty"` Source OrganizationAddressBodySource `json:"source" validate:"required"` SourceRef *string `json:"source_ref,omitempty"` + SourceResourceUID *string `json:"source_resource_uid,omitempty"` StreetAddress *string `json:"street_address,omitempty"` Timezone *string `json:"timezone,omitempty"` TypeLabel *string `json:"type_label,omitempty"` @@ -4809,20 +5539,21 @@ func (o OrganizationCategory) Validate() error { } type OrganizationCategoryBody struct { - ActiveFrom *time.Time `json:"active_from,omitempty"` - Category string `json:"category" validate:"required"` - Confidence *float64 `json:"confidence,omitempty"` - Ordinal *int64 `json:"ordinal,omitempty" validate:"omitempty,gte=0"` - Pref *int64 `json:"pref,omitempty"` - Source OrganizationCategoryBodySource `json:"source" validate:"required"` - SourceRef *string `json:"source_ref,omitempty"` - TypeLabel *string `json:"type_label,omitempty"` - TypeTokens []string `json:"type_tokens,omitempty"` - VcardAltid *string `json:"vcard_altid,omitempty"` - VcardGroup *string `json:"vcard_group,omitempty"` - VcardPid []string `json:"vcard_pid,omitempty"` - VcardPropID *string `json:"vcard_prop_id,omitempty"` - VcardProperty *string `json:"vcard_property,omitempty"` + ActiveFrom *time.Time `json:"active_from,omitempty"` + Category string `json:"category" validate:"required"` + Confidence *float64 `json:"confidence,omitempty"` + Ordinal *int64 `json:"ordinal,omitempty" validate:"omitempty,gte=0"` + Pref *int64 `json:"pref,omitempty"` + Source OrganizationCategoryBodySource `json:"source" validate:"required"` + SourceRef *string `json:"source_ref,omitempty"` + SourceResourceUID *string `json:"source_resource_uid,omitempty"` + TypeLabel *string `json:"type_label,omitempty"` + TypeTokens []string `json:"type_tokens,omitempty"` + VcardAltid *string `json:"vcard_altid,omitempty"` + VcardGroup *string `json:"vcard_group,omitempty"` + VcardPid []string `json:"vcard_pid,omitempty"` + VcardPropID *string `json:"vcard_prop_id,omitempty"` + VcardProperty *string `json:"vcard_property,omitempty"` } func (o OrganizationCategoryBody) Validate() error { @@ -4886,25 +5617,26 @@ func (o OrganizationContactPoint) Validate() error { } type OrganizationContactPointBody struct { - ActiveFrom *time.Time `json:"active_from,omitempty"` - Confidence *float64 `json:"confidence,omitempty"` - ContactKind OrganizationContactPointBodyContactKind `json:"contact_kind" validate:"required"` - Ordinal *int64 `json:"ordinal,omitempty" validate:"omitempty,gte=0"` - OriginalValue string `json:"original_value" validate:"required"` - Pref *int64 `json:"pref,omitempty"` - ScopeKind *string `json:"scope_kind,omitempty"` - ScopeValue *string `json:"scope_value,omitempty"` - ServiceSlug *string `json:"service_slug,omitempty"` - Source OrganizationContactPointBodySource `json:"source" validate:"required"` - SourceRef *string `json:"source_ref,omitempty"` - TypeLabel *string `json:"type_label,omitempty"` - TypeTokens []string `json:"type_tokens,omitempty"` - URI *string `json:"uri,omitempty"` - VcardAltid *string `json:"vcard_altid,omitempty"` - VcardGroup *string `json:"vcard_group,omitempty"` - VcardPid []string `json:"vcard_pid,omitempty"` - VcardPropID *string `json:"vcard_prop_id,omitempty"` - VcardProperty *string `json:"vcard_property,omitempty"` + ActiveFrom *time.Time `json:"active_from,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` + ContactKind OrganizationContactPointBodyContactKind `json:"contact_kind" validate:"required"` + Ordinal *int64 `json:"ordinal,omitempty" validate:"omitempty,gte=0"` + OriginalValue string `json:"original_value" validate:"required"` + Pref *int64 `json:"pref,omitempty"` + ScopeKind *string `json:"scope_kind,omitempty"` + ScopeValue *string `json:"scope_value,omitempty"` + ServiceSlug *string `json:"service_slug,omitempty"` + Source OrganizationContactPointBodySource `json:"source" validate:"required"` + SourceRef *string `json:"source_ref,omitempty"` + SourceResourceUID *string `json:"source_resource_uid,omitempty"` + TypeLabel *string `json:"type_label,omitempty"` + TypeTokens []string `json:"type_tokens,omitempty"` + URI *string `json:"uri,omitempty"` + VcardAltid *string `json:"vcard_altid,omitempty"` + VcardGroup *string `json:"vcard_group,omitempty"` + VcardPid []string `json:"vcard_pid,omitempty"` + VcardPropID *string `json:"vcard_prop_id,omitempty"` + VcardProperty *string `json:"vcard_property,omitempty"` } func (o OrganizationContactPointBody) Validate() error { @@ -4989,21 +5721,22 @@ func (o OrganizationIdentifier) Validate() error { } type OrganizationIdentifierBody struct { - ActiveFrom *time.Time `json:"active_from,omitempty"` - Confidence *float64 `json:"confidence,omitempty"` - IdentifierKind OrganizationIdentifierBodyIdentifierKind `json:"identifier_kind" validate:"required"` - IdentifierValue string `json:"identifier_value" validate:"required"` - Ordinal *int64 `json:"ordinal,omitempty" validate:"omitempty,gte=0"` - Pref *int64 `json:"pref,omitempty"` - Source OrganizationIdentifierBodySource `json:"source" validate:"required"` - SourceRef *string `json:"source_ref,omitempty"` - TypeLabel *string `json:"type_label,omitempty"` - TypeTokens []string `json:"type_tokens,omitempty"` - VcardAltid *string `json:"vcard_altid,omitempty"` - VcardGroup *string `json:"vcard_group,omitempty"` - VcardPid []string `json:"vcard_pid,omitempty"` - VcardPropID *string `json:"vcard_prop_id,omitempty"` - VcardProperty *string `json:"vcard_property,omitempty"` + ActiveFrom *time.Time `json:"active_from,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` + IdentifierKind OrganizationIdentifierBodyIdentifierKind `json:"identifier_kind" validate:"required"` + IdentifierValue string `json:"identifier_value" validate:"required"` + Ordinal *int64 `json:"ordinal,omitempty" validate:"omitempty,gte=0"` + Pref *int64 `json:"pref,omitempty"` + Source OrganizationIdentifierBodySource `json:"source" validate:"required"` + SourceRef *string `json:"source_ref,omitempty"` + SourceResourceUID *string `json:"source_resource_uid,omitempty"` + TypeLabel *string `json:"type_label,omitempty"` + TypeTokens []string `json:"type_tokens,omitempty"` + VcardAltid *string `json:"vcard_altid,omitempty"` + VcardGroup *string `json:"vcard_group,omitempty"` + VcardPid []string `json:"vcard_pid,omitempty"` + VcardPropID *string `json:"vcard_prop_id,omitempty"` + VcardProperty *string `json:"vcard_property,omitempty"` } func (o OrganizationIdentifierBody) Validate() error { @@ -5064,25 +5797,26 @@ func (o OrganizationMedia) Validate() error { } type OrganizationMediaBody struct { - ActiveFrom *time.Time `json:"active_from,omitempty"` - Confidence *float64 `json:"confidence,omitempty"` - ContentHash *string `json:"content_hash,omitempty"` - Data *string `json:"data,omitempty"` - MediaKind OrganizationMediaBodyMediaKind `json:"media_kind" validate:"required"` - MediaType *string `json:"media_type,omitempty"` - Ordinal *int64 `json:"ordinal,omitempty" validate:"omitempty,gte=0"` - OriginalValue *string `json:"original_value,omitempty"` - Pref *int64 `json:"pref,omitempty"` - Source OrganizationMediaBodySource `json:"source" validate:"required"` - SourceRef *string `json:"source_ref,omitempty"` - TypeLabel *string `json:"type_label,omitempty"` - TypeTokens []string `json:"type_tokens,omitempty"` - URI *string `json:"uri,omitempty"` - VcardAltid *string `json:"vcard_altid,omitempty"` - VcardGroup *string `json:"vcard_group,omitempty"` - VcardPid []string `json:"vcard_pid,omitempty"` - VcardPropID *string `json:"vcard_prop_id,omitempty"` - VcardProperty *string `json:"vcard_property,omitempty"` + ActiveFrom *time.Time `json:"active_from,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` + ContentHash *string `json:"content_hash,omitempty"` + Data *string `json:"data,omitempty"` + MediaKind OrganizationMediaBodyMediaKind `json:"media_kind" validate:"required"` + MediaType *string `json:"media_type,omitempty"` + Ordinal *int64 `json:"ordinal,omitempty" validate:"omitempty,gte=0"` + OriginalValue *string `json:"original_value,omitempty"` + Pref *int64 `json:"pref,omitempty"` + Source OrganizationMediaBodySource `json:"source" validate:"required"` + SourceRef *string `json:"source_ref,omitempty"` + SourceResourceUID *string `json:"source_resource_uid,omitempty"` + TypeLabel *string `json:"type_label,omitempty"` + TypeTokens []string `json:"type_tokens,omitempty"` + URI *string `json:"uri,omitempty"` + VcardAltid *string `json:"vcard_altid,omitempty"` + VcardGroup *string `json:"vcard_group,omitempty"` + VcardPid []string `json:"vcard_pid,omitempty"` + VcardPropID *string `json:"vcard_prop_id,omitempty"` + VcardProperty *string `json:"vcard_property,omitempty"` } func (o OrganizationMediaBody) Validate() error { @@ -5139,21 +5873,22 @@ func (o OrganizationName) Validate() error { } type OrganizationNameBody struct { - ActiveFrom *time.Time `json:"active_from,omitempty"` - Confidence *float64 `json:"confidence,omitempty"` - Name string `json:"name" validate:"required"` - NameKind OrganizationNameBodyNameKind `json:"name_kind" validate:"required"` - Ordinal *int64 `json:"ordinal,omitempty" validate:"omitempty,gte=0"` - Pref *int64 `json:"pref,omitempty"` - Source OrganizationNameBodySource `json:"source" validate:"required"` - SourceRef *string `json:"source_ref,omitempty"` - TypeLabel *string `json:"type_label,omitempty"` - TypeTokens []string `json:"type_tokens,omitempty"` - VcardAltid *string `json:"vcard_altid,omitempty"` - VcardGroup *string `json:"vcard_group,omitempty"` - VcardPid []string `json:"vcard_pid,omitempty"` - VcardPropID *string `json:"vcard_prop_id,omitempty"` - VcardProperty *string `json:"vcard_property,omitempty"` + ActiveFrom *time.Time `json:"active_from,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` + Name string `json:"name" validate:"required"` + NameKind OrganizationNameBodyNameKind `json:"name_kind" validate:"required"` + Ordinal *int64 `json:"ordinal,omitempty" validate:"omitempty,gte=0"` + Pref *int64 `json:"pref,omitempty"` + Source OrganizationNameBodySource `json:"source" validate:"required"` + SourceRef *string `json:"source_ref,omitempty"` + SourceResourceUID *string `json:"source_resource_uid,omitempty"` + TypeLabel *string `json:"type_label,omitempty"` + TypeTokens []string `json:"type_tokens,omitempty"` + VcardAltid *string `json:"vcard_altid,omitempty"` + VcardGroup *string `json:"vcard_group,omitempty"` + VcardPid []string `json:"vcard_pid,omitempty"` + VcardPropID *string `json:"vcard_prop_id,omitempty"` + VcardProperty *string `json:"vcard_property,omitempty"` } func (o OrganizationNameBody) Validate() error { @@ -6164,6 +6899,140 @@ func (p PersonDaysPage) Validate() error { return errors } +type PersonEnrichmentProviderSetting struct { + AllowSensitiveTargets bool `json:"allow_sensitive_targets"` + AllowedIdentifiers []string `json:"allowed_identifiers,omitempty" validate:"required"` + Credential *SecretSettingState `json:"credential,omitempty"` + CredentialID string `json:"credential_id" validate:"required"` + Enabled bool `json:"enabled"` + Endpoint string `json:"endpoint" validate:"required"` + Kind PersonEnrichmentProviderSettingKind `json:"kind" validate:"required"` + MaxJobAge string `json:"max_job_age" validate:"required"` + MaxRequestsPerDay int64 `json:"max_requests_per_day"` + MaxRequestsPerRun int64 `json:"max_requests_per_run"` + MaxRetries int64 `json:"max_retries"` + Mode *string `json:"mode,omitempty"` + Name string `json:"name" validate:"required"` + NumResults *int64 `json:"num_results,omitempty"` + PollEndpoint *string `json:"poll_endpoint,omitempty"` + PollInterval string `json:"poll_interval" validate:"required"` + RefreshInterval string `json:"refresh_interval" validate:"required"` + RequestTimeout string `json:"request_timeout" validate:"required"` + RetentionPosture string `json:"retention_posture" validate:"required"` + TargetKeys []string `json:"target_keys,omitempty" validate:"required"` + Tier *string `json:"tier,omitempty"` + TrainingPosture string `json:"training_posture" validate:"required"` +} + +func (p PersonEnrichmentProviderSetting) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.AllowedIdentifiers, "required"); err != nil { + errors = errors.Append("AllowedIdentifiers", err) + } + if p.Credential != nil { + if v, ok := any(p.Credential).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Credential", err) + } + } + } + if err := typesValidator.Var(p.CredentialID, "required"); err != nil { + errors = errors.Append("CredentialID", err) + } + if err := typesValidator.Var(p.Endpoint, "required"); err != nil { + errors = errors.Append("Endpoint", err) + } + if v, ok := any(p.Kind).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Kind", err) + } + } + if err := typesValidator.Var(p.MaxJobAge, "required"); err != nil { + errors = errors.Append("MaxJobAge", err) + } + if err := typesValidator.Var(p.Name, "required"); err != nil { + errors = errors.Append("Name", err) + } + if err := typesValidator.Var(p.PollInterval, "required"); err != nil { + errors = errors.Append("PollInterval", err) + } + if err := typesValidator.Var(p.RefreshInterval, "required"); err != nil { + errors = errors.Append("RefreshInterval", err) + } + if err := typesValidator.Var(p.RequestTimeout, "required"); err != nil { + errors = errors.Append("RequestTimeout", err) + } + if err := typesValidator.Var(p.RetentionPosture, "required"); err != nil { + errors = errors.Append("RetentionPosture", err) + } + if err := typesValidator.Var(p.TargetKeys, "required"); err != nil { + errors = errors.Append("TargetKeys", err) + } + if err := typesValidator.Var(p.TrainingPosture, "required"); err != nil { + errors = errors.Append("TrainingPosture", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonEnrichmentProviderUpdate struct { + AllowSensitiveTargets bool `json:"allow_sensitive_targets"` + AllowedIdentifiers []string `json:"allowed_identifiers,omitempty" validate:"required"` + Enabled bool `json:"enabled"` + Endpoint string `json:"endpoint" validate:"required"` + Kind PersonEnrichmentProviderUpdateKind `json:"kind" validate:"required"` + MaxJobAge *string `json:"max_job_age,omitempty"` + MaxRequestsPerDay int64 `json:"max_requests_per_day"` + MaxRequestsPerRun int64 `json:"max_requests_per_run"` + MaxRetries int64 `json:"max_retries"` + Mode *string `json:"mode,omitempty"` + NumResults *int64 `json:"num_results,omitempty"` + PollEndpoint *string `json:"poll_endpoint,omitempty"` + PollInterval *string `json:"poll_interval,omitempty"` + RefreshInterval string `json:"refresh_interval" validate:"required"` + RequestTimeout string `json:"request_timeout" validate:"required"` + RetentionPosture string `json:"retention_posture" validate:"required"` + TargetKeys []string `json:"target_keys,omitempty" validate:"required"` + Tier *string `json:"tier,omitempty"` + TrainingPosture string `json:"training_posture" validate:"required"` +} + +func (p PersonEnrichmentProviderUpdate) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.AllowedIdentifiers, "required"); err != nil { + errors = errors.Append("AllowedIdentifiers", err) + } + if err := typesValidator.Var(p.Endpoint, "required"); err != nil { + errors = errors.Append("Endpoint", err) + } + if v, ok := any(p.Kind).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Kind", err) + } + } + if err := typesValidator.Var(p.RefreshInterval, "required"); err != nil { + errors = errors.Append("RefreshInterval", err) + } + if err := typesValidator.Var(p.RequestTimeout, "required"); err != nil { + errors = errors.Append("RequestTimeout", err) + } + if err := typesValidator.Var(p.RetentionPosture, "required"); err != nil { + errors = errors.Append("RetentionPosture", err) + } + if err := typesValidator.Var(p.TargetKeys, "required"); err != nil { + errors = errors.Append("TargetKeys", err) + } + if err := typesValidator.Var(p.TrainingPosture, "required"); err != nil { + errors = errors.Append("TrainingPosture", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + type PersonFactClaim struct { ClaimKey string `json:"claim_key" validate:"required"` Confidence ConfidenceInputs `json:"confidence"` @@ -7237,6 +8106,36 @@ func (p PersonNamePatchRequest) Validate() error { return errors } +type PersonNetwork struct { + Depth int64 `json:"depth"` + Edges []NetworkEdge `json:"edges,omitempty" validate:"required"` + Nodes []NetworkNode `json:"nodes,omitempty" validate:"required"` + RootPersonID int64 `json:"root_person_id"` + Truncated bool `json:"truncated"` +} + +func (p PersonNetwork) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Edges { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Edges[%d]", i), err) + } + } + } + for i, item := range p.Nodes { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Nodes[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type PersonProfile struct { DisplayName *string `json:"display_name,omitempty"` ID int64 `json:"id"` @@ -7774,6 +8673,36 @@ func (p Provenance) Validate() error { return errors } +type ProviderCredentialResponse struct { + CredentialID string `json:"credential_id" validate:"required"` + PendingRestart bool `json:"pending_restart"` + State SecretSettingState `json:"state"` +} + +func (p ProviderCredentialResponse) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.CredentialID, "required"); err != nil { + errors = errors.Append("CredentialID", err) + } + if v, ok := any(p.State).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("State", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type ProviderCredentialWriteRequest struct { + Value string `json:"value" validate:"required,min=1"` +} + +func (p ProviderCredentialWriteRequest) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + type ProviderUsage struct { BilledUnits float64 `json:"billed_units"` InputBytes int64 `json:"input_bytes"` @@ -8533,7 +9462,23 @@ func (s SearchResult) Validate() error { } type SecretSettingState struct { - Configured bool `json:"configured"` + Configured bool `json:"configured"` + Source *SecretSettingStateSource `json:"source,omitempty"` +} + +func (s SecretSettingState) Validate() error { + var errors runtime.ValidationErrors + if s.Source != nil { + if v, ok := any(s.Source).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Source", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors } type SecretSettingUpdate struct { @@ -8663,19 +9608,27 @@ type SetPersonFactPinRequest struct { } type Setting struct { - Group SettingGroup `json:"group" validate:"required"` + CredentialID *string `json:"credential_id,omitempty"` + Description string `json:"description" validate:"required"` + Group SettingGroup0 `json:"group" validate:"required"` + Inherited *bool `json:"inherited,omitempty"` Key string `json:"key" validate:"required"` Kind SettingKind `json:"kind" validate:"required"` + Label string `json:"label" validate:"required"` Options []string `json:"options,omitempty"` ReadOnly *bool `json:"read_only,omitempty"` RestartRequired bool `json:"restart_required"` Secret *SecretSettingState `json:"secret,omitempty"` Testable *bool `json:"testable,omitempty"` + Validation *SettingValidation `json:"validation,omitempty"` Value *SettingValue `json:"value,omitempty"` } func (s Setting) Validate() error { var errors runtime.ValidationErrors + if err := typesValidator.Var(s.Description, "required"); err != nil { + errors = errors.Append("Description", err) + } if v, ok := any(s.Group).(runtime.Validator); ok { if err := v.Validate(); err != nil { errors = errors.Append("Group", err) @@ -8689,6 +9642,9 @@ func (s Setting) Validate() error { errors = errors.Append("Kind", err) } } + if err := typesValidator.Var(s.Label, "required"); err != nil { + errors = errors.Append("Label", err) + } if s.Secret != nil { if v, ok := any(s.Secret).(runtime.Validator); ok { if err := v.Validate(); err != nil { @@ -8696,6 +9652,13 @@ func (s Setting) Validate() error { } } } + if s.Validation != nil { + if v, ok := any(s.Validation).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Validation", err) + } + } + } if s.Value != nil { if v, ok := any(s.Value).(runtime.Validator); ok { if err := v.Validate(); err != nil { @@ -8709,6 +9672,16 @@ func (s Setting) Validate() error { return errors } +type SettingGroup struct { + Description string `json:"description" validate:"required"` + ID string `json:"id" validate:"required"` + Label string `json:"label" validate:"required"` +} + +func (s SettingGroup) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(s)) +} + type SettingUpdate struct { Key string `json:"key" validate:"required"` Secret *SecretSettingUpdate `json:"secret,omitempty"` @@ -8740,6 +9713,13 @@ func (s SettingUpdate) Validate() error { return errors } +type SettingValidation struct { + Hint *string `json:"hint,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Required *bool `json:"required,omitempty"` +} + type SettingValue struct { SettingValue_OneOf *SettingValue_OneOf `json:"-"` } @@ -8794,8 +9774,7 @@ func (s *SettingValue) UnmarshalJSON(data []byte) error { } type SettingsPatchRequest struct { - ConfirmAPIKeyRestart *bool `json:"confirm_api_key_restart,omitempty"` - Updates []SettingUpdate `json:"updates" validate:"required"` + Updates []SettingUpdate `json:"updates" validate:"required"` } func (s SettingsPatchRequest) Validate() error { @@ -8814,12 +9793,32 @@ func (s SettingsPatchRequest) Validate() error { } type SettingsResponse struct { - PendingRestart bool `json:"pending_restart"` - Settings []Setting `json:"settings" validate:"required"` + CredentialEtag string `json:"credential_etag" validate:"required"` + Groups []SettingGroup `json:"groups" validate:"required"` + PendingRestart bool `json:"pending_restart"` + PersonEnrichmentProviders []PersonEnrichmentProviderSetting `json:"person_enrichment_providers,omitempty"` + Settings []Setting `json:"settings" validate:"required"` } func (s SettingsResponse) Validate() error { var errors runtime.ValidationErrors + if err := typesValidator.Var(s.CredentialEtag, "required"); err != nil { + errors = errors.Append("CredentialEtag", err) + } + for i, item := range s.Groups { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Groups[%d]", i), err) + } + } + } + for i, item := range s.PersonEnrichmentProviders { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("PersonEnrichmentProviders[%d]", i), err) + } + } + } for i, item := range s.Settings { if v, ok := any(item).(runtime.Validator); ok { if err := v.Validate(); err != nil { diff --git a/pkg/client/openapi.yaml b/pkg/client/openapi.yaml index 5a27d5a5e..e27156479 100644 --- a/pkg/client/openapi.yaml +++ b/pkg/client/openapi.yaml @@ -1058,6 +1058,18 @@ components: - enabled - books type: object + CardDAVAddressBookIdentityResponse: + properties: + id: + format: int64 + minimum: 1 + type: integer + name: + type: string + required: + - id + - name + type: object CardDAVBookResponse: properties: id: @@ -1110,90 +1122,224 @@ components: type: object CardDAVConflictDetailResponse: properties: - address_book_id: - format: int64 - type: integer - href: + address_book: + $ref: "#/components/schemas/CardDAVAddressBookIdentityResponse" + allowed_resolutions: + items: + enum: + - keep_local + - keep_remote + type: string + x-enum-names: + - CardDAVConflictDetailResponseAllowedResolutionsKeepLocal + - CardDAVConflictDetailResponseAllowedResolutionsKeepRemote + type: array + base: + $ref: "#/components/schemas/CardDAVContactSummaryResponse" + created_at: + format: date-time type: string id: format: int64 + minimum: 1 type: integer - local_tombstone: - type: boolean - local_vcard: + local: + $ref: "#/components/schemas/CardDAVContactSummaryResponse" + remote: + $ref: "#/components/schemas/CardDAVContactSummaryResponse" + resolution: + enum: + - keep_local + - keep_remote type: string - remote_tombstone: - type: boolean - remote_vcard: + x-enum-names: + - CardDAVConflictDetailResponseResolutionKeepLocal + - CardDAVConflictDetailResponseResolutionKeepRemote + resolved_at: + format: date-time type: string status: + enum: + - unresolved + - resolved + type: string + x-enum-names: + - CardDAVConflictDetailResponseStatusUnresolved + - CardDAVConflictDetailResponseStatusResolved + updated_at: + format: date-time type: string required: - id - - address_book_id - - href - - local_tombstone - - remote_tombstone + - address_book - status + - base + - local + - remote + - allowed_resolutions + - created_at + - updated_at type: object CardDAVConflictResolutionResponse: properties: id: format: int64 + minimum: 1 type: integer + resolution: + enum: + - keep_local + - keep_remote + type: string + x-enum-names: + - CardDAVConflictResolutionResponseResolutionKeepLocal + - CardDAVConflictResolutionResponseResolutionKeepRemote status: + enum: + - resolved type: string + x-enum-names: + - CardDAVConflictResolutionResponseStatusResolved required: - id - status + - resolution type: object CardDAVConflictResponse: properties: - address_book_id: - format: int64 - type: integer - href: - type: string + address_book: + $ref: "#/components/schemas/CardDAVAddressBookIdentityResponse" + allowed_resolutions: + items: + enum: + - keep_local + - keep_remote + type: string + x-enum-names: + - CardDAVConflictResponseAllowedResolutionsKeepLocal + - CardDAVConflictResponseAllowedResolutionsKeepRemote + type: array id: format: int64 + minimum: 1 type: integer - local_tombstone: - type: boolean - remote_tombstone: - type: boolean + local_state: + enum: + - present + - deleted + - unavailable + type: string + x-enum-names: + - CardDAVConflictResponseLocalStatePresent + - CardDAVConflictResponseLocalStateDeleted + - CardDAVConflictResponseLocalStateUnavailable + remote_state: + enum: + - present + - deleted + - unavailable + type: string + x-enum-names: + - CardDAVConflictResponseRemoteStatePresent + - CardDAVConflictResponseRemoteStateDeleted + - CardDAVConflictResponseRemoteStateUnavailable status: + enum: + - unresolved + - resolved + type: string + x-enum-names: + - CardDAVConflictResponseStatusUnresolved + - CardDAVConflictResponseStatusResolved + updated_at: + format: date-time type: string required: - id - - address_book_id - - href - - local_tombstone - - remote_tombstone + - address_book - status + - local_state + - remote_state + - allowed_resolutions + - updated_at type: object CardDAVConflictsResponse: properties: conflicts: items: $ref: "#/components/schemas/CardDAVConflictResponse" - nullable: true type: array required: - conflicts type: object + CardDAVContactSummaryResponse: + properties: + display_name: + type: string + emails: + items: + type: string + type: array + phones: + items: + type: string + type: array + state: + enum: + - present + - deleted + - unavailable + type: string + x-enum-names: + - CardDAVContactSummaryResponseStatePresent + - CardDAVContactSummaryResponseStateDeleted + - CardDAVContactSummaryResponseStateUnavailable + truncated: + type: boolean + required: + - state + - emails + - phones + type: object CardDAVPublicationResponse: properties: + address_book: + $ref: "#/components/schemas/CardDAVAddressBookIdentityResponse" + conflict_id: + format: int64 + minimum: 1 + type: integer desired: type: boolean - href: - type: string pending_operation: + enum: + - create + - update + - delete type: string + x-enum-names: + - CardDAVPublicationResponsePendingOperationCreate + - CardDAVPublicationResponsePendingOperationUpdate + - CardDAVPublicationResponsePendingOperationDelete person_id: format: int64 + minimum: 1 type: integer + state: + enum: + - unpublished + - published + - pending + - conflict + type: string + x-enum-names: + - CardDAVPublicationResponseStateUnpublished + - CardDAVPublicationResponseStatePublished + - CardDAVPublicationResponseStatePending + - CardDAVPublicationResponseStateConflict required: - person_id + - state - desired type: object CardDAVResolveRequest: @@ -1207,6 +1353,131 @@ components: required: - choice type: object + CardDAVRunResponse: + properties: + books: + format: int64 + type: integer + created: + format: int64 + type: integer + error_code: + enum: + - cancelled + - retry_after + - authentication_failed + - upstream_failed + - safety_limit + - sync_failed + - unsafe_error_redacted + - daemon_restarted + type: string + error_message: + type: string + finished_at: + format: date-time + type: string + full: + type: boolean + id: + format: int64 + type: integer + removed: + format: int64 + type: integer + started_at: + format: date-time + type: string + state: + enum: + - running + - succeeded + - failed + - cancelled + - partial + type: string + trigger: + enum: + - manual + - scheduled + type: string + updated: + format: int64 + type: integer + required: + - id + - trigger + - full + - state + - started_at + - books + - created + - updated + - removed + type: object + CardDAVRunsResponse: + properties: + next_before_id: + format: int64 + type: integer + runs: + items: + $ref: "#/components/schemas/CardDAVRunResponse" + type: array + required: + - runs + type: object + CardDAVStatusAccount: + properties: + base_url: + type: string + username: + type: string + required: + - base_url + - username + type: object + CardDAVStatusResponse: + properties: + account: + $ref: "#/components/schemas/CardDAVStatusAccount" + active: + $ref: "#/components/schemas/CardDAVRunResponse" + available: + type: boolean + configured: + type: boolean + credential_configured: + type: boolean + enabled: + type: boolean + latest: + $ref: "#/components/schemas/CardDAVRunResponse" + latest_successful: + $ref: "#/components/schemas/CardDAVRunResponse" + next_scheduled_at: + format: date-time + type: string + repair_reason: + enum: + - account_missing + - credential_missing + - credential_mismatch + - credential_unavailable + - runtime_unavailable + type: string + schedule: + type: string + scheduled: + type: boolean + required: + - configured + - available + - credential_configured + - enabled + - scheduled + - schedule + type: object CardDAVSyncRequest: additionalProperties: false properties: @@ -2333,6 +2604,49 @@ components: - source_identifier - source_message_id type: object + DirectoryPeopleResponse: + properties: + next_cursor: + type: string + people: + items: + $ref: "#/components/schemas/DirectoryPersonSummary" + type: array + required: + - people + type: object + DirectoryPersonSummary: + properties: + categories: + items: + type: string + type: array + contact_state: + type: string + display_name: + type: string + id: + format: int64 + type: integer + last_contact_at: + format: date-time + type: string + organizations: + items: + type: string + type: array + primary_channel: + type: string + revision: + format: int64 + type: integer + required: + - id + - revision + - contact_state + - categories + - organizations + type: object DiscoverError: properties: code: @@ -5103,42 +5417,387 @@ components: required: - name type: object - OperationHealth: + NetworkEdge: properties: - busy: - type: boolean + end_date: + type: string + id: + type: string + kind: + enum: + - relationship + - employment + type: string label: type: string - started_at: - format: date-time + relationship_type_slug: + type: string + source_node_id: + type: string + start_date: + type: string + target_node_id: type: string required: - - busy + - id + - kind + - source_node_id + - target_node_id + - label type: object - Organization: + NetworkNode: properties: - created_at: - format: date-time - type: string - description: - type: string - id: + entity_id: format: int64 type: integer - kind: - type: string - merged_into_id: + hop: format: int64 type: integer - name: + id: type: string - primary_domain: + kind: + enum: + - person + - organization type: string - retired_at: - format: date-time + label: type: string - revision: - format: int64 + required: + - id + - kind + - entity_id + - label + - hop + type: object + OperationHealth: + properties: + busy: + type: boolean + label: + type: string + started_at: + format: date-time + type: string + required: + - busy + type: object + OperationLaneStatus: + properties: + active: + $ref: "#/components/schemas/OperationRunSummary" + configured: + type: boolean + history_availability: + enum: + - available + - unavailable + type: string + kind: + enum: + - carddav_sync + - document_embedding + - document_extraction + - message_embedding + - person_embedding + - person_enrichment + - person_sweep + - source_sync + - visual_embedding + type: string + lane: + enum: + - contacts + - documents + - messages + - person_facts + - visual_attachments + type: string + latest: + $ref: "#/components/schemas/OperationRunSummary" + latest_successful: + $ref: "#/components/schemas/OperationRunSummary" + related_status: + enum: + - listSourceStatus + - getDocumentIndexStatus + - getDocumentVectorStatus + - getVisualAttachmentStatus + - getCardDAVStatus + type: string + supported_actions: + items: + enum: + - carddav_sync + - visual_build + - visual_resume + type: string + type: array + unavailable_code: + type: string + required: + - kind + - lane + - configured + - history_availability + - supported_actions + type: object + OperationPublicCounter: + properties: + name: + enum: + - processed + - added + - updated + - item_errors + - attempted + - succeeded + - failed + - projected_writes + - books + - created + - removed + type: string + unit: + enum: + - messages + - people + - writes + - books + - contacts + type: string + value: + format: int64 + type: integer + required: + - name + - unit + - value + type: object + OperationPublicError: + properties: + code: + enum: + - source_sync_failed + - person_sweep_failed + - policy + - budget + - lease_lost + - rate_limited + - timeout + - provider_http + - invalid_output + - archive_gap + - internal + - cancelled + - retry_after + - authentication_failed + - upstream_failed + - safety_limit + - sync_failed + - unsafe_error_redacted + - daemon_restarted + - carddav_sync_failed + type: string + message: + type: string + required: + - code + - message + type: object + OperationRunDetail: + properties: + counters: + items: + $ref: "#/components/schemas/OperationPublicCounter" + type: array + error: + $ref: "#/components/schemas/OperationPublicError" + finished_at: + format: date-time + type: string + id: + type: string + kind: + enum: + - carddav_sync + - document_embedding + - document_extraction + - message_embedding + - person_embedding + - person_enrichment + - person_sweep + - source_sync + - visual_embedding + type: string + lane: + enum: + - contacts + - documents + - messages + - person_facts + - visual_attachments + type: string + started_at: + format: date-time + type: string + state: + enum: + - cancelled + - failed + - partial + - queued + - running + - succeeded + type: string + trigger: + enum: + - manual + - scheduled + type: string + required: + - id + - kind + - lane + - state + - started_at + - counters + type: object + OperationRunSummary: + properties: + counters: + items: + $ref: "#/components/schemas/OperationPublicCounter" + type: array + error: + $ref: "#/components/schemas/OperationPublicError" + finished_at: + format: date-time + type: string + id: + type: string + kind: + enum: + - carddav_sync + - document_embedding + - document_extraction + - message_embedding + - person_embedding + - person_enrichment + - person_sweep + - source_sync + - visual_embedding + type: string + lane: + enum: + - contacts + - documents + - messages + - person_facts + - visual_attachments + type: string + started_at: + format: date-time + type: string + state: + enum: + - cancelled + - failed + - partial + - queued + - running + - succeeded + type: string + trigger: + enum: + - manual + - scheduled + type: string + required: + - id + - kind + - lane + - state + - started_at + - counters + type: object + OperationRunsResponse: + properties: + next_cursor: + type: string + runs: + items: + $ref: "#/components/schemas/OperationRunSummary" + type: array + unavailable_kinds: + items: + $ref: "#/components/schemas/OperationUnavailableKind" + type: array + required: + - runs + - unavailable_kinds + type: object + OperationStatusResponse: + properties: + lanes: + items: + $ref: "#/components/schemas/OperationLaneStatus" + type: array + required: + - lanes + type: object + OperationUnavailableKind: + properties: + kind: + enum: + - carddav_sync + - document_embedding + - document_extraction + - message_embedding + - person_embedding + - person_enrichment + - person_sweep + - source_sync + - visual_embedding + type: string + lane: + enum: + - contacts + - documents + - messages + - person_facts + - visual_attachments + type: string + unavailable_code: + type: string + required: + - kind + - lane + - unavailable_code + type: object + Organization: + properties: + created_at: + format: date-time + type: string + description: + type: string + id: + format: int64 + type: integer + kind: + type: string + merged_into_id: + format: int64 + type: integer + name: + type: string + primary_domain: + type: string + retired_at: + format: date-time + type: string + revision: + format: int64 type: integer updated_at: format: date-time @@ -5268,6 +5927,9 @@ components: source_ref: nullable: true type: string + source_resource_uid: + nullable: true + type: string street_address: nullable: true type: string @@ -5451,6 +6113,9 @@ components: source_ref: nullable: true type: string + source_resource_uid: + nullable: true + type: string type_label: nullable: true type: string @@ -5571,6 +6236,9 @@ components: source_ref: nullable: true type: string + source_resource_uid: + nullable: true + type: string type_label: nullable: true type: string @@ -5690,6 +6358,9 @@ components: source_ref: nullable: true type: string + source_resource_uid: + nullable: true + type: string type_label: nullable: true type: string @@ -5799,6 +6470,9 @@ components: source_ref: nullable: true type: string + source_resource_uid: + nullable: true + type: string type_label: nullable: true type: string @@ -5893,6 +6567,9 @@ components: source_ref: nullable: true type: string + source_resource_uid: + nullable: true + type: string type_label: nullable: true type: string @@ -6763,6 +7440,153 @@ components: - days - total_count type: object + PersonEnrichmentProviderSetting: + properties: + allow_sensitive_targets: + type: boolean + allowed_identifiers: + items: + type: string + nullable: true + type: array + credential: + $ref: "#/components/schemas/SecretSettingState" + credential_id: + type: string + enabled: + type: boolean + endpoint: + type: string + kind: + enum: + - exa + - sixtyfour + type: string + max_job_age: + type: string + max_requests_per_day: + format: int64 + type: integer + max_requests_per_run: + format: int64 + type: integer + max_retries: + format: int64 + type: integer + mode: + type: string + name: + type: string + num_results: + format: int64 + type: integer + poll_endpoint: + type: string + poll_interval: + type: string + refresh_interval: + type: string + request_timeout: + type: string + retention_posture: + type: string + target_keys: + items: + type: string + nullable: true + type: array + tier: + type: string + training_posture: + type: string + required: + - name + - kind + - enabled + - endpoint + - allowed_identifiers + - target_keys + - allow_sensitive_targets + - retention_posture + - training_posture + - refresh_interval + - request_timeout + - poll_interval + - max_job_age + - max_retries + - max_requests_per_run + - max_requests_per_day + - credential_id + type: object + PersonEnrichmentProviderUpdate: + additionalProperties: false + properties: + allow_sensitive_targets: + type: boolean + allowed_identifiers: + items: + type: string + nullable: true + type: array + enabled: + type: boolean + endpoint: + type: string + kind: + enum: + - exa + - sixtyfour + type: string + max_job_age: + type: string + max_requests_per_day: + format: int64 + type: integer + max_requests_per_run: + format: int64 + type: integer + max_retries: + format: int64 + type: integer + mode: + type: string + num_results: + format: int64 + type: integer + poll_endpoint: + type: string + poll_interval: + type: string + refresh_interval: + type: string + request_timeout: + type: string + retention_posture: + type: string + target_keys: + items: + type: string + nullable: true + type: array + tier: + type: string + training_posture: + type: string + required: + - kind + - enabled + - endpoint + - allowed_identifiers + - target_keys + - allow_sensitive_targets + - retention_posture + - training_posture + - refresh_interval + - request_timeout + - max_retries + - max_requests_per_run + - max_requests_per_day + type: object PersonFactClaim: properties: claim_key: @@ -7839,6 +8663,33 @@ components: nullable: true type: array type: object + PersonNetwork: + properties: + depth: + format: int64 + type: integer + edges: + items: + $ref: "#/components/schemas/NetworkEdge" + nullable: true + type: array + nodes: + items: + $ref: "#/components/schemas/NetworkNode" + nullable: true + type: array + root_person_id: + format: int64 + type: integer + truncated: + type: boolean + required: + - root_person_id + - depth + - truncated + - nodes + - edges + type: object PersonProfile: properties: display_name: @@ -8311,6 +9162,28 @@ components: - roles - directions type: object + ProviderCredentialResponse: + properties: + credential_id: + type: string + pending_restart: + type: boolean + state: + $ref: "#/components/schemas/SecretSettingState" + required: + - credential_id + - state + - pending_restart + type: object + ProviderCredentialWriteRequest: + additionalProperties: false + properties: + value: + minLength: 1 + type: string + required: + - value + type: object ProviderUsage: properties: billed_units: @@ -9089,6 +9962,12 @@ components: properties: configured: type: boolean + source: + enum: + - stored + - environment + - none + type: string required: - configured type: object @@ -9242,15 +10121,27 @@ components: type: object Setting: properties: + credential_id: + type: string + description: + type: string group: enum: - browser - server - archive + - sync + - logging - search - sources + - attachments + - activity + - backup + - enrichment - integrations type: string + inherited: + type: boolean key: type: string kind: @@ -9262,6 +10153,8 @@ components: - string_array - secret type: string + label: + type: string options: items: type: string @@ -9275,14 +10168,31 @@ components: $ref: "#/components/schemas/SecretSettingState" testable: type: boolean + validation: + $ref: "#/components/schemas/SettingValidation" value: $ref: "#/components/schemas/SettingValue" required: - key - group + - label + - description - kind - restart_required type: object + SettingGroup: + properties: + description: + type: string + id: + type: string + label: + type: string + required: + - id + - label + - description + type: object SettingUpdate: additionalProperties: false properties: @@ -9295,6 +10205,19 @@ components: required: - key type: object + SettingValidation: + properties: + hint: + type: string + maximum: + format: double + type: number + minimum: + format: double + type: number + required: + type: boolean + type: object SettingValue: oneOf: - additionalProperties: false @@ -9339,8 +10262,6 @@ components: SettingsPatchRequest: additionalProperties: false properties: - confirm_api_key_restart: - type: boolean updates: items: $ref: "#/components/schemas/SettingUpdate" @@ -9351,14 +10272,27 @@ components: type: object SettingsResponse: properties: + credential_etag: + type: string + groups: + items: + $ref: "#/components/schemas/SettingGroup" + type: array pending_restart: type: boolean + person_enrichment_providers: + items: + $ref: "#/components/schemas/PersonEnrichmentProviderSetting" + nullable: true + type: array settings: items: $ref: "#/components/schemas/Setting" type: array required: + - groups - settings + - credential_etag - pending_restart type: object SimilarSearchResponse: @@ -12108,6 +13042,101 @@ paths: summary: Publish a person to CardDAV tags: - API + /api/v1/carddav/runs: + get: + operationId: listCardDAVRuns + parameters: + - description: Maximum runs to return (default 25, max 100) + in: query + name: limit + schema: + format: int64 + maximum: 100 + minimum: 1 + type: integer + - description: Return runs with IDs lower than this cursor + in: query + name: before_id + schema: + format: int64 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CardDAVRunsResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List CardDAV synchronization runs + tags: + - API + /api/v1/carddav/status: + get: + operationId: getCardDAVStatus + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CardDAVStatusResponse" + description: OK + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + headers: + Retry-After: + description: Seconds until CardDAV retry is safe + schema: + format: int64 + minimum: 0 + type: integer + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get CardDAV synchronization status + tags: + - API /api/v1/carddav/sync: post: operationId: syncCardDAV @@ -16411,10 +17440,198 @@ paths: schema: format: int64 type: integer - - description: External task ID - in: path - name: task_id - required: true + - description: External task ID + in: path + name: task_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TaskLinkMutationResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Unlink a task from an archived email + tags: + - API + /api/v1/multimodal/build: + post: + operationId: startVisualAttachmentBuild + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/VisualBuildRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Consent and run one bounded visual attachment embedding pass + tags: + - API + /api/v1/multimodal/retire: + post: + operationId: retireVisualAttachmentGeneration + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/VisualRetireRequest" + required: true + responses: + "204": + description: No Content + default: + description: Error + security: + - apiKey: [] + summary: Retire the visual attachment generation + tags: + - Search + /api/v1/multimodal/retry: + post: + operationId: retryVisualAttachmentOwner + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/VisualRetryRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Retry one visual attachment owner + tags: + - API + /api/v1/multimodal/run: + post: + operationId: resumeVisualAttachmentBuild + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Resume one bounded visual attachment embedding pass + tags: + - API + /api/v1/multimodal/status: + get: + operationId: getVisualAttachmentStatus + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get visual attachment embedding status + tags: + - API + /api/v1/operations/runs: + get: + operationId: listOperationRuns + parameters: + - description: Exact operation kind + in: query + name: kind + schema: + enum: + - carddav_sync + - document_embedding + - document_extraction + - message_embedding + - person_embedding + - person_enrichment + - person_sweep + - source_sync + - visual_embedding + type: string + - description: Exact semantic operation lane + in: query + name: lane + schema: + enum: + - contacts + - documents + - messages + - person_facts + - visual_attachments + type: string + - description: Exact operation state + in: query + name: state + schema: + enum: + - cancelled + - failed + - partial + - queued + - running + - succeeded + type: string + - description: Maximum runs to return (default 25, max 100) + in: query + name: limit + schema: + format: int64 + maximum: 100 + minimum: 1 + type: integer + - description: Opaque cursor bound to this archive and the exact kind, lane, and state filters + in: query + name: cursor schema: type: string responses: @@ -16422,102 +17639,78 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TaskLinkMutationResponse" + $ref: "#/components/schemas/OperationRunsResponse" description: OK - default: + "400": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - security: - - apiKey: [] - summary: Unlink a task from an archived email - tags: - - API - /api/v1/multimodal/build: - post: - operationId: startVisualAttachmentBuild - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/VisualBuildRequest" - required: true - responses: - "200": + "500": content: application/json: schema: - $ref: "#/components/schemas/Status" - description: OK - default: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - security: - - apiKey: [] - summary: Consent and run one bounded visual attachment embedding pass - tags: - - API - /api/v1/multimodal/retire: - post: - operationId: retireVisualAttachmentGeneration - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/VisualRetireRequest" - required: true - responses: - "204": - description: No Content default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" description: Error security: - apiKey: [] - summary: Retire the visual attachment generation + summary: List normalized operation history tags: - - Search - /api/v1/multimodal/retry: - post: - operationId: retryVisualAttachmentOwner - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/VisualRetryRequest" - required: true + - API + /api/v1/operations/runs/{id}: + get: + operationId: getOperationRun + parameters: + - description: Opaque archive-bound operation run ID + in: path + name: id + required: true + schema: + type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/Status" + $ref: "#/components/schemas/OperationRunDetail" description: OK - default: + "400": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - security: - - apiKey: [] - summary: Retry one visual attachment owner - tags: - - API - /api/v1/multimodal/run: - post: - operationId: resumeVisualAttachmentBuild - responses: - "200": + "404": content: application/json: schema: - $ref: "#/components/schemas/Status" - description: OK + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error default: content: application/json: @@ -16526,18 +17719,18 @@ paths: description: Error security: - apiKey: [] - summary: Resume one bounded visual attachment embedding pass + summary: Get one normalized operation run tags: - API - /api/v1/multimodal/status: + /api/v1/operations/status: get: - operationId: getVisualAttachmentStatus + operationId: getOperationStatus responses: "200": content: application/json: schema: - $ref: "#/components/schemas/Status" + $ref: "#/components/schemas/OperationStatusResponse" description: OK default: content: @@ -16547,7 +17740,7 @@ paths: description: Error security: - apiKey: [] - summary: Get visual attachment embedding status + summary: Get normalized operation lane status tags: - API /api/v1/organizations: @@ -17808,6 +19001,98 @@ paths: summary: Promote a participant cluster to a durable person tags: - API + /api/v1/people/directory: + get: + description: Returns one stable, non-sensitive page of promoted durable people. + operationId: listDirectoryPeople + parameters: + - description: Lexical query over person names, contact points, and organizations + in: query + name: q + schema: + type: string + - description: Opaque cursor returned by the previous Directory page + in: query + name: cursor + schema: + type: string + - description: Maximum rows to return (default 50, max 100) + in: query + name: limit + schema: + format: int64 + type: integer + - description: "Current contact state: active or inactive" + in: query + name: contact_state + schema: + type: string + - description: Current person category + in: query + name: category + schema: + type: string + - description: Current organization + in: query + name: organization + schema: + type: string + - description: Primary communication channel + in: query + name: primary_channel + schema: + type: string + - description: Return people contacted at or after this RFC3339 timestamp + in: query + name: last_contact_after + schema: + format: date-time + type: string + - description: Return people contacted at or before this RFC3339 timestamp + in: query + name: last_contact_before + schema: + format: date-time + type: string + - description: "Directory order: name, last_contact_desc, or last_contact_asc" + in: query + name: sort + schema: + enum: + - name + - last_contact_desc + - last_contact_asc + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DirectoryPeopleResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Query durable people for the Directory + tags: + - API /api/v1/people/search: post: description: Searches only the curated person vector corpus and returns durable person roots in relevance order. @@ -19048,37 +20333,96 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/PersonMergeResult" + $ref: "#/components/schemas/PersonMergeResult" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Merge one durable person profile into another + tags: + - API + /api/v1/people/{id}/merges: + get: + operationId: listPersonMerges + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Maximum results + in: query + name: limit + schema: + format: int64 + type: integer + - description: Results to skip + in: query + name: offset + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonMergesResponse" description: OK - headers: - ETag: - description: Strong person profile revision tag for optimistic concurrency - schema: - type: string - "400": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error "404": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "409": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "428": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error "500": content: application/json: @@ -19099,12 +20443,13 @@ paths: description: Error security: - apiKey: [] - summary: Merge one durable person profile into another + summary: List merge history for a durable person tags: - API - /api/v1/people/{id}/merges: + /api/v1/people/{id}/network: get: - operationId: listPersonMerges + description: Returns declared person relationships and employments only; archive-derived associations are excluded. + operationId: getPersonNetwork parameters: - description: Durable person ID in: path @@ -19113,32 +20458,34 @@ paths: schema: format: int64 type: integer - - description: Maximum results + - description: Breadth-first depth (default 1, minimum 1, maximum 3) in: query - name: limit + name: depth schema: + default: 1 format: int64 + maximum: 3 + minimum: 1 type: integer - - description: Results to skip + - description: Include ended relationships and employment records in: query - name: offset + name: include_ended schema: - format: int64 - type: integer + type: boolean responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PersonMergesResponse" + $ref: "#/components/schemas/PersonNetwork" description: OK - "404": + "400": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "500": + "404": content: application/json: schema: @@ -19158,7 +20505,7 @@ paths: description: Error security: - apiKey: [] - summary: List merge history for a durable person + summary: Get a bounded curated person network tags: - API /api/v1/people/{id}/notes/append: @@ -21652,6 +22999,10 @@ paths: $ref: "#/components/schemas/SettingsResponse" description: OK headers: + Credential-ETag: + description: Strong content hash for the independent provider credential store + schema: + type: string ETag: description: Strong content hash for optimistic concurrency schema: @@ -21690,6 +23041,10 @@ paths: $ref: "#/components/schemas/SettingsResponse" description: OK headers: + Credential-ETag: + description: Strong content hash for the independent provider credential store + schema: + type: string ETag: description: Strong content hash for optimistic concurrency schema: @@ -21735,6 +23090,227 @@ paths: summary: Update browser-managed settings tags: - API + /api/v1/settings/person-enrichment/providers/{name}: + put: + operationId: putSettingsPersonEnrichmentProvider + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PersonEnrichmentProviderUpdate" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/SettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Create or update one named person-enrichment provider + tags: + - API + /api/v1/settings/provider-credentials/{credential_id}: + delete: + operationId: deleteSettingsProviderCredential + parameters: + - in: path + name: credential_id + required: true + schema: + type: string + - description: Strong ETag for the provider credential store + in: header + name: If-Match + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderCredentialResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Clear a stored provider credential + tags: + - API + put: + operationId: putSettingsProviderCredential + parameters: + - in: path + name: credential_id + required: true + schema: + type: string + - description: Strong ETag for the provider credential store + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderCredentialWriteRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderCredentialResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Set a write-only provider credential + tags: + - API /api/v1/sources/status: get: operationId: listSourceStatus diff --git a/web/src/App.svelte b/web/src/App.svelte index 5426db272..e7d4d510b 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -76,8 +76,13 @@ {:else if shellMounted} - {#snippet settings()} - + {#snippet settings(cardDAVRequest, onCardDAVRequestConsumed)} + {/snippet} {:else if session.error !== undefined} diff --git a/web/src/App.test.ts b/web/src/App.test.ts index 0553c84ad..e1e1275a2 100644 --- a/web/src/App.test.ts +++ b/web/src/App.test.ts @@ -242,6 +242,100 @@ describe('application foundation', () => { ).toBe('true')); window.history.replaceState(null, '', '/'); }); + + it('threads repeated same-conflict handoffs through AppShell as distinct exactly-once live events', async () => { + window.history.replaceState(null, '', `/?explore=${encodeURIComponent(JSON.stringify({ + workspace: 'directory', directoryPersonID: 7 + }))}`); + const detailRequests: number[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = new URL(request.url).pathname; + if (path === '/api/session') { + return Response.json({ auth_mode: 'loopback', https: false, plain_http_warning: false }); + } + if (path === '/api/v1/settings') return settingsResponse('system', '"etag-carddav"'); + if (path === '/api/v1/people/directory') return Response.json({ people: [{ + id: 7, revision: 1, display_name: 'Synthetic Person', contact_state: 'active', + categories: [], organizations: [] + }] }); + if (path === '/api/v1/carddav/publications/7') return Response.json({ + person_id: 7, state: 'conflict', desired: true, conflict_id: 41, + address_book: { id: 5, name: 'Synthetic contacts' } + }); + if (path === '/api/v1/carddav/conflicts/41') { + detailRequests.push(41); + return Response.json({ + id: 41, + address_book: { id: 5, name: 'Synthetic contacts' }, + status: 'unresolved', + base: { state: 'present', display_name: 'Base person', emails: [], phones: [] }, + local: { state: 'present', display_name: 'Local person', emails: [], phones: [] }, + remote: { state: 'deleted', emails: [], phones: [] }, + allowed_resolutions: ['keep_local', 'keep_remote'], + created_at: '2026-08-28T10:00:00Z', updated_at: '2026-08-28T11:00:00Z' + }); + } + if (path === '/api/v1/carddav/status') return Response.json({ + configured: true, available: true, credential_configured: true, + enabled: false, scheduled: false, schedule: '' + }); + if (path === '/api/v1/carddav/books') return Response.json({ books: [] }); + if (path === '/api/v1/carddav/runs') return Response.json({ runs: [] }); + if (path === '/api/v1/carddav/conflicts') return Response.json({ conflicts: [] }); + if (path === '/api/v1/people/7') return Response.json({ + id: 7, revision: 1, display_name: 'Synthetic Person', participant_ids: [], vcard_uid: '', + created_at: '2026-08-01T00:00:00Z', updated_at: '2026-08-01T00:00:00Z' + }); + if (path === '/api/v1/people/7/profile') return Response.json({ + person: { id: 7, revision: 1, display_name: 'Synthetic Person' }, + names: [], contact_points: [], addresses: [], dates: [], categories: [], media: [] + }); + if (path === '/api/v1/people/7/attributes') return Response.json({ person_id: 7, attributes: [] }); + if (path === '/api/v1/people/7/contact-state') return Response.json({ + person_id: 7, cadence_status: 'current', interaction_count: 0, + computed_at: '2026-08-28T10:00:00Z', stale: false + }); + if (path === '/api/v1/people/7/employments') return Response.json({ employments: [] }); + if (path === '/api/v1/people/7/relationships') return Response.json({ relationships: [] }); + if (path === '/api/v1/people/7/days') return Response.json({ person_id: 7, days: [], total_count: 0 }); + if (path === '/api/v1/people/7/files/search') return Response.json({ + files: [], total_count: 0, cache_revision: 'synthetic', search_provenance: {} + }); + if (path === '/api/v1/people/7/merges') return Response.json({ merges: [], limit: 100, offset: 0 }); + if (path === '/api/v1/explore') return Response.json({ + rows: [], total_count: 0, cache_revision: 'synthetic', search_provenance: {} + }); + return Response.json({}, { status: 404 }); + }); + const session = createSessionController(fetchFn); + const rendered = render(App, { session }); + + await session.bootstrap(); + await fireEvent.click(await screen.findByRole('button', { name: 'Review CardDAV conflict 41' })); + + const detailHeading = await screen.findByRole('heading', { name: 'Conflict comparison' }); + await waitFor(() => expect(document.activeElement).toBe(detailHeading)); + expect(detailRequests).toEqual([41]); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByRole('status', { name: 'Operation status' }).textContent) + .toBe('Opening CardDAV conflict 41 in Settings.'); + const firstAnnouncement = screen.getByRole('status', { name: 'Operation status' }).firstElementChild; + expect(firstAnnouncement).not.toBeNull(); + + window.history.back(); + await new Promise((resolve) => window.addEventListener('popstate', resolve, { once: true })); + await fireEvent.click(await screen.findByRole('button', { name: 'Review CardDAV conflict 41' })); + + const repeatedStatus = screen.getByRole('status', { name: 'Operation status' }); + await waitFor(() => expect(repeatedStatus.firstElementChild).not.toBe(firstAnnouncement)); + expect(repeatedStatus.textContent).toBe('Opening CardDAV conflict 41 in Settings.'); + expect(screen.getAllByRole('status', { name: 'Operation status' })).toHaveLength(1); + await waitFor(() => expect(detailRequests).toEqual([41, 41])); + + rendered.unmount(); + window.history.replaceState(null, '', '/'); + }); }); function settingsResponse(theme: string, etag: string, pendingRestart = false): Response { diff --git a/web/src/lib/api/generated/schema.d.ts b/web/src/lib/api/generated/schema.d.ts index 9d5e808d9..1f98858ee 100644 --- a/web/src/lib/api/generated/schema.d.ts +++ b/web/src/lib/api/generated/schema.d.ts @@ -368,6 +368,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/carddav/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List CardDAV synchronization runs */ + get: operations["listCardDAVRuns"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/carddav/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get CardDAV synchronization status */ + get: operations["getCardDAVStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/carddav/sync": { parameters: { query?: never; @@ -1822,6 +1856,57 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/operations/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List normalized operation history */ + get: operations["listOperationRuns"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/operations/runs/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one normalized operation run */ + get: operations["getOperationRun"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/operations/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get normalized operation lane status */ + get: operations["getOperationStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/organizations": { parameters: { query?: never; @@ -2131,6 +2216,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/people/directory": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Query durable people for the Directory + * @description Returns one stable, non-sensitive page of promoted durable people. + */ + get: operations["listDirectoryPeople"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/people/search": { parameters: { query?: never; @@ -2429,6 +2534,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/people/{id}/network": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a bounded curated person network + * @description Returns declared person relationships and employments only; archive-derived associations are excluded. + */ + get: operations["getPersonNetwork"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/people/{id}/notes/append": { parameters: { query?: never; @@ -2979,6 +3104,41 @@ export interface paths { patch: operations["patchSettings"]; trace?: never; }; + "/api/v1/settings/person-enrichment/providers/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Create or update one named person-enrichment provider */ + put: operations["putSettingsPersonEnrichmentProvider"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/settings/provider-credentials/{credential_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Set a write-only provider credential */ + put: operations["putSettingsProviderCredential"]; + post?: never; + /** Clear a stored provider credential */ + delete: operations["deleteSettingsProviderCredential"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/sources/status": { parameters: { query?: never; @@ -3693,6 +3853,13 @@ export interface components { } & { [key: string]: unknown; }; + CardDAVAddressBookIdentityResponse: { + /** Format: int64 */ + id: number; + name: string; + } & { + [key: string]: unknown; + }; CardDAVBookResponse: { /** Format: int64 */ id: number; @@ -3716,49 +3883,78 @@ export interface components { [key: string]: unknown; }; CardDAVConflictDetailResponse: { - /** Format: int64 */ - address_book_id: number; - href: string; + address_book: components["schemas"]["CardDAVAddressBookIdentityResponse"]; + allowed_resolutions: ("keep_local" | "keep_remote")[]; + base: components["schemas"]["CardDAVContactSummaryResponse"]; + /** Format: date-time */ + created_at: string; /** Format: int64 */ id: number; - local_tombstone: boolean; - local_vcard?: string; - remote_tombstone: boolean; - remote_vcard?: string; - status: string; + local: components["schemas"]["CardDAVContactSummaryResponse"]; + remote: components["schemas"]["CardDAVContactSummaryResponse"]; + /** @enum {string} */ + resolution?: "keep_local" | "keep_remote"; + /** Format: date-time */ + resolved_at?: string; + /** @enum {string} */ + status: "unresolved" | "resolved"; + /** Format: date-time */ + updated_at: string; } & { [key: string]: unknown; }; CardDAVConflictResolutionResponse: { /** Format: int64 */ id: number; - status: string; + /** @enum {string} */ + resolution: "keep_local" | "keep_remote"; + /** @enum {string} */ + status: "resolved"; } & { [key: string]: unknown; }; CardDAVConflictResponse: { - /** Format: int64 */ - address_book_id: number; - href: string; + address_book: components["schemas"]["CardDAVAddressBookIdentityResponse"]; + allowed_resolutions: ("keep_local" | "keep_remote")[]; /** Format: int64 */ id: number; - local_tombstone: boolean; - remote_tombstone: boolean; - status: string; + /** @enum {string} */ + local_state: "present" | "deleted" | "unavailable"; + /** @enum {string} */ + remote_state: "present" | "deleted" | "unavailable"; + /** @enum {string} */ + status: "unresolved" | "resolved"; + /** Format: date-time */ + updated_at: string; } & { [key: string]: unknown; }; CardDAVConflictsResponse: { - conflicts: components["schemas"]["CardDAVConflictResponse"][] | null; + conflicts: components["schemas"]["CardDAVConflictResponse"][]; + } & { + [key: string]: unknown; + }; + CardDAVContactSummaryResponse: { + display_name?: string; + emails: string[]; + phones: string[]; + /** @enum {string} */ + state: "present" | "deleted" | "unavailable"; + truncated?: boolean; } & { [key: string]: unknown; }; CardDAVPublicationResponse: { + address_book?: components["schemas"]["CardDAVAddressBookIdentityResponse"]; + /** Format: int64 */ + conflict_id?: number; desired: boolean; - href?: string; - pending_operation?: string; + /** @enum {string} */ + pending_operation?: "create" | "update" | "delete"; /** Format: int64 */ person_id: number; + /** @enum {string} */ + state: "unpublished" | "published" | "pending" | "conflict"; } & { [key: string]: unknown; }; @@ -3766,6 +3962,63 @@ export interface components { /** @enum {string} */ choice: "keep_local" | "keep_remote"; }; + CardDAVRunResponse: { + /** Format: int64 */ + books: number; + /** Format: int64 */ + created: number; + /** @enum {string} */ + error_code?: "cancelled" | "retry_after" | "authentication_failed" | "upstream_failed" | "safety_limit" | "sync_failed" | "unsafe_error_redacted" | "daemon_restarted"; + error_message?: string; + /** Format: date-time */ + finished_at?: string; + full: boolean; + /** Format: int64 */ + id: number; + /** Format: int64 */ + removed: number; + /** Format: date-time */ + started_at: string; + /** @enum {string} */ + state: "running" | "succeeded" | "failed" | "cancelled" | "partial"; + /** @enum {string} */ + trigger: "manual" | "scheduled"; + /** Format: int64 */ + updated: number; + } & { + [key: string]: unknown; + }; + CardDAVRunsResponse: { + /** Format: int64 */ + next_before_id?: number; + runs: components["schemas"]["CardDAVRunResponse"][]; + } & { + [key: string]: unknown; + }; + CardDAVStatusAccount: { + base_url: string; + username: string; + } & { + [key: string]: unknown; + }; + CardDAVStatusResponse: { + account?: components["schemas"]["CardDAVStatusAccount"]; + active?: components["schemas"]["CardDAVRunResponse"]; + available: boolean; + configured: boolean; + credential_configured: boolean; + enabled: boolean; + latest?: components["schemas"]["CardDAVRunResponse"]; + latest_successful?: components["schemas"]["CardDAVRunResponse"]; + /** Format: date-time */ + next_scheduled_at?: string; + /** @enum {string} */ + repair_reason?: "account_missing" | "credential_missing" | "credential_mismatch" | "credential_unavailable" | "runtime_unavailable"; + schedule: string; + scheduled: boolean; + } & { + [key: string]: unknown; + }; CardDAVSyncRequest: { full?: boolean; }; @@ -4291,6 +4544,27 @@ export interface components { } & { [key: string]: unknown; }; + DirectoryPeopleResponse: { + next_cursor?: string; + people: components["schemas"]["DirectoryPersonSummary"][]; + } & { + [key: string]: unknown; + }; + DirectoryPersonSummary: { + categories: string[]; + contact_state: string; + display_name?: string; + /** Format: int64 */ + id: number; + /** Format: date-time */ + last_contact_at?: string; + organizations: string[]; + primary_channel?: string; + /** Format: int64 */ + revision: number; + } & { + [key: string]: unknown; + }; DiscoverError: { code: string; message: string; @@ -5521,6 +5795,31 @@ export interface components { } & { [key: string]: unknown; }; + NetworkEdge: { + end_date?: string; + id: string; + /** @enum {string} */ + kind: "relationship" | "employment"; + label: string; + relationship_type_slug?: string; + source_node_id: string; + start_date?: string; + target_node_id: string; + } & { + [key: string]: unknown; + }; + NetworkNode: { + /** Format: int64 */ + entity_id: number; + /** Format: int64 */ + hop: number; + id: string; + /** @enum {string} */ + kind: "person" | "organization"; + label: string; + } & { + [key: string]: unknown; + }; OperationHealth: { busy: boolean; label?: string; @@ -5529,29 +5828,123 @@ export interface components { } & { [key: string]: unknown; }; - Organization: { - /** Format: date-time */ - created_at: string; - description?: string; - /** Format: int64 */ - id: number; - kind: string; - /** Format: int64 */ - merged_into_id?: number; - name: string; - primary_domain?: string; - /** Format: date-time */ - retired_at?: string; - /** Format: int64 */ - revision: number; - /** Format: date-time */ - updated_at: string; + OperationLaneStatus: { + active?: components["schemas"]["OperationRunSummary"]; + configured: boolean; + /** @enum {string} */ + history_availability: "available" | "unavailable"; + /** @enum {string} */ + kind: "carddav_sync" | "document_embedding" | "document_extraction" | "message_embedding" | "person_embedding" | "person_enrichment" | "person_sweep" | "source_sync" | "visual_embedding"; + /** @enum {string} */ + lane: "contacts" | "documents" | "messages" | "person_facts" | "visual_attachments"; + latest?: components["schemas"]["OperationRunSummary"]; + latest_successful?: components["schemas"]["OperationRunSummary"]; + /** @enum {string} */ + related_status?: "listSourceStatus" | "getDocumentIndexStatus" | "getDocumentVectorStatus" | "getVisualAttachmentStatus" | "getCardDAVStatus"; + supported_actions: ("carddav_sync" | "visual_build" | "visual_resume")[]; + unavailable_code?: string; } & { [key: string]: unknown; }; - OrganizationAddress: { - address_kind: string; - country_code?: string; + OperationPublicCounter: { + /** @enum {string} */ + name: "processed" | "added" | "updated" | "item_errors" | "attempted" | "succeeded" | "failed" | "projected_writes" | "books" | "created" | "removed"; + /** @enum {string} */ + unit: "messages" | "people" | "writes" | "books" | "contacts"; + /** Format: int64 */ + value: number; + } & { + [key: string]: unknown; + }; + OperationPublicError: { + /** @enum {string} */ + code: "source_sync_failed" | "person_sweep_failed" | "policy" | "budget" | "lease_lost" | "rate_limited" | "timeout" | "provider_http" | "invalid_output" | "archive_gap" | "internal" | "cancelled" | "retry_after" | "authentication_failed" | "upstream_failed" | "safety_limit" | "sync_failed" | "unsafe_error_redacted" | "daemon_restarted" | "carddav_sync_failed"; + message: string; + } & { + [key: string]: unknown; + }; + OperationRunDetail: { + counters: components["schemas"]["OperationPublicCounter"][]; + error?: components["schemas"]["OperationPublicError"]; + /** Format: date-time */ + finished_at?: string; + id: string; + /** @enum {string} */ + kind: "carddav_sync" | "document_embedding" | "document_extraction" | "message_embedding" | "person_embedding" | "person_enrichment" | "person_sweep" | "source_sync" | "visual_embedding"; + /** @enum {string} */ + lane: "contacts" | "documents" | "messages" | "person_facts" | "visual_attachments"; + /** Format: date-time */ + started_at: string; + /** @enum {string} */ + state: "cancelled" | "failed" | "partial" | "queued" | "running" | "succeeded"; + /** @enum {string} */ + trigger?: "manual" | "scheduled"; + } & { + [key: string]: unknown; + }; + OperationRunSummary: { + counters: components["schemas"]["OperationPublicCounter"][]; + error?: components["schemas"]["OperationPublicError"]; + /** Format: date-time */ + finished_at?: string; + id: string; + /** @enum {string} */ + kind: "carddav_sync" | "document_embedding" | "document_extraction" | "message_embedding" | "person_embedding" | "person_enrichment" | "person_sweep" | "source_sync" | "visual_embedding"; + /** @enum {string} */ + lane: "contacts" | "documents" | "messages" | "person_facts" | "visual_attachments"; + /** Format: date-time */ + started_at: string; + /** @enum {string} */ + state: "cancelled" | "failed" | "partial" | "queued" | "running" | "succeeded"; + /** @enum {string} */ + trigger?: "manual" | "scheduled"; + } & { + [key: string]: unknown; + }; + OperationRunsResponse: { + next_cursor?: string; + runs: components["schemas"]["OperationRunSummary"][]; + unavailable_kinds: components["schemas"]["OperationUnavailableKind"][]; + } & { + [key: string]: unknown; + }; + OperationStatusResponse: { + lanes: components["schemas"]["OperationLaneStatus"][]; + } & { + [key: string]: unknown; + }; + OperationUnavailableKind: { + /** @enum {string} */ + kind: "carddav_sync" | "document_embedding" | "document_extraction" | "message_embedding" | "person_embedding" | "person_enrichment" | "person_sweep" | "source_sync" | "visual_embedding"; + /** @enum {string} */ + lane: "contacts" | "documents" | "messages" | "person_facts" | "visual_attachments"; + unavailable_code: string; + } & { + [key: string]: unknown; + }; + Organization: { + /** Format: date-time */ + created_at: string; + description?: string; + /** Format: int64 */ + id: number; + kind: string; + /** Format: int64 */ + merged_into_id?: number; + name: string; + primary_domain?: string; + /** Format: date-time */ + retired_at?: string; + /** Format: int64 */ + revision: number; + /** Format: date-time */ + updated_at: string; + } & { + [key: string]: unknown; + }; + OrganizationAddress: { + address_kind: string; + country_code?: string; country_name?: string; envelope: components["schemas"]["ValueEnvelope"]; extended_address?: string; @@ -5598,6 +5991,7 @@ export interface components { /** @enum {string} */ source: "user" | "carddav_import" | "vcard_import" | "archive_observation" | "extraction" | "enrichment" | "system"; source_ref?: string | null; + source_resource_uid?: string | null; street_address?: string | null; timezone?: string | null; type_label?: string | null; @@ -5677,6 +6071,7 @@ export interface components { /** @enum {string} */ source: "user" | "carddav_import" | "vcard_import" | "archive_observation" | "extraction" | "enrichment" | "system"; source_ref?: string | null; + source_resource_uid?: string | null; type_label?: string | null; type_tokens?: string[] | null; vcard_altid?: string | null; @@ -5720,6 +6115,7 @@ export interface components { /** @enum {string} */ source: "user" | "carddav_import" | "vcard_import" | "archive_observation" | "extraction" | "enrichment" | "system"; source_ref?: string | null; + source_resource_uid?: string | null; type_label?: string | null; type_tokens?: string[] | null; uri?: string | null; @@ -5761,6 +6157,7 @@ export interface components { /** @enum {string} */ source: "user" | "carddav_import" | "vcard_import" | "archive_observation" | "extraction" | "enrichment" | "system"; source_ref?: string | null; + source_resource_uid?: string | null; type_label?: string | null; type_tokens?: string[] | null; vcard_altid?: string | null; @@ -5802,6 +6199,7 @@ export interface components { /** @enum {string} */ source: "user" | "carddav_import" | "vcard_import" | "archive_observation" | "extraction" | "enrichment" | "system"; source_ref?: string | null; + source_resource_uid?: string | null; type_label?: string | null; type_tokens?: string[] | null; uri?: string | null; @@ -5836,6 +6234,7 @@ export interface components { /** @enum {string} */ source: "user" | "carddav_import" | "vcard_import" | "archive_observation" | "extraction" | "enrichment" | "system"; source_ref?: string | null; + source_resource_uid?: string | null; type_label?: string | null; type_tokens?: string[] | null; vcard_altid?: string | null; @@ -6219,6 +6618,63 @@ export interface components { } & { [key: string]: unknown; }; + PersonEnrichmentProviderSetting: { + allow_sensitive_targets: boolean; + allowed_identifiers: string[] | null; + credential?: components["schemas"]["SecretSettingState"]; + credential_id: string; + enabled: boolean; + endpoint: string; + /** @enum {string} */ + kind: "exa" | "sixtyfour"; + max_job_age: string; + /** Format: int64 */ + max_requests_per_day: number; + /** Format: int64 */ + max_requests_per_run: number; + /** Format: int64 */ + max_retries: number; + mode?: string; + name: string; + /** Format: int64 */ + num_results?: number; + poll_endpoint?: string; + poll_interval: string; + refresh_interval: string; + request_timeout: string; + retention_posture: string; + target_keys: string[] | null; + tier?: string; + training_posture: string; + } & { + [key: string]: unknown; + }; + PersonEnrichmentProviderUpdate: { + allow_sensitive_targets: boolean; + allowed_identifiers: string[] | null; + enabled: boolean; + endpoint: string; + /** @enum {string} */ + kind: "exa" | "sixtyfour"; + max_job_age?: string; + /** Format: int64 */ + max_requests_per_day: number; + /** Format: int64 */ + max_requests_per_run: number; + /** Format: int64 */ + max_retries: number; + mode?: string; + /** Format: int64 */ + num_results?: number; + poll_endpoint?: string; + poll_interval?: string; + refresh_interval: string; + request_timeout: string; + retention_posture: string; + target_keys: string[] | null; + tier?: string; + training_posture: string; + }; PersonFactClaim: { claim_key: string; confidence: components["schemas"]["ConfidenceInputs"]; @@ -6702,6 +7158,17 @@ export interface components { add?: components["schemas"]["PersonNameInputRequest"][] | null; supersede?: number[] | null; }; + PersonNetwork: { + /** Format: int64 */ + depth: number; + edges: components["schemas"]["NetworkEdge"][] | null; + nodes: components["schemas"]["NetworkNode"][] | null; + /** Format: int64 */ + root_person_id: number; + truncated: boolean; + } & { + [key: string]: unknown; + }; PersonProfile: { display_name?: string; /** Format: int64 */ @@ -6916,6 +7383,16 @@ export interface components { } & { [key: string]: unknown; }; + ProviderCredentialResponse: { + credential_id: string; + pending_restart: boolean; + state: components["schemas"]["SecretSettingState"]; + } & { + [key: string]: unknown; + }; + ProviderCredentialWriteRequest: { + value: string; + }; ProviderUsage: { /** Format: double */ billed_units: number; @@ -7274,6 +7751,8 @@ export interface components { }; SecretSettingState: { configured: boolean; + /** @enum {string} */ + source?: "stored" | "environment" | "none"; } & { [key: string]: unknown; }; @@ -7339,25 +7818,47 @@ export interface components { pinned: boolean; }; Setting: { + credential_id?: string; + description: string; /** @enum {string} */ - group: "browser" | "server" | "archive" | "search" | "sources" | "integrations"; + group: "browser" | "server" | "archive" | "sync" | "logging" | "search" | "sources" | "attachments" | "activity" | "backup" | "enrichment" | "integrations"; + inherited?: boolean; key: string; /** @enum {string} */ kind: "string" | "integer" | "number" | "boolean" | "string_array" | "secret"; + label: string; options?: string[] | null; read_only?: boolean; restart_required: boolean; secret?: components["schemas"]["SecretSettingState"]; testable?: boolean; + validation?: components["schemas"]["SettingValidation"]; value?: components["schemas"]["SettingValue"]; } & { [key: string]: unknown; }; + SettingGroup: { + description: string; + id: string; + label: string; + } & { + [key: string]: unknown; + }; SettingUpdate: { key: string; secret?: components["schemas"]["SecretSettingUpdate"]; value?: components["schemas"]["SettingValue"]; }; + SettingValidation: { + hint?: string; + /** Format: double */ + maximum?: number; + /** Format: double */ + minimum?: number; + required?: boolean; + } & { + [key: string]: unknown; + }; SettingValue: { string: string; } | { @@ -7372,11 +7873,13 @@ export interface components { strings: string[]; }; SettingsPatchRequest: { - confirm_api_key_restart?: boolean; updates: components["schemas"]["SettingUpdate"][]; }; SettingsResponse: { + credential_etag: string; + groups: components["schemas"]["SettingGroup"][]; pending_restart: boolean; + person_enrichment_providers?: components["schemas"]["PersonEnrichmentProviderSetting"][] | null; settings: components["schemas"]["Setting"][]; } & { [key: string]: unknown; @@ -9601,6 +10104,116 @@ export interface operations { }; }; }; + listCardDAVRuns: { + parameters: { + query?: { + /** @description Maximum runs to return (default 25, max 100) */ + limit?: number; + /** @description Return runs with IDs lower than this cursor */ + before_id?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CardDAVRunsResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getCardDAVStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CardDAVStatusResponse"]; + }; + }; + /** @description Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + /** @description Seconds until CardDAV retry is safe */ + "Retry-After"?: number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; syncCardDAV: { parameters: { query?: never; @@ -14637,17 +15250,19 @@ export interface operations { }; }; }; - listOrganizations: { + listOperationRuns: { parameters: { query?: { - /** @description Maximum results */ + /** @description Exact operation kind */ + kind?: "carddav_sync" | "document_embedding" | "document_extraction" | "message_embedding" | "person_embedding" | "person_enrichment" | "person_sweep" | "source_sync" | "visual_embedding"; + /** @description Exact semantic operation lane */ + lane?: "contacts" | "documents" | "messages" | "person_facts" | "visual_attachments"; + /** @description Exact operation state */ + state?: "cancelled" | "failed" | "partial" | "queued" | "running" | "succeeded"; + /** @description Maximum runs to return (default 25, max 100) */ limit?: number; - /** @description Results to skip */ - offset?: number; - /** @description Include retired organizations */ - include_retired?: boolean; - /** @description Normalized-name search */ - q?: string; + /** @description Opaque cursor bound to this archive and the exact kind, lane, and state filters */ + cursor?: string; }; header?: never; path?: never; @@ -14661,7 +15276,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["OrganizationsResponse"]; + "application/json": components["schemas"]["OperationRunsResponse"]; }; }; /** @description Error */ @@ -14674,6 +15289,15 @@ export interface operations { }; }; /** @description Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ 503: { headers: { [name: string]: unknown; @@ -14693,30 +15317,25 @@ export interface operations { }; }; }; - createOrganization: { + getOperationRun: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["OrganizationCreateBody"]; + path: { + /** @description Opaque archive-bound operation run ID */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - /** @description Strong organization revision tag for optimistic concurrency */ - ETag?: string; - /** @description Canonical URL of the created organization */ - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Organization"]; + "application/json": components["schemas"]["OperationRunDetail"]; }; }; /** @description Error */ @@ -14729,7 +15348,7 @@ export interface operations { }; }; /** @description Error */ - 503: { + 404: { headers: { [name: string]: unknown; }; @@ -14738,7 +15357,165 @@ export interface operations { }; }; /** @description Error */ - default: { + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getOperationStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OperationStatusResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + listOrganizations: { + parameters: { + query?: { + /** @description Maximum results */ + limit?: number; + /** @description Results to skip */ + offset?: number; + /** @description Include retired organizations */ + include_retired?: boolean; + /** @description Normalized-name search */ + q?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationsResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createOrganization: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OrganizationCreateBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + /** @description Strong organization revision tag for optimistic concurrency */ + ETag?: string; + /** @description Canonical URL of the created organization */ + Location?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Organization"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { headers: { [name: string]: unknown; }; @@ -16093,6 +16870,74 @@ export interface operations { }; }; }; + listDirectoryPeople: { + parameters: { + query?: { + /** @description Lexical query over person names, contact points, and organizations */ + q?: string; + /** @description Opaque cursor returned by the previous Directory page */ + cursor?: string; + /** @description Maximum rows to return (default 50, max 100) */ + limit?: number; + /** @description Current contact state: active or inactive */ + contact_state?: string; + /** @description Current person category */ + category?: string; + /** @description Current organization */ + organization?: string; + /** @description Primary communication channel */ + primary_channel?: string; + /** @description Return people contacted at or after this RFC3339 timestamp */ + last_contact_after?: string; + /** @description Return people contacted at or before this RFC3339 timestamp */ + last_contact_before?: string; + /** @description Directory order: name, last_contact_desc, or last_contact_asc */ + sort?: "name" | "last_contact_desc" | "last_contact_asc"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DirectoryPeopleResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; searchPeople: { parameters: { query?: never; @@ -17558,11 +18403,13 @@ export interface operations { }; }; }; - appendPersonNote: { + getPersonNetwork: { parameters: { query?: { - /** @description Validate and preview without writing */ - dry_run?: boolean; + /** @description Breadth-first depth (default 1, minimum 1, maximum 3) */ + depth?: number; + /** @description Include ended relationships and employment records */ + include_ended?: boolean; }; header?: never; path: { @@ -17571,11 +18418,7 @@ export interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AppendPersonNoteRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -17583,7 +18426,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PersonAttributeWrite"]; + "application/json": components["schemas"]["PersonNetwork"]; }; }; /** @description Error */ @@ -17605,15 +18448,6 @@ export interface operations { }; }; /** @description Error */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Error */ 503: { headers: { [name: string]: unknown; @@ -17633,9 +18467,12 @@ export interface operations { }; }; }; - getPersonStructuredProfile: { + appendPersonNote: { parameters: { - query?: never; + query?: { + /** @description Validate and preview without writing */ + dry_run?: boolean; + }; header?: never; path: { /** @description Durable person ID */ @@ -17643,17 +18480,19 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AppendPersonNoteRequest"]; + }; + }; responses: { /** @description OK */ 200: { headers: { - /** @description Strong person profile revision tag for optimistic concurrency */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["StructuredPersonProfile"]; + "application/json": components["schemas"]["PersonAttributeWrite"]; }; }; /** @description Error */ @@ -17675,6 +18514,15 @@ export interface operations { }; }; /** @description Error */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ 503: { headers: { [name: string]: unknown; @@ -17694,11 +18542,72 @@ export interface operations { }; }; }; - patchPersonStructuredProfile: { + getPersonStructuredProfile: { parameters: { query?: never; - header: { - /** @description Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. */ + header?: never; + path: { + /** @description Durable person ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Strong person profile revision tag for optimistic concurrency */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StructuredPersonProfile"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + patchPersonStructuredProfile: { + parameters: { + query?: never; + header: { + /** @description Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. */ "If-Match": string; }; path: { @@ -20169,6 +21078,8 @@ export interface operations { /** @description OK */ 200: { headers: { + /** @description Strong content hash for the independent provider credential store */ + "Credential-ETag"?: string; /** @description Strong content hash for optimistic concurrency */ ETag?: string; [name: string]: unknown; @@ -20203,6 +21114,93 @@ export interface operations { "application/json": components["schemas"]["SettingsPatchRequest"]; }; }; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Strong content hash for the independent provider credential store */ + "Credential-ETag"?: string; + /** @description Strong content hash for optimistic concurrency */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingsResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 412: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + putSettingsPersonEnrichmentProvider: { + parameters: { + query?: never; + header: { + /** @description Strong config ETag returned by the latest settings read */ + "If-Match": string; + }; + path: { + name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PersonEnrichmentProviderUpdate"]; + }; + }; responses: { /** @description OK */ 200: { @@ -20225,6 +21223,15 @@ export interface operations { }; }; /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ 409: { headers: { [name: string]: unknown; @@ -20271,6 +21278,172 @@ export interface operations { }; }; }; + putSettingsProviderCredential: { + parameters: { + query?: never; + header: { + /** @description Strong ETag for the provider credential store */ + "If-Match": string; + }; + path: { + credential_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProviderCredentialWriteRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Strong content hash for optimistic concurrency */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderCredentialResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 412: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + deleteSettingsProviderCredential: { + parameters: { + query?: never; + header: { + /** @description Strong ETag for the provider credential store */ + "If-Match": string; + }; + path: { + credential_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Strong content hash for optimistic concurrency */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderCredentialResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 412: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; listSourceStatus: { parameters: { query?: { diff --git a/web/src/lib/carddav/conflicts-controller.svelte.test.ts b/web/src/lib/carddav/conflicts-controller.svelte.test.ts new file mode 100644 index 000000000..ba7e87fc7 --- /dev/null +++ b/web/src/lib/carddav/conflicts-controller.svelte.test.ts @@ -0,0 +1,623 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../api/client'; +import { + CardDAVConflictsController, + type CardDAVConflictChoice +} from './conflicts-controller.svelte'; + +const forbiddenMarkers = { + raw_vcard: 'BEGIN:VCARD\nFN:FORBIDDEN-VCARD\nEND:VCARD', + url: 'https://forbidden-url.example.test/dav', + href: '/forbidden-href/contact.vcf', + etag: 'forbidden-etag', + hash: 'forbidden-hash', + uid: 'forbidden-uid', + header: 'Authorization: forbidden-header', + credential: 'forbidden-credential' +}; + +function listItem(id: number, overrides: Record = {}) { + return { + id, + address_book: { id: 7, name: 'Synthetic contacts', ...forbiddenMarkers }, + status: 'unresolved', + local_state: 'present', + remote_state: 'deleted', + allowed_resolutions: ['keep_local', 'keep_remote'], + updated_at: '2026-08-28T10:00:00Z', + ...forbiddenMarkers, + ...overrides + }; +} + +function summary(state: 'present' | 'deleted' | 'unavailable', overrides: Record = {}) { + return { + state, + emails: [], + phones: [], + ...forbiddenMarkers, + ...overrides + }; +} + +function detail(id: number, overrides: Record = {}) { + return { + id, + address_book: { id: 7, name: 'Synthetic contacts', ...forbiddenMarkers }, + status: 'unresolved', + base: summary('present', { display_name: 'Synthetic Base', emails: ['base@example.test'] }), + local: summary('present', { display_name: 'Synthetic Local', phones: ['+1 555 0100'] }), + remote: summary('deleted'), + allowed_resolutions: ['keep_local', 'keep_remote'], + created_at: '2026-08-27T10:00:00Z', + updated_at: '2026-08-28T10:00:00Z', + ...forbiddenMarkers, + ...overrides + }; +} + +function requestOf(input: RequestInfo | URL): Request { + return input instanceof Request ? input : new Request(input); +} + +function deferredResponse() { + let resolve!: (response: Response) => void; + const promise = new Promise((settle) => { resolve = settle; }); + return { promise, resolve }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('CardDAVConflictsController', () => { + it('loads the compact queue and exact selected detail while projecting only safe fields', async () => { + const paths: string[] = []; + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + paths.push(path); + if (path === '/api/v1/carddav/conflicts') { + return Response.json({ conflicts: [listItem(41)] }); + } + if (path === '/api/v1/carddav/conflicts/41') return Response.json(detail(41)); + throw new Error(`Unexpected GET ${path}`); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + + await controller.load(); + await controller.select(41); + + expect(paths).toEqual(['/api/v1/carddav/conflicts', '/api/v1/carddav/conflicts/41']); + expect(controller.conflicts).toEqual([{ + id: 41, + address_book: { id: 7, name: 'Synthetic contacts' }, + status: 'unresolved', + local_state: 'present', + remote_state: 'deleted', + allowed_resolutions: ['keep_local', 'keep_remote'], + updated_at: '2026-08-28T10:00:00Z' + }]); + expect(controller.selectedDetail).toEqual({ + id: 41, + address_book: { id: 7, name: 'Synthetic contacts' }, + status: 'unresolved', + base: { state: 'present', display_name: 'Synthetic Base', emails: ['base@example.test'], phones: [] }, + local: { state: 'present', display_name: 'Synthetic Local', emails: [], phones: ['+1 555 0100'] }, + remote: { state: 'deleted', emails: [], phones: [] }, + allowed_resolutions: ['keep_local', 'keep_remote'], + created_at: '2026-08-27T10:00:00Z', + updated_at: '2026-08-28T10:00:00Z' + }); + expect(JSON.stringify({ list: controller.conflicts, detail: controller.selectedDetail })).not.toMatch( + /FORBIDDEN-VCARD|forbidden-(?:url|href|etag|hash|uid|header|credential)/i + ); + controller.destroy(); + }); + + it('keeps list and detail errors independent while invalidating failed detail refreshes', async () => { + let listReads = 0; + let detailReads = 0; + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path === '/api/v1/carddav/conflicts') { + listReads += 1; + if (listReads === 1) return Response.json({ conflicts: [listItem(41)] }); + if (listReads === 2) return Response.json({ error: 'unavailable', message: forbiddenMarkers.raw_vcard }, { status: 503 }); + return Response.json({ conflicts: [] }); + } + detailReads += 1; + if (detailReads === 1) return Response.json(detail(41)); + if (detailReads === 2) return Response.json({ error: 'unavailable', message: forbiddenMarkers.credential }, { status: 503 }); + return Response.json(detail(41, { updated_at: '2026-08-28T11:00:00Z' })); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + await controller.select(41); + + await controller.retryList(); + expect(controller.conflicts).toHaveLength(1); + expect(controller.listError).toBe('Unable to load CardDAV conflicts.'); + expect(controller.detailError).toBeNull(); + await controller.retrySelectedState(); + expect(controller.selectedDetail).toBeUndefined(); + expect(controller.isResolutionAllowed('keep_local')).toBe(false); + expect(controller.detailError).toBe('Unable to load CardDAV conflict details.'); + expect(controller.listError).toBe('Unable to load CardDAV conflicts.'); + + await controller.retryList(); + await controller.retrySelectedState(); + expect(controller.conflicts).toEqual([]); + expect(controller.selectedDetail?.updated_at).toBe('2026-08-28T11:00:00Z'); + expect([controller.listError, controller.detailError]).toEqual([null, null]); + expect([listReads, detailReads]).toEqual([3, 3]); + controller.destroy(); + }); + + it('treats typed unavailable detail as global optional state without retaining conflict data', async () => { + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path === '/api/v1/carddav/conflicts') return Response.json({ conflicts: [listItem(41)] }); + return Response.json({ + error: 'carddav_unavailable', + message: forbiddenMarkers.credential + }, { status: 503 }); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + + await controller.select(41); + + expect(controller.unavailable).toBe(true); + expect(controller.conflicts).toEqual([]); + expect(controller.selectedID).toBeUndefined(); + expect(controller.selectedDetail).toBeUndefined(); + expect(controller.detailError).toBeNull(); + controller.destroy(); + }); + + it('detail unavailable aborts and invalidates an older list lane until an explicit recovery load', async () => { + const olderList = deferredResponse(); + let listReads = 0; + let olderListSignal: AbortSignal | undefined; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (path === '/api/v1/carddav/conflicts') { + listReads += 1; + if (listReads === 1) return Response.json({ conflicts: [listItem(41)] }); + if (listReads === 2) { + olderListSignal = request.signal; + return olderList.promise; + } + return Response.json({ conflicts: [listItem(42)] }); + } + return Response.json({ error: 'carddav_unavailable' }, { status: 503 }); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + + const staleList = controller.retryList(); + await controller.select(41); + + expect(olderListSignal?.aborted).toBe(true); + expect(controller.unavailable).toBe(true); + expect(controller.conflicts).toEqual([]); + expect([controller.listLoading, controller.detailLoading]).toEqual([false, false]); + + olderList.resolve(Response.json({ conflicts: [listItem(99)] })); + await staleList; + expect(controller.unavailable).toBe(true); + expect(controller.conflicts).toEqual([]); + expect([controller.listError, controller.detailError]).toEqual([null, null]); + + await controller.load(); + expect(controller.unavailable).toBe(false); + expect(controller.conflicts.map(({ id }) => id)).toEqual([42]); + expect([controller.listLoading, controller.detailLoading]).toEqual([false, false]); + controller.destroy(); + }); + + it('list unavailable aborts and settles an older detail lane until an explicit recovery load', async () => { + const olderDetail = deferredResponse(); + let listReads = 0; + let detailReads = 0; + let olderDetailSignal: AbortSignal | undefined; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (path === '/api/v1/carddav/conflicts') { + listReads += 1; + if (listReads === 2) return Response.json({ error: 'carddav_unavailable' }, { status: 503 }); + return Response.json({ conflicts: [listItem(41)] }); + } + detailReads += 1; + if (detailReads === 1) { + olderDetailSignal = request.signal; + return olderDetail.promise; + } + return Response.json(detail(41)); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + + const staleDetail = controller.select(41); + await controller.retryList(); + + expect(olderDetailSignal?.aborted).toBe(true); + expect(controller.unavailable).toBe(true); + expect(controller.selectedID).toBeUndefined(); + expect([controller.listLoading, controller.detailLoading]).toEqual([false, false]); + + olderDetail.resolve(Response.json(detail(41))); + await staleDetail; + expect(controller.unavailable).toBe(true); + expect(controller.selectedDetail).toBeUndefined(); + expect([controller.listError, controller.detailError]).toEqual([null, null]); + + await controller.load(); + expect(controller.unavailable).toBe(false); + await controller.select(41); + expect(controller.selectedDetail?.id).toBe(41); + expect([controller.listLoading, controller.detailLoading]).toEqual([false, false]); + controller.destroy(); + }); + + it('ignores an older selected-detail response after a newer conflict is selected', async () => { + const older = deferredResponse(); + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path.endsWith('/41')) return older.promise; + if (path.endsWith('/42')) return Response.json(detail(42, { address_book: { id: 8, name: 'Second book' } })); + return Response.json({ conflicts: [listItem(41), listItem(42)] }); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + + const first = controller.select(41); + await controller.select(42); + older.resolve(Response.json(detail(41))); + await first; + + expect(controller.selectedID).toBe(42); + expect(controller.selectedDetail?.id).toBe(42); + expect(controller.selectedDetail?.address_book.name).toBe('Second book'); + controller.destroy(); + }); + + it('sends one exact generated choice and applies a clean receipt once', async () => { + const post = deferredResponse(); + const requestFacts: Array<{ method: string; path: string; choice?: string }> = []; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (request.method === 'POST') { + const body = await request.clone().json(); + requestFacts.push({ method: request.method, path, choice: String(body.choice) }); + return post.promise; + } + requestFacts.push({ method: request.method, path }); + if (path.endsWith('/41')) return Response.json(detail(41)); + return Response.json({ conflicts: [listItem(41), listItem(42)] }); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + await controller.select(41); + + const first = controller.resolve(41, 'keep_local'); + const duplicate = await controller.resolve(41, 'keep_local'); + expect(controller.pendingResolutionID).toBe(41); + expect(duplicate).toEqual({ kind: 'ignored' }); + post.resolve(Response.json({ id: 41, status: 'resolved', resolution: 'keep_local' })); + expect(await first).toEqual({ kind: 'resolved' }); + + expect(requestFacts.filter(({ method }) => method === 'POST')).toEqual([{ + method: 'POST', path: '/api/v1/carddav/conflicts/41/resolve', choice: 'keep_local' + }]); + expect(controller.conflicts.map(({ id }) => id)).toEqual([42]); + expect(controller.selectedDetail?.status).toBe('resolved'); + expect(controller.selectedDetail?.allowed_resolutions).toEqual([]); + expect(controller.announcement).toBe('CardDAV conflict 41 resolved by keeping the local card.'); + expect(controller.focusRequest).toEqual({ key: 1, conflictID: 42 }); + controller.destroy(); + }); + + it.each([ + { name: 'typed stale response', response: () => Response.json({ error: 'carddav_conflict_stale', message: forbiddenMarkers.href }, { status: 409 }) }, + { name: 'typed pending response', response: () => Response.json({ error: 'carddav_conflict_pending', message: forbiddenMarkers.etag }, { status: 409 }) }, + { name: 'server ambiguity', response: () => Response.json({ error: 'unavailable', message: forbiddenMarkers.header }, { status: 503 }) }, + { name: 'transport ambiguity', response: () => new TypeError('connection reset') } + ])('performs detail/list GET-only reconciliation after $name', async ({ response }) => { + let posts = 0; + let listReads = 0; + let detailReads = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (request.method === 'POST') { + posts += 1; + const result = response(); + if (result instanceof Error) throw result; + return result; + } + if (path === '/api/v1/carddav/conflicts') { + listReads += 1; + return Response.json({ conflicts: [listItem(41, { updated_at: listReads === 1 ? '2026-08-28T10:00:00Z' : '2026-08-28T12:00:00Z' })] }); + } + detailReads += 1; + return Response.json(detail(41, { + updated_at: detailReads === 1 ? '2026-08-28T10:00:00Z' : '2026-08-28T12:00:00Z', + allowed_resolutions: detailReads === 1 ? ['keep_local', 'keep_remote'] : ['keep_remote'] + })); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + await controller.select(41); + + expect(await controller.resolve(41, 'keep_local')).toEqual({ kind: 'reconciled' }); + + expect([posts, listReads, detailReads]).toEqual([1, 2, 2]); + expect(controller.selectedDetail?.updated_at).toBe('2026-08-28T12:00:00Z'); + expect(controller.selectedDetail?.allowed_resolutions).toEqual(['keep_remote']); + expect(controller.resolutionUnknown).toBe(false); + expect(controller.resolutionError).toContain('Current conflict state was refreshed'); + expect(JSON.stringify(controller)).not.toMatch(/forbidden-(?:href|etag|header)/i); + controller.destroy(); + }); + + it('locks mutation after failed reconciliation and a GET-only retry recovers both snapshots', async () => { + let posts = 0; + let listReads = 0; + let detailReads = 0; + let reconciliationFails = true; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (request.method === 'POST') { + posts += 1; + return Response.json({ error: 'carddav_conflict_stale' }, { status: 409 }); + } + if (path === '/api/v1/carddav/conflicts') { + listReads += 1; + if (listReads > 1 && reconciliationFails) return Response.json({ error: 'unavailable' }, { status: 503 }); + return Response.json({ conflicts: [listItem(41, { updated_at: listReads === 1 ? '2026-08-28T10:00:00Z' : '2026-08-28T13:00:00Z' })] }); + } + detailReads += 1; + if (detailReads > 1 && reconciliationFails) return Response.json({ error: 'unavailable' }, { status: 503 }); + return Response.json(detail(41, { updated_at: detailReads === 1 ? '2026-08-28T10:00:00Z' : '2026-08-28T13:00:00Z' })); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + await controller.select(41); + + expect(await controller.resolve(41, 'keep_local')).toEqual({ kind: 'unknown' }); + expect(controller.resolutionUnknown).toBe(true); + expect(controller.selectedDetail?.updated_at).toBe('2026-08-28T10:00:00Z'); + expect(controller.conflicts[0]?.updated_at).toBe('2026-08-28T10:00:00Z'); + expect(controller.isResolutionAllowed('keep_local')).toBe(false); + expect(await controller.resolve(41, 'keep_local')).toEqual({ kind: 'ignored' }); + + reconciliationFails = false; + await controller.retrySelectedState(); + expect(controller.resolutionUnknown).toBe(false); + expect(controller.selectedDetail?.updated_at).toBe('2026-08-28T13:00:00Z'); + expect(controller.conflicts[0]?.updated_at).toBe('2026-08-28T13:00:00Z'); + expect(controller.isResolutionAllowed('keep_local')).toBe(true); + expect([posts, listReads, detailReads]).toEqual([1, 3, 3]); + controller.destroy(); + }); + + it('reports typed CardDAV unavailability as unknown after an ambiguous resolution', async () => { + let listReads = 0; + let detailReads = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (request.method === 'POST') { + return Response.json({ error: 'carddav_conflict_stale' }, { status: 409 }); + } + if (path === '/api/v1/carddav/conflicts') { + listReads += 1; + if (listReads > 1) return Response.json({ error: 'carddav_unavailable' }, { status: 503 }); + return Response.json({ conflicts: [listItem(41)] }); + } + detailReads += 1; + if (detailReads > 1) return Response.json({ error: 'carddav_unavailable' }, { status: 503 }); + return Response.json(detail(41)); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + await controller.select(41); + + expect(await controller.resolve(41, 'keep_local')).toEqual({ kind: 'unknown' }); + expect(controller.unavailable).toBe(true); + expect(controller.announcement).toBeNull(); + expect([listReads, detailReads]).toEqual([2, 2]); + controller.destroy(); + }); + + it.each([ + { name: 'the selected row', selectedDuringRetry: 41 }, + { name: 'a different row', selectedDuringRetry: 42 } + ])('keeps retry reconciliation atomic when selecting $name', async ({ selectedDuringRetry }) => { + const retryList = deferredResponse(); + const retryDetail = deferredResponse(); + let posts = 0; + let listReads = 0; + let detailReads = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (request.method === 'POST') { + posts += 1; + return Response.json({ error: 'carddav_conflict_stale' }, { status: 409 }); + } + if (path === '/api/v1/carddav/conflicts') { + listReads += 1; + if (listReads === 1) return Response.json({ conflicts: [listItem(41), listItem(42)] }); + if (listReads === 2) return Response.json({ error: 'unavailable' }, { status: 503 }); + return retryList.promise; + } + detailReads += 1; + if (detailReads === 1) return Response.json(detail(41)); + if (detailReads === 2) return Response.json({ error: 'unavailable' }, { status: 503 }); + if (detailReads === 3) return retryDetail.promise; + return Response.json(detail(Number(path.split('/').at(-1)), { updated_at: '2026-08-28T15:00:00Z' })); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + await controller.select(41); + expect(await controller.resolve(41, 'keep_local')).toEqual({ kind: 'unknown' }); + + const retry = controller.retrySelectedState(); + await vi.waitFor(() => expect([listReads, detailReads]).toEqual([3, 3])); + await controller.select(selectedDuringRetry); + retryList.resolve(Response.json({ + conflicts: [ + listItem(41, { updated_at: '2026-08-28T14:00:00Z' }), + listItem(42, { updated_at: '2026-08-28T14:00:00Z' }) + ] + })); + retryDetail.resolve(Response.json(detail(41, { updated_at: '2026-08-28T14:00:00Z' }))); + await retry; + + expect(controller.selectedID).toBe(41); + expect(controller.selectedDetail?.updated_at).toBe('2026-08-28T14:00:00Z'); + expect(controller.conflicts[0]?.updated_at).toBe('2026-08-28T14:00:00Z'); + expect(controller.listLoading).toBe(false); + expect(controller.detailLoading).toBe(false); + expect(controller.resolutionUnknown).toBe(false); + expect([posts, listReads, detailReads]).toEqual([1, 3, 3]); + + if (selectedDuringRetry === 42) { + await controller.select(42); + expect(controller.selectedID).toBe(42); + expect(controller.selectedDetail?.id).toBe(42); + expect(controller.selectedDetail?.updated_at).toBe('2026-08-28T15:00:00Z'); + } + controller.destroy(); + }); + + it('reports a refreshed resolved detail without asking for another choice after an ambiguous POST', async () => { + let posts = 0; + let listReads = 0; + let detailReads = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (request.method === 'POST') { + posts += 1; + return Response.json({ error: 'unavailable' }, { status: 503 }); + } + if (path === '/api/v1/carddav/conflicts') { + listReads += 1; + return Response.json({ conflicts: listReads === 1 ? [listItem(41)] : [] }); + } + detailReads += 1; + if (detailReads === 1) return Response.json(detail(41)); + return Response.json(detail(41, { + status: 'resolved', + resolution: 'keep_remote', + resolved_at: '2026-08-28T14:00:00Z', + allowed_resolutions: [], + updated_at: '2026-08-28T14:00:00Z' + })); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + await controller.select(41); + + expect(await controller.resolve(41, 'keep_local')).toEqual({ kind: 'reconciled' }); + + expect([posts, listReads, detailReads]).toEqual([1, 2, 2]); + expect(controller.selectedDetail?.status).toBe('resolved'); + expect(controller.selectedDetail?.resolution).toBe('keep_remote'); + expect(controller.selectedDetail?.allowed_resolutions).toEqual([]); + expect(controller.isResolutionAllowed('keep_local')).toBe(false); + expect(controller.resolutionUnknown).toBe(false); + expect(controller.resolutionError).toBeNull(); + expect(controller.announcement).toBe('CardDAV conflict 41 state was refreshed and is already resolved.'); + controller.destroy(); + }); + + it('consumes a keyed requested conflict once', async () => { + let detailReads = 0; + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path.endsWith('/41')) { + detailReads += 1; + return Response.json(detail(41)); + } + return Response.json({ conflicts: [listItem(41)] }); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + + expect(await controller.openRequestedConflict({ conflictID: 41, key: 3 })).toBe(true); + expect(await controller.openRequestedConflict({ conflictID: 41, key: 3 })).toBe(false); + expect(await controller.openRequestedConflict({ conflictID: 41, key: 4 })).toBe(true); + expect(detailReads).toBe(2); + controller.destroy(); + }); + + it('aborts owned reads and resolution transport and blocks every late write after destroy', async () => { + const mutation = deferredResponse(); + const signals: AbortSignal[] = []; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + signals.push(request.signal); + if (request.method === 'POST') return mutation.promise; + if (path.endsWith('/41')) return Response.json(detail(41)); + return Response.json({ conflicts: [listItem(41)] }); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + await controller.select(41); + const resolution = controller.resolve(41, 'keep_remote'); + + controller.destroy(); + expect(signals.at(-1)?.aborted).toBe(true); + mutation.resolve(Response.json({ id: 41, status: 'resolved', resolution: 'keep_remote' })); + await resolution; + + expect(controller.conflicts.map(({ id }) => id)).toEqual([41]); + expect(controller.selectedDetail?.status).toBe('unresolved'); + expect(controller.announcement).toBeNull(); + expect(controller.focusRequest).toBeUndefined(); + }); + + it('aborts independent list and detail reads and retains both confirmed surfaces after destroy', async () => { + const lateList = deferredResponse(); + const lateDetail = deferredResponse(); + const lateSignals: AbortSignal[] = []; + let listReads = 0; + let detailReads = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (path === '/api/v1/carddav/conflicts') { + listReads += 1; + if (listReads === 1) return Response.json({ conflicts: [listItem(41)] }); + lateSignals.push(request.signal); + return lateList.promise; + } + detailReads += 1; + if (detailReads === 1) return Response.json(detail(41)); + lateSignals.push(request.signal); + return lateDetail.promise; + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + await controller.select(41); + + const listRetry = controller.retryList(); + const detailRetry = controller.retrySelectedState(); + await vi.waitFor(() => expect(lateSignals).toHaveLength(2)); + controller.destroy(); + + expect(lateSignals.every((signal) => signal.aborted)).toBe(true); + lateList.resolve(Response.json({ conflicts: [] })); + lateDetail.resolve(Response.json(detail(41, { updated_at: '2026-08-29T10:00:00Z' }))); + await Promise.all([listRetry, detailRetry]); + expect(controller.conflicts.map(({ id }) => id)).toEqual([41]); + expect(controller.selectedDetail?.updated_at).toBe('2026-08-28T10:00:00Z'); + }); +}); diff --git a/web/src/lib/carddav/conflicts-controller.svelte.ts b/web/src/lib/carddav/conflicts-controller.svelte.ts new file mode 100644 index 000000000..f23000f7f --- /dev/null +++ b/web/src/lib/carddav/conflicts-controller.svelte.ts @@ -0,0 +1,459 @@ +import type { APIClient } from '../api/client'; +import type { components } from '../api/generated/schema'; + +type GeneratedAddressBook = components['schemas']['CardDAVAddressBookIdentityResponse']; +type GeneratedConflict = components['schemas']['CardDAVConflictResponse']; +type GeneratedDetail = components['schemas']['CardDAVConflictDetailResponse']; +type GeneratedSummary = components['schemas']['CardDAVContactSummaryResponse']; + +export type CardDAVConflictChoice = GeneratedDetail['allowed_resolutions'][number]; +export type CardDAVConflictAddressBook = Pick; +export type CardDAVConflictSummary = Pick & + Partial>; +export type CardDAVConflictListItem = Pick & { + address_book: CardDAVConflictAddressBook; + }; +export type CardDAVConflictDetail = Pick & + Partial> & { + address_book: CardDAVConflictAddressBook; + base: CardDAVConflictSummary; + local: CardDAVConflictSummary; + remote: CardDAVConflictSummary; + }; + +export interface CardDAVConflictFocusRequest { + key: number; + conflictID?: number; + detail?: boolean; +} + +export interface CardDAVRequestedConflict { + conflictID: number; + key: number; +} + +export type CardDAVConflictResolutionOutcome = + | { kind: 'resolved' } + | { kind: 'reconciled' } + | { kind: 'unknown' } + | { kind: 'error' } + | { kind: 'ignored' }; + +type Snapshot = + | { ok: true; value: T } + | { ok: false; unavailable: boolean }; + +const STALE_RESOLUTION_CODES = new Set(['carddav_conflict_stale', 'carddav_conflict_pending']); + +export class CardDAVConflictsController { + conflicts = $state([]); + selectedID = $state(); + selectedDetail = $state(); + + listLoading = $state(true); + detailLoading = $state(false); + pendingResolutionID = $state(); + + listError = $state(null); + detailError = $state(null); + unavailable = $state(false); + resolutionError = $state(null); + resolutionUnknown = $state(false); + announcement = $state(null); + focusRequest = $state(); + + private readonly client: APIClient; + private disposed = false; + private generation = 1; + private listRequestGeneration = 0; + private detailRequestGeneration = 0; + private mutationGeneration = 0; + private reconciliationGeneration = 0; + private focusGeneration = 0; + private reconciliationPending = false; + private consumedRequestKey?: number; + private listAbort?: AbortController; + private detailAbort?: AbortController; + private mutationAbort?: AbortController; + + constructor(client: APIClient) { + this.client = client; + } + + async load(): Promise { + await this.readList(); + } + + async retryList(): Promise { + await this.readList(); + } + + async select(id: number): Promise { + if (this.disposed || this.unavailable || this.reconciliationPending || id <= 0) return; + if (this.selectedID !== id) { + this.selectedID = id; + this.selectedDetail = undefined; + this.detailError = null; + this.resolutionError = null; + this.resolutionUnknown = false; + } + this.announcement = null; + await this.readDetail(id); + } + + async retrySelectedState(): Promise { + if (this.selectedID === undefined || this.disposed || this.unavailable || this.reconciliationPending) return; + if (this.resolutionUnknown) { + await this.reconcile(this.selectedID); + return; + } + await this.readDetail(this.selectedID); + } + + async openRequestedConflict(request: CardDAVRequestedConflict): Promise { + if (this.disposed || this.reconciliationPending || request.key === this.consumedRequestKey || request.conflictID <= 0) return false; + this.consumedRequestKey = request.key; + await this.select(request.conflictID); + if (!this.disposed && !this.unavailable) { + this.focusRequest = { key: ++this.focusGeneration, conflictID: request.conflictID, detail: true }; + } + return true; + } + + isResolutionAllowed(choice: CardDAVConflictChoice): boolean { + const detail = this.selectedDetail; + if (!detail) return false; + return ( + !this.disposed && + !this.unavailable && + !this.resolutionUnknown && + this.pendingResolutionID === undefined && + detail.id === this.selectedID && + detail.status === 'unresolved' && + detail.allowed_resolutions.includes(choice) + ); + } + + async resolve(id: number, choice: CardDAVConflictChoice): Promise { + if (id !== this.selectedID || !this.isResolutionAllowed(choice)) return { kind: 'ignored' }; + const context = this.generation; + const mutation = ++this.mutationGeneration; + const controller = new AbortController(); + this.mutationAbort = controller; + this.pendingResolutionID = id; + this.resolutionError = null; + this.announcement = null; + try { + const result = await this.client.POST('/api/v1/carddav/conflicts/{id}/resolve', { + params: { path: { id } }, + body: { choice }, + signal: controller.signal + }); + if (!this.currentMutation(context, mutation, controller.signal)) return { kind: 'ignored' }; + if (result.data && + result.data.id === id && + result.data.status === 'resolved' && + result.data.resolution === choice) { + this.applyResolution(id, choice); + return { kind: 'resolved' }; + } + if (result.data || isAmbiguousResolution(result.response.status, result.error?.error)) { + return await this.reconcileResolution(id, context, mutation); + } + this.resolutionError = 'Unable to resolve this CardDAV conflict.'; + return { kind: 'error' }; + } catch { + if (!this.currentMutation(context, mutation, controller.signal)) return { kind: 'ignored' }; + return await this.reconcileResolution(id, context, mutation); + } finally { + if (this.currentMutation(context, mutation)) { + if (this.mutationAbort === controller) this.mutationAbort = undefined; + this.pendingResolutionID = undefined; + } + } + } + + destroy(): void { + if (this.disposed) return; + this.disposed = true; + this.generation += 1; + this.listRequestGeneration += 1; + this.detailRequestGeneration += 1; + this.mutationGeneration += 1; + this.listAbort?.abort(); + this.detailAbort?.abort(); + this.mutationAbort?.abort(); + this.listAbort = undefined; + this.detailAbort = undefined; + this.mutationAbort = undefined; + } + + private async readList(): Promise { + if (this.disposed) return false; + const context = this.generation; + const request = ++this.listRequestGeneration; + this.listAbort?.abort(); + const controller = new AbortController(); + this.listAbort = controller; + this.listLoading = true; + this.listError = null; + const snapshot = await this.fetchList(controller.signal); + if (!this.currentList(context, request, controller.signal)) return false; + if (snapshot.ok) { + this.unavailable = false; + this.conflicts = snapshot.value; + } else if (snapshot.unavailable) { + this.applyUnavailable(); + } else { + this.listError = 'Unable to load CardDAV conflicts.'; + } + if (this.listAbort === controller) this.listAbort = undefined; + this.listLoading = false; + return snapshot.ok; + } + + private async readDetail(id: number): Promise { + if (this.disposed) return false; + const context = this.generation; + const request = ++this.detailRequestGeneration; + this.detailAbort?.abort(); + const controller = new AbortController(); + this.detailAbort = controller; + this.detailLoading = true; + this.detailError = null; + const snapshot = await this.fetchDetail(id, controller.signal); + if (!this.currentDetail(context, request, controller.signal) || this.selectedID !== id) return false; + if (snapshot.ok && snapshot.value.id === id) { + this.selectedDetail = snapshot.value; + } else if (!snapshot.ok && snapshot.unavailable) { + this.applyUnavailable(); + } else { + this.selectedDetail = undefined; + this.detailError = 'Unable to load CardDAV conflict details.'; + } + if (this.detailAbort === controller) this.detailAbort = undefined; + this.detailLoading = false; + return snapshot.ok && snapshot.value.id === id; + } + + private async reconcileResolution( + id: number, + context: number, + mutation: number + ): Promise { + const reconciled = await this.reconcile(id); + if (this.unavailable && !this.disposed) return { kind: 'unknown' }; + if (!this.currentMutation(context, mutation)) return { kind: 'ignored' }; + if (reconciled) { + if (this.selectedDetail?.id === id && this.selectedDetail.status === 'resolved') { + this.resolutionError = null; + this.announcement = `CardDAV conflict ${id} state was refreshed and is already resolved.`; + return { kind: 'reconciled' }; + } + this.resolutionError = 'Current conflict state was refreshed after the resolution result was uncertain. Choose again to resolve it.'; + return { kind: 'reconciled' }; + } + this.resolutionUnknown = true; + this.resolutionError = 'Current CardDAV conflict state is unknown. Retry state before resolving it.'; + return { kind: 'unknown' }; + } + + private async reconcile(id: number): Promise { + if (this.disposed || this.reconciliationPending) return false; + const context = this.generation; + const reconciliation = ++this.reconciliationGeneration; + const listRequest = ++this.listRequestGeneration; + const detailRequest = ++this.detailRequestGeneration; + this.listAbort?.abort(); + this.detailAbort?.abort(); + const listController = new AbortController(); + const detailController = new AbortController(); + this.listAbort = listController; + this.detailAbort = detailController; + this.reconciliationPending = true; + this.listLoading = true; + this.detailLoading = true; + this.listError = null; + this.detailError = null; + try { + const [list, detail] = await Promise.all([ + this.fetchList(listController.signal), + this.fetchDetail(id, detailController.signal) + ]); + if (!this.currentList(context, listRequest, listController.signal) || + !this.currentDetail(context, detailRequest, detailController.signal)) return false; + + const validDetail = detail.ok && detail.value.id === id; + const unavailable = (!list.ok && list.unavailable) || (!detail.ok && detail.unavailable); + if (unavailable) { + this.applyUnavailable(); + } else if (list.ok && validDetail) { + this.unavailable = false; + this.conflicts = list.value; + if (this.selectedID === id) this.selectedDetail = detail.value; + this.resolutionUnknown = false; + this.resolutionError = null; + } else { + if (!list.ok) this.listError = 'Unable to load CardDAV conflicts.'; + if (!validDetail) this.detailError = 'Unable to load CardDAV conflict details.'; + this.resolutionUnknown = true; + } + return !unavailable && list.ok && validDetail; + } finally { + if (this.currentList(context, listRequest)) { + if (this.listAbort === listController) this.listAbort = undefined; + this.listLoading = false; + } + if (this.currentDetail(context, detailRequest)) { + if (this.detailAbort === detailController) this.detailAbort = undefined; + this.detailLoading = false; + } + if (this.current(context) && this.reconciliationGeneration === reconciliation) { + this.reconciliationPending = false; + } + } + } + + private async fetchList(signal: AbortSignal): Promise> { + try { + const { data, error } = await this.client.GET('/api/v1/carddav/conflicts', { signal }); + if (!data) return { ok: false, unavailable: error?.error === 'carddav_unavailable' }; + return { ok: true, value: data.conflicts.map(safeListItem) }; + } catch { + return { ok: false, unavailable: false }; + } + } + + private async fetchDetail(id: number, signal: AbortSignal): Promise> { + try { + const { data, error } = await this.client.GET('/api/v1/carddav/conflicts/{id}', { + params: { path: { id } }, + signal + }); + if (!data) return { ok: false, unavailable: error?.error === 'carddav_unavailable' }; + return { ok: true, value: safeDetail(data) }; + } catch { + return { ok: false, unavailable: false }; + } + } + + private applyUnavailable(): void { + // Unavailable replaces the whole conflict context, so every in-flight lane + // must lose ownership before any ignored-abort response can settle later. + this.generation += 1; + this.listRequestGeneration += 1; + this.detailRequestGeneration += 1; + this.mutationGeneration += 1; + this.reconciliationGeneration += 1; + this.listAbort?.abort(); + this.detailAbort?.abort(); + this.mutationAbort?.abort(); + this.listAbort = undefined; + this.detailAbort = undefined; + this.mutationAbort = undefined; + this.unavailable = true; + this.conflicts = []; + this.selectedID = undefined; + this.selectedDetail = undefined; + this.listLoading = false; + this.detailLoading = false; + this.pendingResolutionID = undefined; + this.listError = null; + this.detailError = null; + this.resolutionError = null; + this.resolutionUnknown = false; + this.reconciliationPending = false; + this.announcement = null; + this.focusRequest = undefined; + } + + private applyResolution(id: number, choice: CardDAVConflictChoice): void { + const index = this.conflicts.findIndex((conflict) => conflict.id === id); + const remaining = this.conflicts.filter((conflict) => conflict.id !== id); + this.conflicts = remaining; + if (this.selectedDetail?.id === id) { + this.selectedDetail = { + ...this.selectedDetail, + status: 'resolved', + resolution: choice, + allowed_resolutions: [] + }; + } + this.resolutionUnknown = false; + this.resolutionError = null; + const side = choice === 'keep_local' ? 'local' : 'remote'; + this.announcement = `CardDAV conflict ${id} resolved by keeping the ${side} card.`; + const fallbackIndex = index < 0 ? 0 : Math.min(index, remaining.length - 1); + const next = fallbackIndex >= 0 ? remaining[fallbackIndex] : undefined; + this.focusRequest = { key: ++this.focusGeneration, ...(next ? { conflictID: next.id } : {}) }; + } + + private current(generation: number, signal?: AbortSignal): boolean { + return !this.disposed && this.generation === generation && !signal?.aborted; + } + + private currentList(generation: number, request: number, signal?: AbortSignal): boolean { + return this.current(generation, signal) && this.listRequestGeneration === request; + } + + private currentDetail(generation: number, request: number, signal?: AbortSignal): boolean { + return this.current(generation, signal) && this.detailRequestGeneration === request; + } + + private currentMutation(generation: number, mutation: number, signal?: AbortSignal): boolean { + return this.current(generation, signal) && this.mutationGeneration === mutation; + } +} + +function safeAddressBook(addressBook: GeneratedAddressBook): CardDAVConflictAddressBook { + return { id: addressBook.id, name: addressBook.name }; +} + +function safeChoices(choices: GeneratedDetail['allowed_resolutions']): CardDAVConflictChoice[] { + return choices.filter((choice): choice is CardDAVConflictChoice => + choice === 'keep_local' || choice === 'keep_remote'); +} + +function safeListItem(conflict: GeneratedConflict): CardDAVConflictListItem { + return { + id: conflict.id, + address_book: safeAddressBook(conflict.address_book), + status: conflict.status, + local_state: conflict.local_state, + remote_state: conflict.remote_state, + allowed_resolutions: safeChoices(conflict.allowed_resolutions), + updated_at: conflict.updated_at + }; +} + +function safeSummary(summary: GeneratedSummary): CardDAVConflictSummary { + return { + state: summary.state, + emails: [...summary.emails], + phones: [...summary.phones], + ...(summary.display_name !== undefined ? { display_name: summary.display_name } : {}), + ...(summary.truncated !== undefined ? { truncated: summary.truncated } : {}) + }; +} + +function safeDetail(detail: GeneratedDetail): CardDAVConflictDetail { + return { + id: detail.id, + address_book: safeAddressBook(detail.address_book), + status: detail.status, + base: safeSummary(detail.base), + local: safeSummary(detail.local), + remote: safeSummary(detail.remote), + allowed_resolutions: safeChoices(detail.allowed_resolutions), + created_at: detail.created_at, + updated_at: detail.updated_at, + ...(detail.resolution !== undefined ? { resolution: detail.resolution } : {}), + ...(detail.resolved_at !== undefined ? { resolved_at: detail.resolved_at } : {}) + }; +} + +function isAmbiguousResolution(status: number, errorCode: string | undefined): boolean { + return status >= 500 || (status === 409 && errorCode !== undefined && STALE_RESOLUTION_CODES.has(errorCode)); +} diff --git a/web/src/lib/carddav/controller.svelte.test.ts b/web/src/lib/carddav/controller.svelte.test.ts new file mode 100644 index 000000000..532170310 --- /dev/null +++ b/web/src/lib/carddav/controller.svelte.test.ts @@ -0,0 +1,660 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../api/client'; +import { CardDAVController, type CardDAVBookRoles } from './controller.svelte'; + +const idleStatus = { + configured: true, + available: true, + credential_configured: true, + enabled: false, + scheduled: false, + schedule: '' +}; + +function run(id: number, state: 'running' | 'succeeded' = 'running', updated = 0) { + return { + id, + trigger: 'manual' as const, + full: false, + state, + started_at: '2026-08-28T10:00:00Z', + ...(state === 'running' ? {} : { finished_at: '2026-08-28T10:01:00Z' }), + books: 2, + created: 1, + updated, + removed: 0 + }; +} + +function book(id: number, overrides: Record = {}) { + return { + id, + name: `Synthetic book ${id}`, + url: `https://forbidden-url-${id}.example.test/dav`, + subscribed: false, + lookup_source: false, + write_target: false, + needs_full_reconcile: false, + ...overrides + }; +} + +function requestOf(input: RequestInfo | URL): Request { + return input instanceof Request ? input : new Request(input); +} + +function deferredResponse() { + let resolve!: (response: Response) => void; + const promise = new Promise((settle) => { resolve = settle; }); + return { promise, resolve }; +} + +afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); +}); + +describe('CardDAVController', () => { + it('loads status, books, and page-zero history independently', async () => { + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + requests.push(request); + const path = new URL(request.url).pathname; + if (path === '/api/v1/carddav/status') { + return Response.json({ error: 'carddav_unavailable', message: 'private marker' }, { status: 503 }); + } + if (path === '/api/v1/carddav/books') return Response.json({ books: [book(3)] }); + if (path === '/api/v1/carddav/runs') return Response.json({ runs: [] }); + throw new Error(`Unexpected ${request.method} ${path}`); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + + await controller.load(); + + expect(requests.map((request) => new URL(request.url).pathname).sort()).toEqual([ + '/api/v1/carddav/books', + '/api/v1/carddav/runs', + '/api/v1/carddav/status' + ]); + expect(new URL(requests.find((request) => new URL(request.url).pathname.endsWith('/runs'))!.url).searchParams.get('limit')).toBe('25'); + expect(controller.status).toBeUndefined(); + expect(controller.statusError).toBe('Unable to load CardDAV status.'); + expect(controller.books.map(({ id, name }) => ({ id, name }))).toEqual([{ id: 3, name: 'Synthetic book 3' }]); + expect(controller.books[0]).not.toHaveProperty('url'); + expect(controller.runs).toEqual([]); + expect(controller.runsError).toBeNull(); + controller.destroy(); + }); + + it('blocks sync after a failed status refresh instead of using stale status', async () => { + let statusReads = 0; + let posts = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (path.endsWith('/status')) { + statusReads += 1; + if (statusReads === 1) return Response.json(idleStatus); + return Response.json({ error: 'unavailable' }, { status: 503 }); + } + if (path.endsWith('/books')) return Response.json({ books: [] }); + if (path.endsWith('/runs')) return Response.json({ runs: [] }); + posts += 1; + return Response.json({ run: run(1) }); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + expect(controller.canSync).toBe(true); + + await controller.retryStatus(); + + expect(controller.statusError).toBe('Unable to load CardDAV status.'); + expect(controller.canSync).toBe(false); + await controller.sync(false); + expect(posts).toBe(0); + controller.destroy(); + }); + + it('blocks address-book role writes after a failed refresh instead of using stale books', async () => { + let bookReads = 0; + let patches = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (path.endsWith('/status')) return Response.json(idleStatus); + if (path.endsWith('/books')) { + if (request.method === 'PATCH') { + patches += 1; + return Response.json(book(1)); + } + bookReads += 1; + if (bookReads === 1) return Response.json({ books: [book(1)] }); + return Response.json({ error: 'unavailable' }, { status: 503 }); + } + if (path.endsWith('/runs')) return Response.json({ runs: [] }); + throw new Error(`Unexpected ${request.method} ${path}`); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + expect(controller.canSetBookRoles).toBe(true); + + await controller.load(); + + expect(controller.booksError).toBe('Unable to load CardDAV address books.'); + expect(controller.canSetBookRoles).toBe(false); + await controller.setBookRoles(1, { subscribed: true, lookup_source: false, write_target: false }); + expect(patches).toBe(0); + controller.destroy(); + }); + + it('uses progress-aware polling, caps backoff, pauses while hidden, and reconciles terminal state', async () => { + vi.useFakeTimers(); + let statusReads = 0; + let bookReads = 0; + let runReads = 0; + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path === '/api/v1/carddav/books') { + bookReads += 1; + return Response.json({ books: [book(1)] }); + } + if (path === '/api/v1/carddav/runs') { + runReads += 1; + return Response.json({ runs: [] }); + } + statusReads += 1; + if (statusReads <= 2) return Response.json({ ...idleStatus, active: run(8, 'running', 1) }); + if (statusReads === 3) return Response.json({ ...idleStatus, active: run(8, 'running', 2) }); + if (statusReads <= 8) return Response.json({ ...idleStatus, active: run(8, 'running', 2) }); + return Response.json({ ...idleStatus, latest: run(8, 'succeeded', 2) }); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + + await vi.advanceTimersByTimeAsync(500); // unchanged -> next 1s + await vi.advanceTimersByTimeAsync(1_000); // progress -> next 500ms + await vi.advanceTimersByTimeAsync(500); // unchanged -> 1s + await vi.advanceTimersByTimeAsync(1_000); // unchanged -> 2s + await vi.advanceTimersByTimeAsync(2_000); // unchanged -> 4s + expect(statusReads).toBe(6); + await vi.advanceTimersByTimeAsync(4_000); // unchanged -> capped 8s + expect(statusReads).toBe(7); + await vi.advanceTimersByTimeAsync(7_999); + expect(statusReads).toBe(7); + await vi.advanceTimersByTimeAsync(1); + expect(statusReads).toBe(8); + + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(8_000); + expect(statusReads).toBe(8); + + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(499); + expect(statusReads).toBe(8); + await vi.advanceTimersByTimeAsync(1); + expect(statusReads).toBe(9); + await vi.waitFor(() => expect(bookReads).toBe(2)); + expect(runReads).toBe(2); + expect(controller.status?.active).toBeUndefined(); + controller.destroy(); + }); + + it('does not let an older idle poll overwrite a newer active status read', async () => { + vi.useFakeTimers(); + const olderPoll = deferredResponse(); + const newerRunsRead = deferredResponse(); + let statusReads = 0; + let bookReads = 0; + let runReads = 0; + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path.endsWith('/books')) { + bookReads += 1; + return Response.json({ books: [] }); + } + if (path.endsWith('/runs')) { + runReads += 1; + if (runReads === 2) return newerRunsRead.promise; + return Response.json({ runs: [] }); + } + statusReads += 1; + if (statusReads === 1) return Response.json({ ...idleStatus, active: run(12, 'running', 1) }); + if (statusReads === 2) return olderPoll.promise; + return Response.json({ ...idleStatus, active: run(12, 'running', 2) }); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + await vi.advanceTimersByTimeAsync(500); + expect(statusReads).toBe(2); + + const refresh = controller.retrySyncState(); + await vi.advanceTimersByTimeAsync(0); + expect(controller.status?.active?.updated).toBe(2); + olderPoll.resolve(Response.json({ ...idleStatus, latest: run(12, 'succeeded', 2) })); + await vi.advanceTimersByTimeAsync(0); + + expect(controller.status?.active?.updated).toBe(2); + expect(controller.status?.latest).toBeUndefined(); + expect([bookReads, runReads]).toEqual([1, 2]); + newerRunsRead.resolve(Response.json({ runs: [] })); + await refresh; + controller.destroy(); + }); + + it('does not let an older ordinary read reactivate status after a newer terminal poll', async () => { + vi.useFakeTimers(); + const olderRead = deferredResponse(); + let statusReads = 0; + let bookReads = 0; + let runReads = 0; + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path.endsWith('/books')) { + bookReads += 1; + return Response.json({ books: [] }); + } + if (path.endsWith('/runs')) { + runReads += 1; + return Response.json({ runs: [] }); + } + statusReads += 1; + if (statusReads === 1) return Response.json({ ...idleStatus, active: run(13, 'running', 1) }); + if (statusReads === 2) return olderRead.promise; + return Response.json({ ...idleStatus, latest: run(13, 'succeeded', 2) }); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + + const retry = controller.retryStatus(); + expect(statusReads).toBe(2); + await vi.advanceTimersByTimeAsync(500); + expect(statusReads).toBe(3); + await vi.advanceTimersByTimeAsync(0); + expect(controller.status?.latest?.state).toBe('succeeded'); + expect(controller.statusLoading).toBe(false); + expect([bookReads, runReads]).toEqual([2, 2]); + + olderRead.resolve(Response.json({ ...idleStatus, active: run(13, 'running', 1) })); + await retry; + expect(controller.status?.active).toBeUndefined(); + expect(controller.status?.latest?.state).toBe('succeeded'); + controller.destroy(); + }); + + it('resumes polling after a newer failed ordinary read supersedes an in-flight poll', async () => { + vi.useFakeTimers(); + const olderPoll = deferredResponse(); + const newerRead = deferredResponse(); + let statusReads = 0; + let bookReads = 0; + let runReads = 0; + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path.endsWith('/books')) { + bookReads += 1; + return Response.json({ books: [] }); + } + if (path.endsWith('/runs')) { + runReads += 1; + return Response.json({ runs: [] }); + } + statusReads += 1; + if (statusReads === 1) return Response.json({ ...idleStatus, active: run(14, 'running', 1) }); + if (statusReads === 2) return olderPoll.promise; + if (statusReads === 3) return newerRead.promise; + return Response.json({ ...idleStatus, latest: run(14, 'succeeded', 2) }); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + await vi.advanceTimersByTimeAsync(500); + expect(statusReads).toBe(2); + + const retry = controller.retryStatus(); + expect(statusReads).toBe(3); + newerRead.resolve(Response.json({ error: 'carddav_unavailable' }, { status: 503 })); + await retry; + expect(controller.statusError).toBe('Unable to load CardDAV status.'); + expect(controller.status?.active?.updated).toBe(1); + + olderPoll.resolve(Response.json({ ...idleStatus, active: run(14, 'running', 2) })); + await vi.advanceTimersByTimeAsync(0); + expect(controller.status?.active?.updated).toBe(1); + await vi.advanceTimersByTimeAsync(499); + expect(statusReads).toBe(3); + await vi.advanceTimersByTimeAsync(1); + + expect(statusReads).toBe(4); + expect(controller.status?.active).toBeUndefined(); + expect(controller.status?.latest?.state).toBe('succeeded'); + expect(controller.statusError).toBeNull(); + expect([bookReads, runReads]).toEqual([2, 2]); + controller.destroy(); + }); + + it('lets a newer successful poll satisfy a superseded retrySyncState status read', async () => { + vi.useFakeTimers(); + const olderRead = deferredResponse(); + const newerPoll = deferredResponse(); + let statusReads = 0; + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path.endsWith('/books')) return Response.json({ books: [] }); + if (path.endsWith('/runs')) return Response.json({ runs: [] }); + statusReads += 1; + if (statusReads === 1) return Response.json({ ...idleStatus, active: run(15, 'running', 1) }); + if (statusReads === 2) return olderRead.promise; + return newerPoll.promise; + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + controller.syncUnknown = true; + + const retry = controller.retrySyncState(); + expect(statusReads).toBe(2); + await vi.advanceTimersByTimeAsync(500); + expect(statusReads).toBe(3); + + olderRead.resolve(Response.json({ error: 'carddav_unavailable' }, { status: 503 })); + await retry; + expect(controller.syncUnknown).toBe(true); + + newerPoll.resolve(Response.json({ ...idleStatus, active: run(15, 'running', 2) })); + await vi.advanceTimersByTimeAsync(0); + + expect(controller.syncPending).toBe(false); + expect(controller.syncUnknown).toBe(false); + expect(controller.status?.active?.updated).toBe(2); + controller.destroy(); + }); + + it('aborts a stale poll and ignores its late completion after destroy', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const deferred = deferredResponse(); + const signals: AbortSignal[] = []; + let statusReads = 0; + const fetchFn = vi.fn((input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (path === '/api/v1/carddav/books') return Promise.resolve(Response.json({ books: [] })); + if (path === '/api/v1/carddav/runs') return Promise.resolve(Response.json({ runs: [] })); + statusReads += 1; + signals.push(request.signal); + if (statusReads === 1) return Promise.resolve(Response.json({ ...idleStatus, active: run(9) })); + return deferred.promise; + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + await vi.advanceTimersByTimeAsync(500); + await vi.waitFor(() => expect(signals).toHaveLength(2)); + + controller.destroy(); + expect(signals[1]!.aborted).toBe(true); + deferred.resolve(Response.json({ ...idleStatus, active: run(99) })); + await Promise.resolve(); + expect(controller.status?.active?.id).toBe(9); + }); + + it('sends exact manual and full sync bodies, suppresses duplicates, and reconciles ambiguous results with GET only', async () => { + const sync = deferredResponse(); + const requests: Request[] = []; + const fetchFn = vi.fn((input) => { + const request = requestOf(input); + requests.push(request); + const path = new URL(request.url).pathname; + if (path === '/api/v1/carddav/status') return Promise.resolve(Response.json(idleStatus)); + if (path === '/api/v1/carddav/books') return Promise.resolve(Response.json({ books: [] })); + if (path === '/api/v1/carddav/runs') return Promise.resolve(Response.json({ runs: [] })); + if (path === '/api/v1/carddav/sync') return sync.promise; + throw new Error(`Unexpected ${request.method} ${path}`); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + + const first = controller.sync(false); + await vi.waitFor(() => expect(requests.filter((request) => request.method === 'POST')).toHaveLength(1)); + await controller.sync(true); + expect(requests.filter((request) => request.method === 'POST')).toHaveLength(1); + const request = requests.find((candidate) => candidate.method === 'POST')!; + await expect(request.clone().json()).resolves.toEqual({ full: false }); + sync.resolve(Response.json({ error: 'sync_failed', message: 'unsafe detail' }, { status: 503 })); + await first; + + expect(requests.filter((candidate) => candidate.method === 'POST')).toHaveLength(1); + expect(requests.filter((candidate) => candidate.method === 'GET' && new URL(candidate.url).pathname.endsWith('/status'))).toHaveLength(2); + expect(requests.filter((candidate) => candidate.method === 'GET' && new URL(candidate.url).pathname.endsWith('/runs'))).toHaveLength(2); + expect(controller.syncError).toBe('Unable to complete CardDAV sync. Current state was refreshed.'); + + const fullFetch = vi.fn(async (input) => { + const request = requestOf(input); + if (request.method === 'POST') { + await expect(request.clone().json()).resolves.toEqual({ full: true }); + return Response.json({ books: 1, created: 0, updated: 1, removed: 0 }); + } + const path = new URL(request.url).pathname; + if (path.endsWith('/status')) return Response.json(idleStatus); + if (path.endsWith('/books')) return Response.json({ books: [] }); + return Response.json({ runs: [] }); + }); + const fullController = new CardDAVController(createAPIClient(fullFetch)); + await fullController.load(); + await fullController.sync(true); + fullController.destroy(); + controller.destroy(); + }); + + it('keeps sync blocked through failed ambiguity reconciliation and retries only the reads', async () => { + const statusReconcile = deferredResponse(); + const runsReconcile = deferredResponse(); + let statusReads = 0; + let runsReads = 0; + let posts = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (path.endsWith('/books')) return Response.json({ books: [] }); + if (path.endsWith('/status')) { + statusReads += 1; + if (statusReads === 1 || statusReads === 3) return Response.json(idleStatus); + return statusReconcile.promise; + } + if (path.endsWith('/runs')) { + runsReads += 1; + if (runsReads === 1 || runsReads === 3) return Response.json({ runs: [] }); + return runsReconcile.promise; + } + posts += 1; + return Response.json({ error: 'sync_failed', message: 'uncertain' }, { status: posts === 1 ? 503 : 400 }); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + + const action = controller.sync(false); + await vi.waitFor(() => expect([statusReads, runsReads]).toEqual([2, 2])); + await controller.sync(false); + expect(posts).toBe(1); + statusReconcile.resolve(Response.json({ error: 'unavailable', message: 'unsafe' }, { status: 503 })); + runsReconcile.resolve(Response.json({ error: 'unavailable', message: 'unsafe' }, { status: 503 })); + await action; + + expect(controller.syncUnknown).toBe(true); + expect(controller.canSync).toBe(false); + await controller.retrySyncState(); + expect([statusReads, runsReads, posts]).toEqual([3, 3, 1]); + expect(controller.syncUnknown).toBe(false); + expect(controller.canSync).toBe(true); + controller.destroy(); + }); + + it('sends the complete normalized role tuple and reloads every book after success', async () => { + const requests: Request[] = []; + let booksRead = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + requests.push(request); + const path = new URL(request.url).pathname; + if (path.endsWith('/status')) return Response.json(idleStatus); + if (path.endsWith('/runs')) return Response.json({ runs: [] }); + if (request.method === 'GET' && path.endsWith('/books')) { + booksRead += 1; + return Response.json({ books: [book(1, { write_target: booksRead > 1, subscribed: booksRead > 1 }), book(2)] }); + } + if (request.method === 'PATCH') return Response.json(book(1, { write_target: true, subscribed: true })); + throw new Error(`Unexpected ${request.method} ${path}`); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + + await controller.setBookRoles(1, { subscribed: false, lookup_source: true, write_target: true }); + + const patch = requests.find((request) => request.method === 'PATCH')!; + expect(new URL(patch.url).pathname).toBe('/api/v1/carddav/books/1'); + await expect(patch.clone().json()).resolves.toEqual({ subscribed: true, lookup_source: true, write_target: true }); + expect(booksRead).toBe(2); + expect(controller.books.find((candidate) => candidate.id === 1)?.write_target).toBe(true); + expect(controller.bookDraft(1)).toBeUndefined(); + controller.destroy(); + }); + + it('blocks role mutations until status confirms a ready CardDAV runtime', async () => { + let statusReads = 0; + let patches = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (path.endsWith('/status')) { + statusReads += 1; + if (statusReads === 1) return Response.json({ error: 'unavailable', message: 'unsafe' }, { status: 503 }); + return Response.json(idleStatus); + } + if (request.method === 'GET' && path.endsWith('/books')) return Response.json({ books: [book(1)] }); + if (path.endsWith('/runs')) return Response.json({ runs: [] }); + patches += 1; + return Response.json(book(1, { subscribed: true })); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + + expect(controller.canSetBookRoles).toBe(false); + await controller.setBookRoles(1, { subscribed: true, lookup_source: false, write_target: false }); + expect(patches).toBe(0); + + await controller.retryStatus(); + expect(controller.canSetBookRoles).toBe(true); + await controller.setBookRoles(1, { subscribed: true, lookup_source: false, write_target: false }); + expect(patches).toBe(1); + controller.destroy(); + }); + + it('retains intended roles across stale reconciliation and makes failed reconciliation GET-only', async () => { + let booksRead = 0; + let patches = 0; + const requests: Request[] = []; + const intended: CardDAVBookRoles = { subscribed: true, lookup_source: true, write_target: false }; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + requests.push(request); + const path = new URL(request.url).pathname; + if (path.endsWith('/status')) return Response.json(idleStatus); + if (path.endsWith('/runs')) return Response.json({ runs: [] }); + if (request.method === 'GET' && path.endsWith('/books')) { + booksRead += 1; + if (booksRead === 3) return Response.json({ error: 'unavailable', message: 'unsafe marker' }, { status: 503 }); + return Response.json({ books: [book(1)] }); + } + patches += 1; + return Response.json({ error: 'carddav_conflict', message: 'stale' }, { status: patches === 1 ? 409 : 503 }); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + + await controller.setBookRoles(1, intended); + expect(patches).toBe(1); + expect(controller.bookDraft(1)).toEqual(intended); + expect(controller.booksUnknown).toBe(false); + + await controller.setBookRoles(1, intended); + expect(patches).toBe(2); + expect(controller.booksUnknown).toBe(true); + expect(controller.bookDraft(1)).toEqual(intended); + await controller.setBookRoles(1, intended); + expect(patches).toBe(2); + + await controller.retryBooks(); + expect(booksRead).toBe(4); + expect(controller.booksUnknown).toBe(false); + expect(patches).toBe(2); + controller.destroy(); + }); + + it('keeps role writes blocked until stale reconciliation finishes', async () => { + const reconcile = deferredResponse(); + let booksRead = 0; + let patches = 0; + const intended: CardDAVBookRoles = { subscribed: true, lookup_source: false, write_target: false }; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (path.endsWith('/status')) return Response.json(idleStatus); + if (path.endsWith('/runs')) return Response.json({ runs: [] }); + if (request.method === 'GET') { + booksRead += 1; + if (booksRead === 1) return Response.json({ books: [book(1)] }); + return reconcile.promise; + } + patches += 1; + return Response.json({ error: 'carddav_conflict', message: 'stale' }, { status: patches === 1 ? 409 : 400 }); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + + const action = controller.setBookRoles(1, intended); + await vi.waitFor(() => expect(booksRead).toBe(2)); + await controller.setBookRoles(1, intended); + expect(patches).toBe(1); + reconcile.resolve(Response.json({ books: [book(1)] })); + await action; + controller.destroy(); + }); + + it('retains history and retries the same cursor, replaces the head, and deduplicates appended runs', async () => { + const cursors: Array = []; + let cursorFailure = true; + let head = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const url = new URL(request.url); + if (url.pathname.endsWith('/status')) return Response.json(idleStatus); + if (url.pathname.endsWith('/books')) return Response.json({ books: [] }); + const cursor = url.searchParams.get('before_id'); + cursors.push(cursor); + if (cursor === '8' && cursorFailure) { + cursorFailure = false; + return Response.json({ error: 'unavailable', message: 'unsafe marker' }, { status: 503 }); + } + if (cursor === '8') return Response.json({ runs: [run(9, 'succeeded'), run(8, 'succeeded'), run(7, 'succeeded')] }); + head += 1; + return head === 1 + ? Response.json({ runs: [run(10, 'succeeded'), run(9, 'succeeded')], next_before_id: 8 }) + : Response.json({ runs: [run(12, 'succeeded')], next_before_id: 11 }); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + expect(controller.runs.map(({ id }) => id)).toEqual([10, 9]); + + await controller.loadMoreRuns(); + expect(controller.runs.map(({ id }) => id)).toEqual([10, 9]); + expect(controller.runsPageError).toBe('Unable to load more CardDAV history.'); + await controller.retryRuns(); + expect(cursors).toEqual([null, '8', '8']); + expect(controller.runs.map(({ id }) => id)).toEqual([10, 9, 8, 7]); + + await controller.refreshRuns(); + expect(controller.runs.map(({ id }) => id)).toEqual([12]); + expect(controller.nextBeforeID).toBe(11); + controller.destroy(); + }); +}); diff --git a/web/src/lib/carddav/controller.svelte.ts b/web/src/lib/carddav/controller.svelte.ts new file mode 100644 index 000000000..9720b90a7 --- /dev/null +++ b/web/src/lib/carddav/controller.svelte.ts @@ -0,0 +1,550 @@ +import { SvelteMap } from 'svelte/reactivity'; + +import type { APIClient } from '../api/client'; +import type { components } from '../api/generated/schema'; + +type CardDAVStatus = components['schemas']['CardDAVStatusResponse']; +type CardDAVRun = components['schemas']['CardDAVRunResponse']; +type GeneratedBook = components['schemas']['CardDAVBookResponse']; +export type CardDAVBookRoles = components['schemas']['CardDAVBookRolesRequest']; +export type CardDAVBook = Pick; + +interface VisibilityDocument { + readonly hidden: boolean; + addEventListener(type: 'visibilitychange', listener: () => void): void; + removeEventListener(type: 'visibilitychange', listener: () => void): void; +} + +const FIRST_PAGE_LIMIT = 25; +const MIN_POLL_MS = 500; +const MAX_POLL_MS = 8_000; + +export class CardDAVController { + status = $state(); + books = $state([]); + runs = $state([]); + nextBeforeID = $state(); + + statusLoading = $state(true); + booksLoading = $state(true); + runsLoading = $state(true); + runsPageLoading = $state(false); + syncPending = $state(false); + bookPendingID = $state(); + + statusError = $state(null); + booksError = $state(null); + runsError = $state(null); + runsPageError = $state(null); + syncError = $state(null); + syncStatus = $state(null); + syncUnknown = $state(false); + bookError = $state(null); + bookStatus = $state(null); + booksUnknown = $state(false); + + private readonly drafts = new SvelteMap(); + private readonly client: APIClient; + private readonly visibilityDocument?: VisibilityDocument; + private disposed = false; + private generation = 1; + private statusReadAbort?: AbortController; + private booksReadAbort?: AbortController; + private runsReadAbort?: AbortController; + private pollAbort?: AbortController; + private syncAbort?: AbortController; + private bookMutationAbort?: AbortController; + private pollTimer?: ReturnType; + private pollGeneration = 1; + private pollDelay = MIN_POLL_MS; + private pollFingerprint = ''; + private statusCommitGeneration = 0; + private successfulStatusCommitGeneration = 0; + private syncKnownAfterStatusCommit?: number; + private failedRunsCursor?: number; + + private readonly visibilityChanged = (): void => { + if (this.visibilityDocument?.hidden) { + this.stopPolling(); + return; + } + if (this.shouldPoll()) { + this.pollDelay = MIN_POLL_MS; + this.schedulePoll(MIN_POLL_MS); + } + }; + + constructor(client: APIClient, visibilityDocument: VisibilityDocument | undefined = + typeof document === 'undefined' ? undefined : document) { + this.client = client; + this.visibilityDocument = visibilityDocument; + visibilityDocument?.addEventListener('visibilitychange', this.visibilityChanged); + } + + async load(): Promise { + await Promise.all([this.readStatus(true), this.loadBooks(false), this.refreshRuns()]); + } + + async retryStatus(): Promise { + await this.readStatus(true); + } + + async retryBooks(): Promise { + await this.loadBooks(this.books.length > 0 || this.booksUnknown); + } + + async retryRuns(): Promise { + if (this.runsPageError && this.failedRunsCursor !== undefined) { + await this.loadRunsPage(this.failedRunsCursor); + return; + } + await this.refreshRuns(); + } + + async refreshRuns(): Promise { + const context = this.generation; + this.runsReadAbort?.abort(); + const requestController = new AbortController(); + this.runsReadAbort = requestController; + this.runsLoading = this.runs.length === 0; + this.runsError = null; + this.runsPageError = null; + this.failedRunsCursor = undefined; + try { + const { data } = await this.client.GET('/api/v1/carddav/runs', { + params: { query: { limit: FIRST_PAGE_LIMIT } }, + signal: requestController.signal + }); + if (!this.current(context, requestController.signal)) return false; + if (!data) { + this.runsError = 'Unable to load CardDAV history.'; + return false; + } + this.runs = uniqueRuns(data.runs); + this.nextBeforeID = data.next_before_id; + return true; + } catch { + if (this.current(context, requestController.signal)) { + this.runsError = 'Unable to load CardDAV history.'; + } + return false; + } finally { + if (this.current(context)) { + if (this.runsReadAbort === requestController) this.runsReadAbort = undefined; + this.runsLoading = false; + } + } + } + + async loadMoreRuns(): Promise { + if (this.runsPageLoading || this.nextBeforeID === undefined) return; + await this.loadRunsPage(this.nextBeforeID); + } + + setBookDraft(id: number, roles: CardDAVBookRoles): void { + this.drafts.set(id, normalizedRoles(roles)); + this.bookError = null; + this.bookStatus = null; + } + + bookDraft(id: number): CardDAVBookRoles | undefined { + const draft = this.drafts.get(id); + return draft ? { ...draft } : undefined; + } + + rolesFor(book: CardDAVBook): CardDAVBookRoles { + return this.bookDraft(book.id) ?? rolesFromBook(book); + } + + async setBookRoles(id: number, roles: CardDAVBookRoles): Promise { + const intended = normalizedRoles(roles); + this.drafts.set(id, intended); + if (!this.canSetBookRoles) return; + const context = this.generation; + this.bookMutationAbort?.abort(); + const requestController = new AbortController(); + this.bookMutationAbort = requestController; + this.bookPendingID = id; + this.bookError = null; + this.bookStatus = null; + let reconcile = false; + let committed = false; + try { + const { data, response } = await this.client.PATCH('/api/v1/carddav/books/{id}', { + params: { path: { id } }, + body: intended, + signal: requestController.signal + }); + if (!this.current(context, requestController.signal)) return; + if (data) { + committed = true; + reconcile = true; + this.drafts.delete(id); + this.bookStatus = 'Address-book roles saved. All books were refreshed.'; + } else if (response.status === 409 || response.status >= 500) { + reconcile = true; + this.bookError = 'Address-book roles may have changed. Current books were refreshed; review and apply again.'; + } else { + this.bookError = 'Unable to save address-book roles.'; + } + } catch { + if (!this.current(context, requestController.signal)) return; + reconcile = true; + this.bookError = 'Address-book role result is uncertain. Current books were refreshed; review and apply again.'; + } finally { + if (this.current(context)) { + if (this.bookMutationAbort === requestController) this.bookMutationAbort = undefined; + } + } + if (!this.current(context) || !reconcile) { + if (this.current(context)) this.bookPendingID = undefined; + return; + } + const loaded = await this.loadBooks(true); + if (!loaded && this.current(context)) { + this.booksUnknown = true; + this.bookError = committed + ? 'Address-book roles were saved, but current book state is unknown. Retry book state before editing.' + : 'Address-book role result is uncertain and current book state is unknown. Retry book state before editing.'; + } + if (this.current(context)) this.bookPendingID = undefined; + } + + async sync(full: boolean): Promise { + if (!this.canSync || this.syncPending) return; + const context = this.generation; + this.syncAbort?.abort(); + const requestController = new AbortController(); + this.syncAbort = requestController; + this.syncPending = true; + this.syncError = null; + this.syncStatus = null; + this.pollDelay = MIN_POLL_MS; + this.schedulePoll(MIN_POLL_MS); + let succeeded = false; + let reconcile = false; + try { + const { data, response } = await this.client.POST('/api/v1/carddav/sync', { + body: { full }, + signal: requestController.signal + }); + if (!this.current(context, requestController.signal)) return; + if (data) { + succeeded = true; + reconcile = true; + this.syncStatus = full ? 'Full CardDAV sync completed.' : 'CardDAV sync completed.'; + } else if (response.status === 409 || response.status >= 500) { + reconcile = true; + this.syncError = 'Unable to complete CardDAV sync. Current state was refreshed.'; + } else { + this.syncError = 'Unable to start CardDAV sync.'; + } + } catch { + if (!this.current(context, requestController.signal)) return; + reconcile = true; + this.syncError = 'Unable to complete CardDAV sync. Current state was refreshed.'; + } finally { + if (this.current(context)) { + if (this.syncAbort === requestController) this.syncAbort = undefined; + } + } + if (!this.current(context)) return; + this.stopPolling(); + if (reconcile) { + const [statusLoaded, runsLoaded] = await Promise.all([ + this.readStatus(false), + this.refreshRuns(), + ...(succeeded ? [this.loadBooks(true)] : []) + ]); + this.syncUnknown = !statusLoaded || !runsLoaded; + } + this.syncPending = false; + if (this.shouldPoll()) this.schedulePoll(MIN_POLL_MS); + } + + async retrySyncState(): Promise { + if (this.disposed || this.syncPending) return; + const context = this.generation; + const statusCommitFloor = this.statusCommitGeneration; + this.syncKnownAfterStatusCommit = undefined; + this.syncPending = true; + const statusRead = this.readStatus(false); + const reconciliationStatusCommit = this.statusCommitGeneration; + const [statusLoaded, runsLoaded] = await Promise.all([statusRead, this.refreshRuns()]); + if (!this.current(context)) return; + const statusKnown = statusLoaded || this.successfulStatusCommitGeneration > statusCommitFloor; + this.syncUnknown = !statusKnown || !runsLoaded; + this.syncKnownAfterStatusCommit = runsLoaded && !statusKnown ? statusCommitFloor : undefined; + this.syncPending = false; + if (!this.syncUnknown) this.syncError = null; + if (this.shouldPoll() && this.statusCommitGeneration === reconciliationStatusCommit) { + this.schedulePoll(MIN_POLL_MS); + } + } + + get canSync(): boolean { + return Boolean( + !this.disposed && + !this.statusLoading && + !this.statusError && + this.status?.configured && + this.status.available && + this.status.credential_configured && + !this.syncUnknown && + !this.status.active && + !this.syncPending + ); + } + + get canSetBookRoles(): boolean { + return Boolean( + !this.disposed && + !this.statusLoading && + !this.statusError && + !this.booksLoading && + !this.booksError && + this.status?.configured && + this.status.available && + this.status.credential_configured && + !this.booksUnknown && + this.bookPendingID === undefined + ); + } + + destroy(): void { + if (this.disposed) return; + this.disposed = true; + this.generation += 1; + this.visibilityDocument?.removeEventListener('visibilitychange', this.visibilityChanged); + this.stopPolling(); + this.statusReadAbort?.abort(); + this.booksReadAbort?.abort(); + this.runsReadAbort?.abort(); + this.syncAbort?.abort(); + this.bookMutationAbort?.abort(); + } + + private async readStatus(schedule: boolean): Promise { + const context = this.generation; + const statusCommit = ++this.statusCommitGeneration; + this.statusReadAbort?.abort(); + const requestController = new AbortController(); + this.statusReadAbort = requestController; + this.statusLoading = true; + this.statusError = null; + try { + const { data } = await this.client.GET('/api/v1/carddav/status', { signal: requestController.signal }); + if (!this.currentStatusCommit(context, statusCommit, requestController.signal)) return false; + if (!data) { + this.statusError = 'Unable to load CardDAV status.'; + return false; + } + this.status = data; + this.pollFingerprint = statusFingerprint(data); + this.recordSuccessfulStatusCommit(statusCommit); + return true; + } catch { + if (this.currentStatusCommit(context, statusCommit, requestController.signal)) { + this.statusError = 'Unable to load CardDAV status.'; + } + return false; + } finally { + if (schedule && this.currentStatusCommit(context, statusCommit, requestController.signal) && this.shouldPoll()) { + this.pollDelay = MIN_POLL_MS; + this.schedulePoll(MIN_POLL_MS); + } + if (this.current(context) && this.statusReadAbort === requestController) { + this.statusReadAbort = undefined; + this.statusLoading = false; + } + } + } + + private async loadBooks(reconciliation: boolean): Promise { + const context = this.generation; + this.booksReadAbort?.abort(); + const requestController = new AbortController(); + this.booksReadAbort = requestController; + this.booksLoading = this.books.length === 0; + this.booksError = null; + try { + const { data } = await this.client.GET('/api/v1/carddav/books', { signal: requestController.signal }); + if (!this.current(context, requestController.signal)) return false; + if (!data) { + this.booksError = 'Unable to load CardDAV address books.'; + this.booksUnknown = true; + return false; + } + this.books = (data.books ?? []).map(safeBook); + const liveIDs = new Set(this.books.map(({ id }) => id)); + for (const id of this.drafts.keys()) if (!liveIDs.has(id)) this.drafts.delete(id); + this.booksUnknown = false; + return true; + } catch { + if (this.current(context, requestController.signal)) { + this.booksError = 'Unable to load CardDAV address books.'; + this.booksUnknown = true; + } + return false; + } finally { + if (this.current(context)) { + if (this.booksReadAbort === requestController) this.booksReadAbort = undefined; + this.booksLoading = false; + } + } + } + + private async loadRunsPage(cursor: number): Promise { + const context = this.generation; + this.runsReadAbort?.abort(); + const requestController = new AbortController(); + this.runsReadAbort = requestController; + this.runsPageLoading = true; + this.runsPageError = null; + this.failedRunsCursor = cursor; + try { + const { data } = await this.client.GET('/api/v1/carddav/runs', { + params: { query: { limit: FIRST_PAGE_LIMIT, before_id: cursor } }, + signal: requestController.signal + }); + if (!this.current(context, requestController.signal)) return; + if (!data) { + this.runsPageError = 'Unable to load more CardDAV history.'; + return; + } + this.runs = uniqueRuns([...this.runs, ...data.runs]); + this.nextBeforeID = data.next_before_id; + this.failedRunsCursor = undefined; + } catch { + if (this.current(context, requestController.signal)) { + this.runsPageError = 'Unable to load more CardDAV history.'; + } + } finally { + if (this.current(context)) { + if (this.runsReadAbort === requestController) this.runsReadAbort = undefined; + this.runsPageLoading = false; + } + } + } + + private shouldPoll(): boolean { + return !this.disposed && !this.visibilityDocument?.hidden && Boolean(this.status?.active || this.syncPending); + } + + private schedulePoll(delay: number): void { + if (!this.shouldPoll()) return; + if (this.pollTimer !== undefined) clearTimeout(this.pollTimer); + const generation = ++this.pollGeneration; + this.pollTimer = setTimeout(() => { + this.pollTimer = undefined; + void this.poll(generation); + }, delay); + } + + private async poll(generation: number): Promise { + if (this.disposed || generation !== this.pollGeneration || this.visibilityDocument?.hidden) return; + const context = this.generation; + const statusCommit = ++this.statusCommitGeneration; + this.pollAbort?.abort(); + const requestController = new AbortController(); + this.pollAbort = requestController; + const priorActive = Boolean(this.status?.active); + try { + const { data } = await this.client.GET('/api/v1/carddav/status', { signal: requestController.signal }); + if (!this.currentStatusCommit(context, statusCommit, requestController.signal) || generation !== this.pollGeneration) return; + this.statusLoading = false; + if (!data) { + this.statusError = 'Unable to load CardDAV status.'; + this.pollDelay = Math.min(MAX_POLL_MS, this.pollDelay * 2); + this.schedulePoll(this.pollDelay); + return; + } + const fingerprint = statusFingerprint(data); + const advanced = fingerprint !== this.pollFingerprint; + this.status = data; + this.statusError = null; + this.pollFingerprint = fingerprint; + this.recordSuccessfulStatusCommit(statusCommit); + if (priorActive && !data.active && !this.syncPending) { + this.stopPolling(false); + await Promise.all([this.loadBooks(true), this.refreshRuns()]); + return; + } + if (this.shouldPoll()) { + this.pollDelay = advanced ? MIN_POLL_MS : Math.min(MAX_POLL_MS, this.pollDelay * 2); + this.schedulePoll(this.pollDelay); + } + } catch { + if (!this.currentStatusCommit(context, statusCommit, requestController.signal) || generation !== this.pollGeneration) return; + this.statusLoading = false; + this.statusError = 'Unable to load CardDAV status.'; + this.pollDelay = Math.min(MAX_POLL_MS, this.pollDelay * 2); + this.schedulePoll(this.pollDelay); + } finally { + if (this.pollAbort === requestController) this.pollAbort = undefined; + } + } + + private stopPolling(invalidate = true): void { + if (invalidate) this.pollGeneration += 1; + if (this.pollTimer !== undefined) clearTimeout(this.pollTimer); + this.pollTimer = undefined; + this.pollAbort?.abort(); + this.pollAbort = undefined; + } + + private current(generation: number, signal?: AbortSignal): boolean { + return !this.disposed && this.generation === generation && !signal?.aborted; + } + + private currentStatusCommit(generation: number, statusCommit: number, signal?: AbortSignal): boolean { + return this.current(generation, signal) && this.statusCommitGeneration === statusCommit; + } + + private recordSuccessfulStatusCommit(statusCommit: number): void { + this.successfulStatusCommitGeneration = statusCommit; + if (this.syncKnownAfterStatusCommit === undefined || statusCommit <= this.syncKnownAfterStatusCommit) return; + this.syncKnownAfterStatusCommit = undefined; + this.syncUnknown = false; + this.syncError = null; + } +} + +function safeBook(book: GeneratedBook): CardDAVBook { + return { + id: book.id, + name: book.name, + subscribed: book.subscribed, + lookup_source: book.lookup_source, + write_target: book.write_target, + needs_full_reconcile: book.needs_full_reconcile + }; +} + +function rolesFromBook(book: CardDAVBook): CardDAVBookRoles { + return { + subscribed: book.subscribed, + lookup_source: book.lookup_source, + write_target: book.write_target + }; +} + +function normalizedRoles(roles: CardDAVBookRoles): CardDAVBookRoles { + return { + subscribed: roles.subscribed || roles.write_target, + lookup_source: roles.lookup_source, + write_target: roles.write_target + }; +} + +function uniqueRuns(runs: CardDAVRun[]): CardDAVRun[] { + const seen = new Set(); + return runs.filter((run) => !seen.has(run.id) && Boolean(seen.add(run.id))); +} + +function statusFingerprint(status: CardDAVStatus): string { + const run = status.active; + if (!run) return `idle:${status.latest?.id ?? 0}:${status.latest?.state ?? ''}`; + return [run.id, run.state, run.books, run.created, run.updated, run.removed].join(':'); +} diff --git a/web/src/lib/carddav/navigation.ts b/web/src/lib/carddav/navigation.ts new file mode 100644 index 000000000..c485386f6 --- /dev/null +++ b/web/src/lib/carddav/navigation.ts @@ -0,0 +1,4 @@ +export interface CardDAVSettingsRequest { + key: number; + conflictID?: number; +} diff --git a/web/src/lib/carddav/publication-controller.svelte.test.ts b/web/src/lib/carddav/publication-controller.svelte.test.ts new file mode 100644 index 000000000..5cc6d3f2e --- /dev/null +++ b/web/src/lib/carddav/publication-controller.svelte.test.ts @@ -0,0 +1,312 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../api/client'; +import { CardDAVPublicationController } from './publication-controller.svelte'; + +const forbidden = { + raw_vcard: 'BEGIN:VCARD\nFN:FORBIDDEN\nEND:VCARD', + url: 'https://forbidden.example.test/dav', + href: '/forbidden/contact.vcf', + credential: 'forbidden-private-credential' +}; + +function publication(personID: number, state: 'unpublished' | 'published' | 'pending' | 'conflict', overrides: Record = {}) { + return { + person_id: personID, + state, + desired: state === 'published', + address_book: { id: 5, name: 'Synthetic contacts', ...forbidden }, + ...forbidden, + ...overrides + }; +} + +function requestOf(input: RequestInfo | URL): Request { + return input instanceof Request ? input : new Request(input); +} + +function deferredResponse() { + let resolve!: (response: Response) => void; + const promise = new Promise((settle) => { resolve = settle; }); + return { promise, resolve }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('CardDAVPublicationController', () => { + it('uses only exact durable-person GET, POST, and DELETE routes and projects safe fields', async () => { + const facts: Array<{ method: string; path: string }> = []; + let state: 'unpublished' | 'published' = 'unpublished'; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + facts.push({ method: request.method, path }); + if (request.method === 'POST') state = 'published'; + if (request.method === 'DELETE') state = 'unpublished'; + return Response.json(publication(7, state, { desired: state === 'published' })); + }); + const controller = new CardDAVPublicationController(createAPIClient(fetchFn)); + + await controller.setPerson(7); + expect(await controller.publish()).toEqual({ kind: 'confirmed', action: 'publish' }); + expect(await controller.unpublish()).toEqual({ kind: 'confirmed', action: 'unpublish' }); + + expect(facts).toEqual([ + { method: 'GET', path: '/api/v1/carddav/publications/7' }, + { method: 'POST', path: '/api/v1/carddav/publications/7' }, + { method: 'DELETE', path: '/api/v1/carddav/publications/7' } + ]); + expect(facts.some(({ path }) => path.includes('/participants/'))).toBe(false); + expect(controller.publication).toEqual({ + person_id: 7, + state: 'unpublished', + desired: false, + address_book: { id: 5, name: 'Synthetic contacts' } + }); + expect(JSON.stringify(controller.publication)).not.toMatch(/FORBIDDEN|forbidden/i); + controller.destroy(); + }); + + it('suppresses duplicate mutation and reconciles an ambiguous result with one GET and no replay', async () => { + const mutation = deferredResponse(); + let gets = 0; + let posts = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + if (request.method === 'POST') { + posts += 1; + return mutation.promise; + } + gets += 1; + return Response.json(publication(7, gets === 1 ? 'unpublished' : 'published', { desired: gets > 1 })); + }); + const controller = new CardDAVPublicationController(createAPIClient(fetchFn)); + await controller.setPerson(7); + + const first = controller.publish(); + expect(await controller.publish()).toEqual({ kind: 'ignored' }); + mutation.resolve(Response.json({ error: 'carddav_publication_pending' }, { status: 409 })); + expect(await first).toEqual({ kind: 'reconciled', action: 'publish' }); + + expect([posts, gets]).toEqual([1, 2]); + expect(controller.publication?.state).toBe('published'); + expect(controller.stateUnknown).toBe(false); + controller.destroy(); + }); + + it('locks mutation after failed ambiguous reconciliation and GET-only retry recovers', async () => { + let gets = 0; + let posts = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + if (request.method === 'POST') { + posts += 1; + return Response.json({ error: 'unavailable' }, { status: 503 }); + } + gets += 1; + if (gets === 2) return Response.json({ error: 'carddav_unavailable' }, { status: 503 }); + return Response.json(publication(7, gets === 1 ? 'unpublished' : 'published', { desired: gets > 1 })); + }); + const controller = new CardDAVPublicationController(createAPIClient(fetchFn)); + await controller.setPerson(7); + + expect(await controller.publish()).toEqual({ kind: 'unknown', action: 'publish' }); + expect(controller.publication).toBeUndefined(); + expect(controller.unavailable).toBe(true); + expect(controller.stateUnknown).toBe(true); + expect(await controller.publish()).toEqual({ kind: 'ignored' }); + await controller.retryState(); + + expect([posts, gets]).toEqual([1, 3]); + expect(controller.publication?.state).toBe('published'); + expect(controller.stateUnknown).toBe(false); + controller.destroy(); + }); + + it('reconciles a rejected mutation once and locks changes behind GET-only retry when reconciliation fails', async () => { + const requests: Array<{ method: string; path: string }> = []; + let gets = 0; + let posts = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + requests.push({ method: request.method, path }); + if (request.method === 'POST') { + posts += 1; + throw new TypeError('synthetic transport failure'); + } + gets += 1; + if (gets === 2) throw new TypeError('synthetic reconciliation failure'); + return Response.json(publication(7, gets === 1 ? 'unpublished' : 'published', { desired: gets > 1 })); + }); + const controller = new CardDAVPublicationController(createAPIClient(fetchFn)); + await controller.setPerson(7); + + expect(await controller.publish()).toEqual({ kind: 'unknown', action: 'publish' }); + expect(controller.publication).toBeUndefined(); + expect(controller.stateUnknown).toBe(true); + expect(controller.canPublish()).toBe(false); + expect(await controller.publish()).toEqual({ kind: 'ignored' }); + await controller.retryState(); + + expect([posts, gets]).toEqual([1, 3]); + expect(requests).toEqual([ + { method: 'GET', path: '/api/v1/carddav/publications/7' }, + { method: 'POST', path: '/api/v1/carddav/publications/7' }, + { method: 'GET', path: '/api/v1/carddav/publications/7' }, + { method: 'GET', path: '/api/v1/carddav/publications/7' } + ]); + expect(controller.publication?.state).toBe('published'); + expect(controller.stateUnknown).toBe(false); + controller.destroy(); + }); + + it('projects only typed CardDAV unavailable as optional state and recovers on a later load', async () => { + let configured = false; + const fetchFn = vi.fn(async () => configured + ? Response.json(publication(7, 'unpublished')) + : Response.json({ error: 'carddav_unavailable', message: forbidden.credential }, { status: 503 })); + const controller = new CardDAVPublicationController(createAPIClient(fetchFn)); + + await controller.setPerson(7); + expect(controller.unavailable).toBe(true); + expect(controller.publication).toBeUndefined(); + expect(controller.error).toBeNull(); + expect(controller.stateUnknown).toBe(false); + + configured = true; + await controller.load(); + expect(controller.unavailable).toBe(false); + expect(controller.publication?.state).toBe('unpublished'); + expect(fetchFn).toHaveBeenCalledTimes(2); + controller.destroy(); + }); + + it('invalidates a confirmed publication after a failed refresh and blocks mutations', async () => { + let reads = 0; + let posts = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + if (request.method === 'POST') { + posts += 1; + return Response.json(publication(7, 'published')); + } + reads += 1; + if (reads === 1) return Response.json(publication(7, 'unpublished')); + return Response.json({ error: 'unavailable' }, { status: 503 }); + }); + const controller = new CardDAVPublicationController(createAPIClient(fetchFn)); + await controller.setPerson(7); + expect(controller.canPublish()).toBe(true); + + await controller.retryState(); + + expect(controller.stateUnknown).toBe(true); + expect(controller.canPublish()).toBe(false); + expect(await controller.publish()).toEqual({ kind: 'ignored' }); + expect(posts).toBe(0); + controller.destroy(); + }); + + it('clears synchronously and ignores an old person response after same-controller reuse', async () => { + const oldPerson = deferredResponse(); + const signals = new Map(); + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const id = Number(new URL(request.url).pathname.split('/').at(-1)); + signals.set(id, request.signal); + if (id === 7) return oldPerson.promise; + return Response.json(publication(9, 'published', { + address_book: { id: 6, name: 'Current contacts' } + })); + }); + const controller = new CardDAVPublicationController(createAPIClient(fetchFn)); + + const first = controller.setPerson(7); + const second = controller.setPerson(9); + expect(controller.personID).toBe(9); + expect(controller.publication).toBeUndefined(); + expect(signals.get(7)?.aborted).toBe(true); + await second; + oldPerson.resolve(Response.json(publication(7, 'published', { + address_book: { id: 5, name: 'Old contacts' } + }))); + await first; + + expect(controller.personID).toBe(9); + expect(controller.publication?.address_book?.name).toBe('Current contacts'); + expect(JSON.stringify(controller)).not.toContain('Old contacts'); + controller.destroy(); + }); + + it('aborts reads and mutations and blocks late display and status state after destroy', async () => { + const mutation = deferredResponse(); + const signals: AbortSignal[] = []; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + signals.push(request.signal); + if (request.method === 'POST') return mutation.promise; + return Response.json(publication(7, 'unpublished')); + }); + const controller = new CardDAVPublicationController(createAPIClient(fetchFn)); + await controller.setPerson(7); + const pending = controller.publish(); + + controller.destroy(); + expect(signals.at(-1)?.aborted).toBe(true); + mutation.resolve(Response.json(publication(7, 'published'))); + expect(await pending).toEqual({ kind: 'ignored' }); + expect(controller.publication?.state).toBe('unpublished'); + expect(controller.announcement).toBeNull(); + }); + + it.each([ + publication(7, 'pending', { desired: true, pending_operation: 'create' }), + publication(7, 'conflict', { desired: true, conflict_id: 41 }) + ])('never mutates a generated $state publication', async (response) => { + const methods: string[] = []; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + methods.push(request.method); + return Response.json(response); + }); + const controller = new CardDAVPublicationController(createAPIClient(fetchFn)); + await controller.setPerson(7); + + expect(await controller.publish()).toEqual({ kind: 'ignored' }); + expect(await controller.unpublish()).toEqual({ kind: 'ignored' }); + expect(methods).toEqual(['GET']); + controller.destroy(); + }); + + it('aborts a mutation on person switch and never writes its old status or focus into the new person', async () => { + const mutation = deferredResponse(); + let person7Reads = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (request.method === 'POST') return mutation.promise; + if (path.endsWith('/7')) { + person7Reads += 1; + return Response.json(publication(7, 'unpublished')); + } + return Response.json(publication(9, 'published', { + address_book: { id: 6, name: 'Current contacts' } + })); + }); + const controller = new CardDAVPublicationController(createAPIClient(fetchFn)); + await controller.setPerson(7); + const oldMutation = controller.publish(); + await controller.setPerson(9); + mutation.resolve(Response.json(publication(7, 'published', { + address_book: { id: 5, name: 'Old contacts' } + }))); + + expect(await oldMutation).toEqual({ kind: 'ignored' }); + expect(person7Reads).toBe(1); + expect(controller.personID).toBe(9); + expect(controller.publication?.address_book?.name).toBe('Current contacts'); + expect(controller.announcement).toBeNull(); + controller.destroy(); + }); +}); diff --git a/web/src/lib/carddav/publication-controller.svelte.ts b/web/src/lib/carddav/publication-controller.svelte.ts new file mode 100644 index 000000000..ee587f5d8 --- /dev/null +++ b/web/src/lib/carddav/publication-controller.svelte.ts @@ -0,0 +1,263 @@ +import type { APIClient } from '../api/client'; +import type { components } from '../api/generated/schema'; + +type GeneratedPublication = components['schemas']['CardDAVPublicationResponse']; +type GeneratedBook = components['schemas']['CardDAVAddressBookIdentityResponse']; + +export type CardDAVPublicationAction = 'publish' | 'unpublish'; +export type CardDAVPublicationBook = Pick; +export type CardDAVPublication = Pick & + Partial> & { + address_book?: CardDAVPublicationBook; + }; + +export type CardDAVPublicationOutcome = + | { kind: 'confirmed'; action: CardDAVPublicationAction } + | { kind: 'reconciled'; action: CardDAVPublicationAction } + | { kind: 'unknown'; action: CardDAVPublicationAction } + | { kind: 'error'; action: CardDAVPublicationAction } + | { kind: 'ignored' }; + +type Snapshot = + | { ok: true; value: CardDAVPublication } + | { ok: false; unavailable: boolean }; + +export class CardDAVPublicationController { + personID = $state(); + publication = $state(); + loading = $state(false); + pendingAction = $state(); + error = $state(null); + unavailable = $state(false); + stateUnknown = $state(false); + announcement = $state(null); + + private readonly client: APIClient; + private disposed = false; + private generation = 0; + private readGeneration = 0; + private mutationGeneration = 0; + private readAbort?: AbortController; + private mutationAbort?: AbortController; + + constructor(client: APIClient) { + this.client = client; + } + + async setPerson(personID: number): Promise { + if (this.disposed || !Number.isSafeInteger(personID) || personID <= 0) return; + this.generation += 1; + this.readGeneration += 1; + this.mutationGeneration += 1; + this.readAbort?.abort(); + this.mutationAbort?.abort(); + this.readAbort = undefined; + this.mutationAbort = undefined; + this.personID = personID; + this.publication = undefined; + this.loading = true; + this.pendingAction = undefined; + this.error = null; + this.unavailable = false; + this.stateUnknown = false; + this.announcement = null; + await this.load(); + } + + async load(): Promise { + if (this.personID === undefined || this.disposed) return; + await this.readState(this.personID); + } + + async retryState(): Promise { + if (this.personID === undefined || this.disposed || this.loading) return; + await this.readState(this.personID); + } + + async publish(): Promise { + return await this.mutate('publish'); + } + + async unpublish(): Promise { + return await this.mutate('unpublish'); + } + + canPublish(): boolean { + return this.isActionAllowed('publish'); + } + + canUnpublish(): boolean { + return this.isActionAllowed('unpublish'); + } + + destroy(): void { + if (this.disposed) return; + this.disposed = true; + this.generation += 1; + this.readGeneration += 1; + this.mutationGeneration += 1; + this.readAbort?.abort(); + this.mutationAbort?.abort(); + this.readAbort = undefined; + this.mutationAbort = undefined; + } + + private async mutate(action: CardDAVPublicationAction): Promise { + const personID = this.personID; + if (personID === undefined || !this.isActionAllowed(action)) return { kind: 'ignored' }; + const context = this.generation; + const mutation = ++this.mutationGeneration; + const controller = new AbortController(); + this.mutationAbort?.abort(); + this.mutationAbort = controller; + this.pendingAction = action; + this.error = null; + this.announcement = null; + try { + const result = action === 'publish' + ? await this.client.POST('/api/v1/carddav/publications/{person_id}', { + params: { path: { person_id: personID } }, signal: controller.signal + }) + : await this.client.DELETE('/api/v1/carddav/publications/{person_id}', { + params: { path: { person_id: personID } }, signal: controller.signal + }); + if (!this.currentMutation(context, mutation, personID, controller.signal)) return { kind: 'ignored' }; + const confirmed = result.data ? safePublication(result.data) : undefined; + if (confirmed?.person_id === personID) { + this.applyConfirmed(confirmed, action); + return { kind: 'confirmed', action }; + } + if (result.response.status === 400 || result.response.status === 404) { + this.error = action === 'publish' + ? 'Unable to publish this person to CardDAV.' + : 'Unable to remove this person from CardDAV.'; + return { kind: 'error', action }; + } + return await this.reconcileMutation(personID, action, context, mutation); + } catch { + if (!this.currentMutation(context, mutation, personID, controller.signal)) return { kind: 'ignored' }; + return await this.reconcileMutation(personID, action, context, mutation); + } finally { + if (this.currentMutation(context, mutation, personID)) { + if (this.mutationAbort === controller) this.mutationAbort = undefined; + this.pendingAction = undefined; + } + } + } + + private async reconcileMutation( + personID: number, + action: CardDAVPublicationAction, + context: number, + mutation: number + ): Promise { + const reconciled = await this.readState(personID); + if (!this.currentMutation(context, mutation, personID)) return { kind: 'ignored' }; + if (reconciled) { + this.error = null; + this.announcement = 'CardDAV publication state refreshed.'; + return { kind: 'reconciled', action }; + } + this.stateUnknown = true; + this.error = 'Current CardDAV publication state is unknown. Retry state before changing publication.'; + return { kind: 'unknown', action }; + } + + private async readState(personID: number): Promise { + if (this.disposed || this.personID !== personID) return false; + const context = this.generation; + const request = ++this.readGeneration; + this.readAbort?.abort(); + const controller = new AbortController(); + this.readAbort = controller; + this.loading = true; + this.error = null; + const snapshot = await this.fetchState(personID, controller.signal); + if (!this.currentRead(context, request, personID, controller.signal)) return false; + if (snapshot.ok && snapshot.value.person_id === personID) { + this.publication = snapshot.value; + this.unavailable = false; + this.stateUnknown = false; + } else if (!snapshot.ok && snapshot.unavailable) { + this.publication = undefined; + this.unavailable = true; + this.stateUnknown = false; + } else { + this.publication = undefined; + this.unavailable = false; + this.stateUnknown = true; + this.error = 'Unable to load CardDAV publication state.'; + } + if (this.readAbort === controller) this.readAbort = undefined; + this.loading = false; + return snapshot.ok && snapshot.value.person_id === personID; + } + + private async fetchState(personID: number, signal: AbortSignal): Promise { + try { + const { data, error } = await this.client.GET('/api/v1/carddav/publications/{person_id}', { + params: { path: { person_id: personID } }, signal + }); + const value = data ? safePublication(data) : undefined; + if (value) return { ok: true, value }; + return { ok: false, unavailable: error?.error === 'carddav_unavailable' }; + } catch { + return { ok: false, unavailable: false }; + } + } + + private isActionAllowed(action: CardDAVPublicationAction): boolean { + if (this.disposed || this.loading || this.pendingAction !== undefined || this.stateUnknown) return false; + const publication = this.publication; + if (!publication || publication.person_id !== this.personID) return false; + if (action === 'publish') return publication.state === 'unpublished' && publication.address_book !== undefined; + return publication.state === 'published'; + } + + private applyConfirmed(publication: CardDAVPublication, action: CardDAVPublicationAction): void { + this.publication = publication; + this.unavailable = false; + this.stateUnknown = false; + this.error = null; + const book = publication.address_book?.name; + if (publication.state === 'pending') { + this.announcement = 'CardDAV publication change is pending.'; + } else if (publication.state === 'conflict') { + this.announcement = 'CardDAV publication needs conflict review.'; + } else if (publication.state === 'published') { + this.announcement = `Published this person to CardDAV${book ? ` in ${book}` : ''}.`; + } else { + this.announcement = `Removed this person from CardDAV${book ? ` in ${book}` : ''}.`; + } + } + + private current(generation: number, personID: number, signal?: AbortSignal): boolean { + return !this.disposed && this.generation === generation && this.personID === personID && !signal?.aborted; + } + + private currentRead(generation: number, request: number, personID: number, signal?: AbortSignal): boolean { + return this.current(generation, personID, signal) && this.readGeneration === request; + } + + private currentMutation(generation: number, mutation: number, personID: number, signal?: AbortSignal): boolean { + return this.current(generation, personID, signal) && this.mutationGeneration === mutation; + } +} + +function safePublication(value: GeneratedPublication): CardDAVPublication | undefined { + if (!Number.isSafeInteger(value.person_id) || value.person_id <= 0) return undefined; + const addressBook = value.address_book && Number.isSafeInteger(value.address_book.id) && value.address_book.id > 0 + ? { id: value.address_book.id, name: value.address_book.name } + : undefined; + const conflictID = value.conflict_id !== undefined && Number.isSafeInteger(value.conflict_id) && value.conflict_id > 0 + ? value.conflict_id + : undefined; + return { + person_id: value.person_id, + state: value.state, + desired: value.desired, + ...(addressBook ? { address_book: addressBook } : {}), + ...(value.pending_operation !== undefined ? { pending_operation: value.pending_operation } : {}), + ...(conflictID !== undefined ? { conflict_id: conflictID } : {}) + }; +} diff --git a/web/src/lib/components/directory/AttributeDefinitionDialog.svelte b/web/src/lib/components/directory/AttributeDefinitionDialog.svelte new file mode 100644 index 000000000..a761bfa78 --- /dev/null +++ b/web/src/lib/components/directory/AttributeDefinitionDialog.svelte @@ -0,0 +1,390 @@ + + + + {#if created} +
+ Created {created.label} + Slug: {created.slug} + Universal ID: {created.universal_id} +
+
+
+ {:else if controller.definitionCreationCommit} +
+ {#if !creating && (error ?? controller.conflict?.message ?? controller.definitionsError)} + + {/if} + {#if controller.definitionCreationCommit.kind === 'target'} +

The field was created, but the person attribute registry has not returned its exact identity yet. Retry the registry refresh; do not create the field again.

+ {#if !creating} +
+
+ {/if} + {:else} +

The server accepted the create request without a usable identity. Do not create the field again. Reload Directory before making another attempt.

+ {#if !creating} +
+
+ {/if} + {/if} +
+ {:else} +
{ event.preventDefault(); void create(); }}> + + + + + {#if supportsChoices(valueType)} + + Optional. Choices support text, integer, number, boolean, date, and timestamp values. + {/if} + {#if supportsUnit(valueType) && !hasChoices}{/if} + {#if valueType === 'text'}{/if} + + {#if error}{/if} +
+
+ + {/if} +
+ + diff --git a/web/src/lib/components/directory/AttributeDefinitionDialog.test.ts b/web/src/lib/components/directory/AttributeDefinitionDialog.test.ts new file mode 100644 index 000000000..eb8b91253 --- /dev/null +++ b/web/src/lib/components/directory/AttributeDefinitionDialog.test.ts @@ -0,0 +1,863 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../../api/client'; +import type { components } from '../../api/generated/schema'; +import type { DirectoryReadBundle } from '../../directory/models'; +import { DirectoryProfileController } from '../../directory/profile-controller.svelte'; +import { chooseSelectOption } from '../../../test/kit-ui'; +import AttributeDefinitionDialog from './AttributeDefinitionDialog.svelte'; +import AttributeSection from './AttributeSection.svelte'; + +type AttributeDefinition = components['schemas']['AttributeDefinition']; + +const when = '2026-08-01T00:00:00Z'; + +afterEach(() => cleanup()); + +function definition(overrides: Partial = {}): AttributeDefinition { + return { + id: 41, + universal_id: '00000000-0000-4000-8000-000000000041', + object_type: 'person', + slug: 'preferred_channel', + label: 'Preferred channel', + description: 'How this person prefers to be contacted', + value_type: 'text', + field_type: 'select', + cardinality: 'single', + display_order: 0, + is_required: false, + ownership: 'user', + ui_creatable: true, + ui_editable: true, + api_mutable: true, + is_searchable: false, + is_sensitive: true, + is_audited: false, + is_deletable: true, + history_exempt: false, + options: { choices: [{ value: 'email', label: 'email' }, { value: 'phone', label: 'phone' }] }, + is_active: true, + revision: 1, + created_at: when, + updated_at: when, + ...overrides + }; +} + +function controller(fetchFn: typeof fetch, definitions: AttributeDefinition[] = []): DirectoryProfileController { + const bundle = { + definitions: { definitions }, + etags: {}, + errors: {} + } satisfies DirectoryReadBundle; + return new DirectoryProfileController(createAPIClient(fetchFn), 7, bundle); +} + +describe('AttributeDefinitionDialog', () => { + it('creates a sensitive user choice definition, refreshes the registry, and focuses the returned identity', async () => { + const requests: Request[] = []; + const created = definition(); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + if (request.method === 'POST') return Response.json(created, { status: 201 }); + return Response.json({ definitions: [created] }); + }); + const profile = controller(fetchFn); + + render(AttributeDefinitionDialog, { controller: profile, onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: ' Preferred channel ' } }); + await fireEvent.input(screen.getByLabelText('Description'), { target: { value: ' How this person prefers to be contacted ' } }); + await fireEvent.input(screen.getByLabelText('Choices'), { target: { value: ' email\nphone ' } }); + await fireEvent.click(screen.getByRole('checkbox', { name: 'Sensitive' })); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + + await waitFor(() => expect(profile.createdDefinition).toEqual(created)); + expect(requests).toHaveLength(2); + expect(requests.map((request) => [request.method, new URL(request.url).pathname])).toEqual([ + ['POST', '/api/v1/attribute-definitions'], + ['GET', '/api/v1/attribute-definitions'] + ]); + const body = await requests[0]!.clone().json() as Record; + expect(body).toEqual({ + object_type: 'person', + label: 'Preferred channel', + description: 'How this person prefers to be contacted', + value_type: 'text', + field_type: 'select', + cardinality: 'single', + is_sensitive: true, + options: { choices: [{ value: 'email', label: 'email' }, { value: 'phone', label: 'phone' }] } + }); + expect(body).not.toHaveProperty('ownership'); + expect(profile.createdDefinition).toEqual(created); + expect(profile.definitions).toEqual([created]); + expect(profile.createdDefinition).toBe(profile.definitions[0]); + const result = screen.getByRole('status'); + expect(result.textContent).toContain('preferred_channel'); + expect(result.textContent).toContain(created.universal_id); + expect(document.activeElement).toBe(result); + }); + + it.each([ + ['Text', 'text', 'text', undefined], + ['Integer', 'integer', 'duration', undefined], + ['Number', 'real', 'text', undefined], + ['Boolean', 'boolean', 'checkbox', undefined], + ['Date', 'date', 'date', undefined], + ['Timestamp', 'timestamp', 'timestamp', undefined], + ['JSON', 'json', 'json', undefined], + ['Person reference', 'record_reference', 'person', 'person'] + ])('sends a server-compatible %s definition', async (optionLabel, valueType, fieldType, recordTarget) => { + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const created = definition({ + label: 'Synthetic field', slug: 'synthetic_field', value_type: valueType, + field_type: fieldType, ...(recordTarget ? { record_target: recordTarget } : {}), is_sensitive: false, + options: undefined + }); + if (request.method === 'POST') return Response.json(created, { status: 201 }); + return Response.json({ definitions: [created] }); + }); + + render(AttributeDefinitionDialog, { controller: controller(fetchFn), onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Synthetic field' } }); + await chooseSelectOption(screen.getByLabelText('Value type'), optionLabel); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + await waitFor(() => expect(requests).toHaveLength(2)); + + const body = await requests[0]!.clone().json() as Record; + expect(body).toMatchObject({ value_type: valueType, field_type: fieldType }); + if (recordTarget) expect(body).toHaveProperty('record_target', recordTarget); + else expect(body).not.toHaveProperty('record_target'); + }); + + it('sends trimmed labeled text choices and max length with multi-select cardinality', async () => { + const requests: Request[] = []; + const created = definition({ cardinality: 'multi', field_type: 'multiselect', is_sensitive: false }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + return request.method === 'POST' + ? Response.json(created, { status: 201 }) + : Response.json({ definitions: [created] }); + }); + + render(AttributeDefinitionDialog, { controller: controller(fetchFn), onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Preferred channel' } }); + await chooseSelectOption(screen.getByLabelText('Cardinality'), 'Multiple values'); + await fireEvent.input(screen.getByLabelText('Choices'), { target: { value: ' email | Email\n phone | Phone ' } }); + await fireEvent.input(screen.getByLabelText('Maximum length'), { target: { value: ' 32 ' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + await waitFor(() => expect(requests).toHaveLength(2)); + + await expect(requests[0]!.clone().json()).resolves.toMatchObject({ + cardinality: 'multi', field_type: 'multiselect', + options: { + choices: [{ value: 'email', label: 'Email' }, { value: 'phone', label: 'Phone' }], + max_length: 32 + } + }); + }); + + it.each([ + ['a blank label', '', '', 'Enter a label.'], + ['a choice missing its value', 'Field', ' | Visible label', 'Each choice needs a value.'], + ['a choice missing its label', 'Field', 'value | ', 'Each choice needs a label.'], + ['trim-equivalent choices', 'Field', ' email | Email\nemail | Other', 'Choice values must be unique.'] + ])('reports %s before issuing a request', async (_case, label, choices, message) => { + const fetchFn = vi.fn(); + render(AttributeDefinitionDialog, { controller: controller(fetchFn), onClose: vi.fn() }); + if (label) await fireEvent.input(screen.getByLabelText('Label'), { target: { value: label } }); + if (choices) await fireEvent.input(screen.getByLabelText('Choices'), { target: { value: choices } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + + expect(screen.getByRole('alert').textContent).toContain(message); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('uses the server canonical value when detecting duplicate integer choices', async () => { + const fetchFn = vi.fn(); + render(AttributeDefinitionDialog, { controller: controller(fetchFn), onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Integer choice' } }); + await chooseSelectOption(screen.getByLabelText('Value type'), 'Integer'); + await fireEvent.input(screen.getByLabelText('Choices'), { target: { value: '01 | First\n1 | Second' } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + + expect(screen.getByRole('alert').textContent).toContain('Choice values must be unique.'); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('clears and hides choices when switching to a value type without a server canonical string', async () => { + const requests: Request[] = []; + const created = definition({ value_type: 'json', field_type: 'json', options: undefined, is_sensitive: false }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + return request.method === 'POST' ? Response.json(created, { status: 201 }) : Response.json({ definitions: [created] }); + }); + render(AttributeDefinitionDialog, { controller: controller(fetchFn), onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'JSON field' } }); + await fireEvent.input(screen.getByLabelText('Choices'), { target: { value: 'stale choice' } }); + await chooseSelectOption(screen.getByLabelText('Value type'), 'JSON'); + expect(screen.queryByLabelText('Choices')).toBeNull(); + + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + await waitFor(() => expect(requests).toHaveLength(2)); + const body = await requests[0]!.clone().json() as Record; + expect(body).not.toHaveProperty('options'); + }); + + it.each([ + ['-1', 'Maximum length must be a positive whole number'], + ['1.5', 'Maximum length must be a positive whole number'] + ])('rejects invalid text maximum length %s before issuing a request', async (limit, message) => { + const fetchFn = vi.fn(); + render(AttributeDefinitionDialog, { controller: controller(fetchFn), onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Limited text' } }); + await fireEvent.input(screen.getByLabelText('Maximum length'), { target: { value: limit } }); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + expect(screen.getByRole('alert').textContent).toContain(message); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('treats zero text maximum length as unset', async () => { + const requests: Request[] = []; + const created = definition({ field_type: 'text', options: undefined, is_sensitive: false }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + return request.method === 'POST' ? Response.json(created, { status: 201 }) : Response.json({ definitions: [created] }); + }); + render(AttributeDefinitionDialog, { controller: controller(fetchFn), onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Unlimited text' } }); + await fireEvent.input(screen.getByLabelText('Maximum length'), { target: { value: '0' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + await waitFor(() => expect(requests).toHaveLength(2)); + const body = await requests[0]!.clone().json() as Record; + expect(body).not.toHaveProperty('options'); + }); + + it.each([ + ['ordinary text', 'email', '4'], + ['surrounding Go space', ' email ', '4'], + ['astral code points', '😀😀', '1'], + ['NEL boundaries', '\u0085ab\u0085', '1'], + ['BOM content boundaries', '\uFEFFa\uFEFF', '2'] + ])('blocks a text choice over max_length after canonical normalization: %s', async (_case, choice, limit) => { + const created = definition({ field_type: 'select', is_sensitive: false }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + return request.method === 'POST' ? Response.json(created, { status: 201 }) : Response.json({ definitions: [created] }); + }); + render(AttributeDefinitionDialog, { controller: controller(fetchFn), onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Limited choice' } }); + await fireEvent.input(screen.getByLabelText('Choices'), { target: { value: choice } }); + await fireEvent.input(screen.getByLabelText('Maximum length'), { target: { value: limit } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + + expect(screen.getByRole('alert').textContent).toContain(`Each text choice must be ${limit} characters or fewer.`); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('revalidates a text-choice length error when max_length or value type changes', async () => { + const fetchFn = vi.fn(); + render(AttributeDefinitionDialog, { controller: controller(fetchFn), onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Limited choice' } }); + await fireEvent.input(screen.getByLabelText('Choices'), { target: { value: 'email' } }); + await fireEvent.input(screen.getByLabelText('Maximum length'), { target: { value: '4' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + expect(screen.getByRole('alert').textContent).toContain('Each text choice must be 4 characters or fewer.'); + + await fireEvent.input(screen.getByLabelText('Maximum length'), { target: { value: '5' } }); + expect(screen.queryByRole('alert')).toBeNull(); + + await fireEvent.input(screen.getByLabelText('Maximum length'), { target: { value: '4' } }); + expect(screen.getByRole('alert').textContent).toContain('Each text choice must be 4 characters or fewer.'); + + await chooseSelectOption(screen.getByLabelText('Value type'), 'JSON'); + expect(screen.queryByRole('alert')).toBeNull(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('creates, refetches, and saves a canonical text choice exactly at max_length through AttributeEditor', async () => { + const canonicalChoice = '😀\uFEFF'; + const created = definition({ + id: 74, universal_id: 'created-limited-choice', slug: 'limited_choice', label: 'Limited choice', + field_type: 'select', is_sensitive: false, + options: { max_length: 2, choices: [{ value: canonicalChoice, label: 'Astral plus BOM' }] } + }); + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + if (request.method === 'POST') return Response.json(created, { status: 201 }); + if (request.method === 'GET') return Response.json({ definitions: [created] }); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json({ + dry_run: false, + value: { + id: 104, person_id: 7, definition_id: created.id, definition_slug: created.slug, + ordinal: 0, value: body.value, active_from: when, created_at: when, + source: 'user', actor: 'synthetic-user' + } + }); + }); + render(AttributeSection, { controller: controller(fetchFn) }); + await fireEvent.click(screen.getByRole('button', { name: 'Create attribute field' })); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Limited choice' } }); + await fireEvent.input(screen.getByLabelText('Choices'), { + target: { value: `\u0085${canonicalChoice}\u0085 | Astral plus BOM` } + }); + await fireEvent.input(screen.getByLabelText('Maximum length'), { target: { value: '2' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + await screen.findByRole('status'); + await fireEvent.click(screen.getByRole('button', { name: 'Done' })); + await fireEvent.click(screen.getByRole('button', { name: 'Add Limited choice value' })); + await fireEvent.change(screen.getByRole('combobox', { name: 'Limited choice' }), { target: { value: canonicalChoice } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + await waitFor(() => expect(requests.filter((request) => request.method === 'PUT')).toHaveLength(1)); + + const definitionBody = await requests.find((request) => request.method === 'POST')!.clone().json(); + expect(definitionBody).toMatchObject({ + options: { max_length: 2, choices: [{ value: canonicalChoice, label: 'Astral plus BOM' }] } + }); + await expect(requests.find((request) => request.method === 'PUT')!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: canonicalChoice }, source: 'user' + }); + }); + + it.each(['single', 'multi'] as const)( + 'renders current and history for a created %s definition from its first save without a refetch', + async (cardinality) => { + const created = definition({ + id: cardinality === 'single' ? 81 : 82, + universal_id: `created-first-${cardinality}`, + slug: `created_first_${cardinality}`, + label: `First ${cardinality}`, + field_type: 'text', + cardinality, + is_sensitive: false, + options: undefined + }); + const requests: Request[] = []; + let current: components['schemas']['PersonAttributeValue'] | undefined; + let nextValueID = 100; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + if (request.method === 'POST') return Response.json(created, { status: 201 }); + if (request.method === 'GET') return Response.json({ definitions: [created] }); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + const prior = current; + current = { + id: nextValueID++, person_id: 7, definition_id: created.id, definition_slug: created.slug, + ordinal: 0, value: body.value, active_from: when, created_at: when, source: 'user', actor: 'synthetic-user' + }; + return Response.json({ + dry_run: false, + value: current, + ...(prior ? { superseded: { ...prior, active_until: when, superseded_at: when } } : {}) + }); + }); + const profile = controller(fetchFn); + profile.attributes = { person_id: 7, attributes: [] }; + render(AttributeSection, { controller: profile }); + + await fireEvent.click(screen.getByRole('button', { name: 'Create attribute field' })); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: `First ${cardinality}` } }); + if (cardinality === 'multi') await chooseSelectOption(screen.getByLabelText('Cardinality'), 'Multiple values'); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + await screen.findByRole('status'); + await fireEvent.click(screen.getByRole('button', { name: 'Done' })); + await fireEvent.click(screen.getByRole('button', { name: `Add First ${cardinality} value` })); + await fireEvent.input(screen.getByRole('textbox', { name: `First ${cardinality}` }), { target: { value: 'Initial value' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + expect(await screen.findByText('Initial value')).toBeDefined(); + await waitFor(() => expect(profile.mutationPending).toBe(false)); + expect(screen.queryByText('No current value.')).toBeNull(); + expect(screen.getByRole('button', { name: `Edit First ${cardinality} value 1` })).toBeDefined(); + await profile.setAttribute(created.slug, { + value: { type: 'text', text: 'Replacement value' }, expected_value_id: 100, + ...(cardinality === 'multi' ? { ordinal: 0 } : {}) + }); + + expect(await screen.findByText('Replacement value')).toBeDefined(); + const history = screen.getByText('History (1)'); + await fireEvent.click(history); + expect(screen.getByText('Initial value')).toBeDefined(); + expect(requests).toHaveLength(4); + const writes = requests.filter((request) => request.method === 'PUT'); + expect(writes).toHaveLength(2); + await expect(writes[0]!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: 'Initial value' }, source: 'user' + }); + await expect(writes[1]!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: 'Replacement value' }, source: 'user', expected_value_id: 100, + ...(cardinality === 'multi' ? { ordinal: 0 } : {}) + }); + } + ); + + it.each([ + ['Integer', '9007199254740992', 'JavaScript-safe whole number'], + ['Integer', '-0', 'negative zero'], + ['Integer', '-00', 'negative zero'], + ['Number', '-0', 'negative zero'], + ['Number', '1e20', 'ordinary decimal'], + ['Number', '0.00001', 'ordinary decimal'], + ['Number', '0x1p2', 'ordinary decimal'], + ['Timestamp', '2026-01-01T00:00:00.1234567890Z', 'at most nine fractional digits'], + ['Timestamp', '2026-01-01T00:00:00.120Z', 'omit trailing zero'], + ['Timestamp', '2026-01-01T00:00:00+01:00', 'canonical UTC'], + ['Timestamp', '0000-01-01T00:00:00+01:00', 'canonical UTC'], + ['Timestamp', '9999-12-31T23:59:59-01:00', 'canonical UTC'] + ])('rejects a %s choice that cannot round-trip exactly through generated JSON: %s', async (type, choice, message) => { + const fetchFn = vi.fn(); + render(AttributeDefinitionDialog, { controller: controller(fetchFn), onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Boundary choice' } }); + await chooseSelectOption(screen.getByLabelText('Value type'), type); + await fireEvent.input(screen.getByLabelText('Choices'), { target: { value: choice } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + + expect(screen.getByRole('alert').textContent).toContain(message); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('clears stale type-specific options and omits a unit for numeric choices', async () => { + const requests: Request[] = []; + const created = definition({ + label: 'Boundary count', slug: 'boundary_count', value_type: 'integer', field_type: 'select', + is_sensitive: false, options: { choices: [{ value: '1', label: 'One' }] } + }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + return request.method === 'POST' ? Response.json(created, { status: 201 }) : Response.json({ definitions: [created] }); + }); + render(AttributeDefinitionDialog, { controller: controller(fetchFn), onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Boundary count' } }); + await fireEvent.input(screen.getByLabelText('Maximum length'), { target: { value: '5' } }); + await chooseSelectOption(screen.getByLabelText('Value type'), 'Integer'); + expect(screen.queryByLabelText('Maximum length')).toBeNull(); + await fireEvent.input(screen.getByLabelText('Unit'), { target: { value: 'items' } }); + await fireEvent.input(screen.getByLabelText('Choices'), { target: { value: '1 | One' } }); + expect(screen.queryByLabelText('Unit')).toBeNull(); + + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + await waitFor(() => expect(requests).toHaveLength(2)); + + await expect(requests[0]!.clone().json()).resolves.toMatchObject({ + value_type: 'integer', field_type: 'select', options: { choices: [{ value: '1', label: 'One' }] } + }); + const body = await requests[0]!.clone().json() as { options: Record }; + expect(body.options).not.toHaveProperty('unit'); + expect(body.options).not.toHaveProperty('max_length'); + }); + + it('rejects a created definition that is not returned user-owned', async () => { + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + return Response.json(definition({ ownership: 'system' }), { status: 201 }); + }); + const profile = controller(fetchFn); + render(AttributeDefinitionDialog, { controller: profile, onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Wrong owner' } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + + expect((await screen.findByRole('alert')).textContent).toContain('user-owned'); + expect(requests).toHaveLength(1); + expect(profile.createdDefinition).toBeNull(); + }); + + it.each([ + ['the returned definition belongs to an organization', definition({ object_type: 'organization' }), []], + ['the refreshed person registry omits the returned identity', definition(), [definition({ universal_id: 'different-id', slug: 'different_field' })]] + ])('retains the draft and reports failure when %s', async (_case, returned, refreshed) => { + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + return request.method === 'POST' + ? Response.json(returned, { status: 201 }) + : Response.json({ definitions: refreshed }); + }); + const profile = controller(fetchFn); + render(AttributeDefinitionDialog, { controller: profile, onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Returned mismatch' } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + + expect((await screen.findByRole('alert')).textContent).toContain('person attribute registry'); + expect(profile.draft?.kind).toBe('createDefinition'); + expect(profile.createdDefinition).toBeNull(); + }); + + it('retries only registry refresh after a committed POST until the exact refreshed identity appears', async () => { + const created = definition({ is_sensitive: false }); + const requests: Request[] = []; + let getAttempt = 0; + let resolveSecondGet: ((response: Response) => void) | undefined; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + if (request.method === 'POST') return Response.json(created, { status: 201 }); + getAttempt += 1; + if (getAttempt === 1) return Response.json({ error: 'upstream', message: 'Registry temporarily unavailable' }, { status: 503 }); + if (getAttempt === 2) return new Promise((resolve) => { resolveSecondGet = resolve; }); + return Response.json({ definitions: [created] }); + }); + const onClose = vi.fn(); + const profile = controller(fetchFn); + render(AttributeDefinitionDialog, { controller: profile, onClose }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Preferred channel' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + + expect((await screen.findByRole('alert')).textContent).toContain('Registry temporarily unavailable'); + expect(screen.queryByRole('button', { name: 'Create field' })).toBeNull(); + expect(profile.definitionCreationCommit).toEqual({ + kind: 'target', universalID: created.universal_id, slug: created.slug + }); + + const retry = screen.getByRole('button', { name: 'Retry registry refresh' }); + await fireEvent.click(retry); + await fireEvent.click(retry); + expect(requests.filter((request) => request.method === 'POST')).toHaveLength(1); + expect(requests.filter((request) => request.method === 'GET')).toHaveLength(2); + expect(retry).toHaveProperty('disabled', true); + await fireEvent.click(screen.getByRole('button', { name: 'Close create attribute field' })); + expect(onClose).not.toHaveBeenCalled(); + + resolveSecondGet?.(Response.json({ definitions: [] })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Retry registry refresh' })).toHaveProperty('disabled', false)); + await fireEvent.click(screen.getByRole('button', { name: 'Retry registry refresh' })); + const result = await screen.findByRole('status'); + + expect(requests.filter((request) => request.method === 'POST')).toHaveLength(1); + expect(requests.filter((request) => request.method === 'GET')).toHaveLength(3); + expect(profile.createdDefinition).toBe(profile.definitions[0]); + expect(profile.definitionCreationCommit).toBeNull(); + expect(profile.draft).toBeNull(); + expect(document.activeElement).toBe(result); + }); + + it('keeps committed reconciliation controller-owned across dialog close and reopen', async () => { + const created = definition({ is_sensitive: false }); + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + return request.method === 'POST' + ? Response.json(created, { status: 201 }) + : Response.json({ definitions: [] }); + }); + const profile = controller(fetchFn); + const onClose = vi.fn(); + const view = render(AttributeDefinitionDialog, { controller: profile, onClose }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Preferred channel' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + await screen.findByRole('button', { name: 'Retry registry refresh' }); + await fireEvent.click(screen.getByRole('button', { name: 'Close create attribute field' })); + expect(onClose).toHaveBeenCalledOnce(); + view.unmount(); + + render(AttributeDefinitionDialog, { controller: profile, onClose: vi.fn() }); + expect(screen.getByRole('button', { name: 'Retry registry refresh' })).toBeDefined(); + expect(screen.queryByRole('button', { name: 'Create field' })).toBeNull(); + await expect(profile.createDefinition({ + object_type: 'person', label: 'Preferred channel', value_type: 'text', field_type: 'text', + cardinality: 'single', is_sensitive: false + })).resolves.toEqual({ ok: false, code: 'conflict_unresolved' }); + expect(requests.filter((request) => request.method === 'POST')).toHaveLength(1); + }); + + it('treats a successful POST without a usable identity as non-repeatable and offers reload only', async () => { + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + if (request.method === 'POST') { + return Response.json({ ownership: 'user', object_type: 'person' }, { status: 201 }); + } + if (new URL(request.url).pathname === '/api/v1/attribute-definitions') return Response.json({ definitions: [] }); + return Response.json({}); + }); + const profile = controller(fetchFn); + render(AttributeDefinitionDialog, { controller: profile, onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Unidentified field' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + + expect((await screen.findByRole('alert')).textContent).toContain('usable identity'); + expect(profile.definitionCreationCommit).toEqual({ kind: 'unknown' }); + expect(screen.queryByRole('button', { name: 'Create field' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Retry registry refresh' })).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Reload Directory' })); + await waitFor(() => expect(requests.filter((request) => request.method === 'GET')).toHaveLength(4)); + expect(requests.filter((request) => request.method === 'POST')).toHaveLength(1); + expect(profile.definitionCreationCommit).toEqual({ kind: 'unknown' }); + }); + + it('treats an empty successful POST as non-repeatable', async () => { + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + return request.method === 'POST' + ? new Response(null, { status: 204 }) + : Response.json({ definitions: [] }); + }); + const profile = controller(fetchFn); + render(AttributeDefinitionDialog, { controller: profile, onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Unidentified field' } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + + expect((await screen.findByRole('alert')).textContent).toContain('usable identity'); + expect(profile.definitionCreationCommit).toEqual({ kind: 'unknown' }); + expect(screen.queryByRole('button', { name: 'Create field' })).toBeNull(); + await expect(profile.createDefinition({ + object_type: 'person', label: 'Unidentified field', value_type: 'text', field_type: 'text', + cardinality: 'single', is_sensitive: false + })).resolves.toEqual({ ok: false, code: 'conflict_unresolved' }); + expect(requests.filter((request) => request.method === 'POST')).toHaveLength(1); + }); + + it('treats a malformed successful POST as non-repeatable through the production API client', async () => { + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + return request.method === 'POST' + ? new Response('{"ownership":"user",', { status: 201, headers: { 'Content-Type': 'application/json' } }) + : Response.json({ definitions: [] }); + }); + const profile = controller(fetchFn); + render(AttributeDefinitionDialog, { controller: profile, onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Malformed response field' } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + + expect((await screen.findByRole('alert')).textContent).toContain('usable identity'); + expect(profile.definitionCreationCommit).toEqual({ kind: 'unknown' }); + expect(screen.queryByRole('button', { name: 'Create field' })).toBeNull(); + await expect(profile.createDefinition({ + object_type: 'person', label: 'Malformed response field', value_type: 'text', field_type: 'text', + cardinality: 'single', is_sensitive: false + })).resolves.toEqual({ ok: false, code: 'conflict_unresolved' }); + expect(requests.filter((request) => request.method === 'POST')).toHaveLength(1); + }); + + it('records a 201 before its body stream fails without observing another controller request', async () => { + const decoy = definition({ id: 99, universal_id: 'decoy-id', slug: 'stream_field' }); + const requests: Request[] = []; + let resolvePost: ((response: Response) => void) | undefined; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + if (request.method === 'POST') return new Promise((resolve) => { resolvePost = resolve; }); + return Response.json({ definitions: [decoy] }); + }); + const client = createAPIClient(fetchFn); + const initialBundle = { definitions: { definitions: [] }, etags: {}, errors: {} } satisfies DirectoryReadBundle; + const profile = new DirectoryProfileController(client, 7, initialBundle); + const concurrentProfile = new DirectoryProfileController(client, 8, initialBundle); + render(AttributeDefinitionDialog, { controller: profile, onClose: vi.fn() }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Stream field' } }); + void fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + await waitFor(() => expect(resolvePost).toBeDefined()); + + await concurrentProfile.reloadDefinitions(); + expect(profile.definitionCreationCommit).toBeNull(); + + const interruptedBody = new ReadableStream({ + start(stream) { + stream.enqueue(new TextEncoder().encode('{"ownership":"user"')); + stream.error(new Error('body interrupted after 201 headers')); + } + }); + resolvePost?.(new Response(interruptedBody, { status: 201, headers: { 'Content-Type': 'application/json' } })); + + expect((await screen.findByRole('alert')).textContent).toContain('body interrupted after 201 headers'); + expect(profile.definitionCreationCommit).toEqual({ kind: 'unknown' }); + expect(profile.createdDefinition).toBeNull(); + expect(screen.queryByRole('button', { name: 'Create field' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Retry registry refresh' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Reload Directory' })).toBeDefined(); + + const retryBody = { + object_type: 'person', label: 'Stream field', value_type: 'text', field_type: 'text', + cardinality: 'single', is_sensitive: false + } as const; + await expect(profile.createDefinition(retryBody)).resolves.toEqual({ ok: false, code: 'conflict_unresolved' }); + await expect(profile.createDefinition(retryBody)).resolves.toEqual({ ok: false, code: 'conflict_unresolved' }); + expect(requests.filter((request) => request.method === 'POST')).toHaveLength(1); + + await fireEvent.click(screen.getByRole('button', { name: 'Reload Directory' })); + await waitFor(() => expect(requests.filter((request) => request.method === 'GET')).toHaveLength(5)); + expect(profile.definitionCreationCommit).toEqual({ kind: 'unknown' }); + expect(profile.createdDefinition).toBeNull(); + }); + + it('keeps one request pending, blocks every dismissal path, and renders the server error inline', async () => { + let respond: ((response: Response) => void) | undefined; + const fetchFn = vi.fn(() => new Promise((resolve) => { respond = resolve; })); + const onClose = vi.fn(); + render(AttributeDefinitionDialog, { controller: controller(fetchFn), onClose }); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Pending field' } }); + const form = screen.getByRole('button', { name: 'Create field' }).closest('form')!; + + await fireEvent.submit(form); + await fireEvent.submit(form); + expect(fetchFn).toHaveBeenCalledOnce(); + expect(screen.getByRole('button', { name: 'Create field' })).toHaveProperty('disabled', true); + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await fireEvent.keyDown(window, { key: 'Escape' }); + await fireEvent.pointerDown(document.querySelector('.kit-modal-overlay')!); + await fireEvent.click(screen.getByRole('button', { name: 'Close create attribute field' })); + expect(onClose).not.toHaveBeenCalled(); + + respond?.(Response.json({ error: 'attribute_invalid', message: 'Synthetic server validation failure' }, { status: 400 })); + expect((await screen.findByRole('alert')).textContent).toContain('Synthetic server validation failure'); + expect(screen.getByRole('button', { name: 'Create field' })).toHaveProperty('disabled', false); + }); + + it.each([ + { + name: 'integer', option: 'Integer', slug: 'safe_integer_choice', + choices: ['-9007199254740991', '9007199254740991'], + values: [{ type: 'integer', integer: -9007199254740991 }, { type: 'integer', integer: 9007199254740991 }] + }, + { + name: 'real', option: 'Number', slug: 'safe_real_choice', + choices: ['-999999.5', '0', '0.0001', '999999.5'], + values: [ + { type: 'real', real: -999999.5 }, { type: 'real', real: 0 }, + { type: 'real', real: 0.0001 }, { type: 'real', real: 999999.5 } + ] + }, + { + name: 'timestamp', option: 'Timestamp', slug: 'safe_timestamp_choice', + choices: ['0000-01-01T00:00:00Z', '2026-01-01T00:00:00.123456789Z', '9999-12-31T23:59:59Z'], + values: [ + { type: 'timestamp', timestamp: '0000-01-01T00:00:00Z' }, + { type: 'timestamp', timestamp: '2026-01-01T00:00:00.123456789Z' }, + { type: 'timestamp', timestamp: '9999-12-31T23:59:59Z' } + ] + } + ])('creates, refetches, selects, and saves every accepted $name boundary choice through AttributeEditor', async ({ option, slug, choices, values }) => { + const created = definition({ + id: 73, universal_id: `created-${slug}`, slug, label: 'Boundary choice', + value_type: values[0]!.type, field_type: 'multiselect', cardinality: 'multi', is_sensitive: false, + options: { choices: choices.map((value) => ({ value, label: value })) } + }); + const requests: Request[] = []; + let valueID = 100; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const path = new URL(request.url).pathname; + if (request.method === 'POST') return Response.json(created, { status: 201 }); + if (request.method === 'GET') return Response.json({ definitions: [created] }); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json({ + dry_run: false, + value: { + id: valueID++, person_id: 7, definition_id: created.id, definition_slug: slug, + ordinal: body.ordinal ?? 0, value: body.value, active_from: when, created_at: when, + source: 'user', actor: 'synthetic-user' + } + }); + }); + const profile = controller(fetchFn); + render(AttributeSection, { controller: profile }); + await fireEvent.click(screen.getByRole('button', { name: 'Create attribute field' })); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Boundary choice' } }); + await chooseSelectOption(screen.getByLabelText('Value type'), option); + await chooseSelectOption(screen.getByLabelText('Cardinality'), 'Multiple values'); + await fireEvent.input(screen.getByLabelText('Choices'), { target: { value: choices.join('\n') } }); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + await screen.findByRole('status'); + await fireEvent.click(screen.getByRole('button', { name: 'Done' })); + + for (const [index, choice] of choices.entries()) { + await fireEvent.click(screen.getByRole('button', { name: 'Add Boundary choice value' })); + await fireEvent.change(screen.getByRole('combobox', { name: 'Boundary choice' }), { target: { value: choice } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + await waitFor(() => expect(requests.filter((request) => request.method === 'PUT')).toHaveLength(index + 1)); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Save attribute' })).toBeNull()); + } + + const definitionBody = await requests.find((request) => request.method === 'POST')!.clone().json() as Record; + expect(definitionBody).toMatchObject({ options: { choices: created.options!.choices } }); + const writes = await Promise.all(requests.filter((request) => request.method === 'PUT').map((request) => request.clone().json())); + expect(writes).toEqual(values.map((value) => ({ value, source: 'user' }))); + }); + + it.each([ + { option: 'Text', slug: 'short_note', options: { max_length: 5 }, fill: async () => fireEvent.input(screen.getByLabelText('Maximum length'), { target: { value: '5' } }), visible: '0 / 5 characters.' }, + { option: 'Integer', slug: 'duration_days', options: { unit: 'days' }, fill: async () => fireEvent.input(screen.getByLabelText('Unit'), { target: { value: 'days' } }), visible: 'days' } + ])('makes a created $option option visible in the production editor', async ({ option, slug, options, fill, visible }) => { + const created = definition({ + universal_id: `created-${slug}`, slug, label: 'Option field', value_type: option === 'Text' ? 'text' : 'integer', + field_type: option === 'Text' ? 'text' : 'duration', is_sensitive: false, options + }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + return request.method === 'POST' ? Response.json(created, { status: 201 }) : Response.json({ definitions: [created] }); + }); + render(AttributeSection, { controller: controller(fetchFn) }); + await fireEvent.click(screen.getByRole('button', { name: 'Create attribute field' })); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Option field' } }); + await chooseSelectOption(screen.getByLabelText('Value type'), option); + await fill(); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + await screen.findByRole('status'); + await fireEvent.click(screen.getByRole('button', { name: 'Done' })); + await fireEvent.click(screen.getByRole('button', { name: 'Add Option field value' })); + + expect(screen.getByRole('form', { name: 'Add Option field value' }).textContent).toContain(visible); + }); + + it('refreshes definitions without replacing the selected attribute draft and makes the new field immediately usable', async () => { + const existing = definition({ + id: 8, universal_id: '00000000-0000-4000-8000-000000000008', slug: 'existing_field', + label: 'Existing field', field_type: 'text', is_sensitive: false, options: undefined + }); + const created = definition({ is_sensitive: false }); + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + if (request.method === 'POST') return Response.json(created, { status: 201 }); + return Response.json({ definitions: [existing, created] }); + }); + const profile = controller(fetchFn, [existing]); + render(AttributeSection, { controller: profile }); + await fireEvent.click(screen.getByRole('button', { name: 'Add Existing field value' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Existing field' }), { target: { value: 'retained local draft' } }); + const createTrigger = screen.getByRole('button', { name: 'Create attribute field' }); + createTrigger.focus(); + await fireEvent.click(createTrigger); + await fireEvent.input(screen.getByLabelText('Label'), { target: { value: 'Preferred channel' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Create field' })); + await screen.findByRole('status'); + await fireEvent.click(screen.getByRole('button', { name: 'Done' })); + + await waitFor(() => expect(document.activeElement).toBe(createTrigger)); + expect(profile.createdDefinition).toBeNull(); + expect(screen.getByRole('textbox', { name: 'Existing field' })).toHaveProperty('value', 'retained local draft'); + expect(screen.getByRole('button', { name: 'Add Preferred channel value' })).toBeDefined(); + await fireEvent.click(screen.getByRole('button', { name: 'Add Preferred channel value' })); + expect(screen.getByRole('combobox', { name: 'Preferred channel' })).toBeDefined(); + }); +}); diff --git a/web/src/lib/components/directory/AttributeEditor.svelte b/web/src/lib/components/directory/AttributeEditor.svelte new file mode 100644 index 000000000..4a314b43d --- /dev/null +++ b/web/src/lib/components/directory/AttributeEditor.svelte @@ -0,0 +1,350 @@ + + +{#if initialDefinition.is_sensitive && !sensitiveRevealed} + +{:else if kind === 'unsupported'} +

This {initialDefinition.value_type} value is read-only in the web editor.

+{:else} +
+ {#if kind === 'boolean'} + + {:else if kind === 'choice'} + + + {:else if kind === 'date'} +
+ {initialDefinition.label} + { draft = date; calendarMonth = date; }} + /> +
+ {:else if kind === 'timestamp'} + + + {:else if kind === 'json'} + + + {:else if kind === 'number'} + +
+ { draft = event.currentTarget.value; }} disabled={!definitionAllowsSet} /> + {#if initialDefinition.options?.unit}{initialDefinition.options.unit}{/if} +
+ {:else if kind === 'person'} + + { draft = event.currentTarget.value; }} disabled={!definitionAllowsSet} /> + {:else if initialDefinition.field_type === 'textarea'} + + + {:else} + + + {/if} + + {#if initialDefinition.value_type === 'text' && initialDefinition.options?.max_length} + {trimmedCharacterCount} / {initialDefinition.options.max_length} characters. + {/if} + + {#if validationError} + + {:else if lineageMissing} +

The selected value is no longer current. Cancel or add this draft as a new value.

+ {:else if convertToNew} +

This draft will be added as a new value.

+ {:else if conflictMessage()} + + {/if} + +
+
+ +{/if} + + diff --git a/web/src/lib/components/directory/AttributeEditor.test.ts b/web/src/lib/components/directory/AttributeEditor.test.ts new file mode 100644 index 000000000..120d5012d --- /dev/null +++ b/web/src/lib/components/directory/AttributeEditor.test.ts @@ -0,0 +1,529 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../../api/client'; +import type { components } from '../../api/generated/schema'; +import type { DirectoryReadBundle } from '../../directory/models'; +import { DirectoryProfileController } from '../../directory/profile-controller.svelte'; +import AttributeEditor from './AttributeEditor.svelte'; + +type AttributeDefinition = components['schemas']['AttributeDefinition']; +type AttributeValue = components['schemas']['AttributeValue']; +type PersonAttributeValue = components['schemas']['PersonAttributeValue']; + +const when = '2026-08-01T00:00:00Z'; +const currentMonthDate = (() => { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-17`; +})(); +const currentMonthDateLabel = new Intl.DateTimeFormat(undefined, { + year: 'numeric', month: 'short', day: 'numeric' +}).format(new Date(`${currentMonthDate}T00:00:00`)); + +afterEach(() => cleanup()); + +function definition(overrides: Partial = {}): AttributeDefinition { + return { + id: 4, + universal_id: '00000000-0000-4000-8000-000000000004', + object_type: 'person', + slug: 'relationship_status', + label: 'Relationship status', + description: 'How this person is known', + value_type: 'text', + field_type: 'select', + cardinality: 'single', + display_order: 10, + is_required: false, + ownership: 'user', + ui_creatable: true, + ui_editable: true, + api_mutable: true, + is_searchable: true, + is_sensitive: false, + is_audited: true, + is_deletable: true, + history_exempt: false, + is_active: true, + revision: 1, + created_at: when, + updated_at: when, + options: { choices: [{ value: 'friend', label: 'Friend' }, { value: 'colleague', label: 'Colleague' }] }, + ...overrides + }; +} + +function personValue( + definitionValue: AttributeDefinition, + id: number, + value: AttributeValue, + ordinal = 0, + overrides: Partial = {} +): PersonAttributeValue { + return { + id, + person_id: 7, + definition_id: definitionValue.id, + definition_slug: definitionValue.slug, + ordinal, + value, + active_from: when, + created_at: when, + source: 'user', + actor: 'synthetic-user', + ...overrides + }; +} + +function controllerFor( + fetchFn: typeof fetch, + definitionValue: AttributeDefinition, + current: PersonAttributeValue[] = [] +): DirectoryProfileController { + const bundle = { + attributes: { + person_id: 7, + attributes: [{ definition: definitionValue, current, history: [...current] }] + }, + definitions: { definitions: [definitionValue] }, + etags: {}, + errors: {} + } satisfies DirectoryReadBundle; + return new DirectoryProfileController(createAPIClient(fetchFn), 7, bundle); +} + +function attributeWrite(body: components['schemas']['SetPersonAttributeRequest'], definitionValue: AttributeDefinition, id = 90) { + return { + dry_run: false, + value: personValue(definitionValue, id, body.value, body.ordinal ?? 0), + ...(body.expected_value_id + ? { superseded: personValue(definitionValue, body.expected_value_id, { type: definitionValue.value_type, text: 'Old value' }, body.ordinal ?? 0, { active_until: when, superseded_at: when }) } + : {}) + }; +} + +describe('AttributeEditor', () => { + it('uses a constrained choice and the selected single value ID without inventing an ordinal', async () => { + const requests: Request[] = []; + const choice = definition(); + const current = personValue(choice, 19, { type: 'text', text: 'colleague' }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json(attributeWrite(body, choice)); + }); + const controller = controllerFor(fetchFn, choice, [current]); + + render(AttributeEditor, { controller, definition: choice, current }); + await fireEvent.change(screen.getByLabelText('Relationship status'), { target: { value: 'friend' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + await waitFor(() => expect(requests).toHaveLength(1)); + expect(new URL(requests[0]!.url).pathname).toBe('/api/v1/people/7/attributes/relationship_status'); + await expect(requests[0]!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: 'friend' }, + expected_value_id: 19, + source: 'user' + }); + }); + + it.each([ + { + name: 'boolean', + definition: definition({ slug: 'subscribed', universal_id: 'boolean-id', label: 'Subscribed', value_type: 'boolean', field_type: 'checkbox', options: undefined }), + input: async () => fireEvent.click(screen.getByRole('checkbox', { name: 'Subscribed' })), + want: { type: 'boolean', boolean: true } + }, + { + name: 'integer', + definition: definition({ slug: 'contact_frequency', universal_id: 'integer-id', label: 'Contact frequency', value_type: 'integer', field_type: 'duration', options: { unit: 'days' } }), + input: async () => fireEvent.input(screen.getByLabelText('Contact frequency'), { target: { value: '14' } }), + want: { type: 'integer', integer: 14 } + }, + { + name: 'real number', + definition: definition({ slug: 'rating', universal_id: 'real-id', label: 'Rating', value_type: 'real', field_type: 'text', options: undefined }), + input: async () => fireEvent.input(screen.getByLabelText('Rating'), { target: { value: '4.25' } }), + want: { type: 'real', real: 4.25 } + }, + { + name: 'date', + definition: definition({ slug: 'met_on', universal_id: 'date-id', label: 'Met on', value_type: 'date', field_type: 'date', options: undefined }), + input: async () => fireEvent.click(screen.getByRole('button', { name: currentMonthDateLabel })), + want: { type: 'date', date: currentMonthDate } + }, + { + name: 'person reference', + definition: definition({ slug: 'introduced_by', universal_id: 'person-id', label: 'Introduced by', value_type: 'record_reference', field_type: 'person', record_target: 'person', options: undefined }), + input: async () => fireEvent.input(screen.getByLabelText('Introduced by'), { target: { value: '42' } }), + want: { type: 'record_reference', record_type: 'person', record_id: 42 } + }, + { + name: 'text', + definition: definition({ slug: 'nickname', universal_id: 'text-id', label: 'Nickname', value_type: 'text', field_type: 'text', options: undefined }), + input: async () => fireEvent.input(screen.getByLabelText('Nickname'), { target: { value: 'Synthetic nickname' } }), + want: { type: 'text', text: 'Synthetic nickname' } + } + ])('builds the generated $name value union without CAS for a new value', async ({ definition: definitionValue, input, want }) => { + const requests: Request[] = []; + const fetchFn = vi.fn(async (requestInput) => { + const request = requestInput instanceof Request ? requestInput : new Request(requestInput); + requests.push(request); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json(attributeWrite(body, definitionValue)); + }); + const controller = controllerFor(fetchFn, definitionValue); + + render(AttributeEditor, { controller, definition: definitionValue }); + await input(); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + await waitFor(() => expect(requests).toHaveLength(1)); + await expect(requests[0]!.clone().json()).resolves.toEqual({ value: want, source: 'user' }); + }); + + it('replaces the selected multi-value lineage with its exact value ID and ordinal', async () => { + const requests: Request[] = []; + const multi = definition({ + slug: 'ask_me_about', universal_id: 'multi-id', label: 'Ask me about', value_type: 'text', field_type: 'text', cardinality: 'multi', options: undefined + }); + const current = personValue(multi, 33, { type: 'text', text: 'Old topic' }, 4); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json(attributeWrite(body, multi)); + }); + const controller = controllerFor(fetchFn, multi, [current]); + + render(AttributeEditor, { controller, definition: multi, current }); + await fireEvent.input(screen.getByLabelText('Ask me about'), { target: { value: 'New topic' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + await waitFor(() => expect(requests).toHaveLength(1)); + await expect(requests[0]!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: 'New topic' }, expected_value_id: 33, ordinal: 4, source: 'user' + }); + }); + + it.each([ + { + name: 'timestamp', + definition: definition({ + slug: 'follow_up_at', universal_id: 'timestamp-id', label: 'Follow up at', value_type: 'timestamp', field_type: 'timestamp', options: undefined + }), + draft: '2024-02-29T23:59:59.123456789+05:30', + want: { type: 'timestamp', timestamp: '2024-02-29T23:59:59.123456789+05:30' } + }, + { + name: 'JSON', + definition: definition({ + slug: 'preferences', universal_id: 'json-id', label: 'Preferences', value_type: 'json', field_type: 'textarea', options: undefined + }), + draft: '{"theme":"dark","alerts":true}', + want: { type: 'json', json: { theme: 'dark', alerts: true } } + } + ])('emits the exact generated $name union for a valid definition-driven draft', async ({ definition: definitionValue, draft, want }) => { + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json(attributeWrite(body, definitionValue)); + }); + const controller = controllerFor(fetchFn, definitionValue); + + render(AttributeEditor, { controller, definition: definitionValue }); + await fireEvent.input(screen.getByRole('textbox', { name: definitionValue.label }), { target: { value: draft } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + await waitFor(() => expect(requests).toHaveLength(1)); + await expect(requests[0]!.clone().json()).resolves.toEqual({ value: want, source: 'user' }); + }); + + it.each([ + ['timestamp', definition({ slug: 'follow_up_at', label: 'Follow up at', value_type: 'timestamp', field_type: 'timestamp', options: undefined }), 'tomorrow', 'Enter an RFC3339 timestamp.'], + ['normalized invalid date', definition({ slug: 'follow_up_at', label: 'Follow up at', value_type: 'timestamp', field_type: 'timestamp', options: undefined }), '2026-02-30T14:30:45Z', 'Enter an RFC3339 timestamp.'], + ['non-leap February date', definition({ slug: 'follow_up_at', label: 'Follow up at', value_type: 'timestamp', field_type: 'timestamp', options: undefined }), '2025-02-29T14:30:45Z', 'Enter an RFC3339 timestamp.'], + ['normalized 24-hour time', definition({ slug: 'follow_up_at', label: 'Follow up at', value_type: 'timestamp', field_type: 'timestamp', options: undefined }), '2026-01-01T24:00:00Z', 'Enter an RFC3339 timestamp.'], + ['out-of-range offset', definition({ slug: 'follow_up_at', label: 'Follow up at', value_type: 'timestamp', field_type: 'timestamp', options: undefined }), '2026-01-01T12:00:00+24:00', 'Enter an RFC3339 timestamp.'], + ['JSON syntax', definition({ slug: 'preferences', label: 'Preferences', value_type: 'json', field_type: 'textarea', options: undefined }), '{bad', 'Enter valid JSON.'], + ['null JSON', definition({ slug: 'preferences', label: 'Preferences', value_type: 'json', field_type: 'textarea', options: undefined }), 'null', 'JSON cannot be null.'] + ])('blocks a malformed $name draft before issuing a request', async (_name, definitionValue, draft, message) => { + const fetchFn = vi.fn(); + const controller = controllerFor(fetchFn, definitionValue); + + render(AttributeEditor, { controller, definition: definitionValue }); + await fireEvent.input(screen.getByRole('textbox', { name: definitionValue.label }), { target: { value: draft } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + expect(fetchFn).not.toHaveBeenCalled(); + expect(screen.getByRole('alert').textContent).toContain(message); + }); + + it('displays a live store-aligned count and enforces text max_length before issuing a request', async () => { + const requests: Request[] = []; + const shortNote = definition({ + slug: 'short_note', label: 'Short note', field_type: 'text', options: { max_length: 5 } + }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json(attributeWrite(body, shortNote)); + }); + const controller = controllerFor(fetchFn, shortNote); + + render(AttributeEditor, { controller, definition: shortNote }); + const input = screen.getByRole('textbox', { name: 'Short note' }); + const constraintID = input.getAttribute('aria-describedby'); + expect(constraintID).not.toBeNull(); + expect(document.getElementById(constraintID!)?.textContent).toBe('0 / 5 characters.'); + await fireEvent.input(screen.getByRole('textbox', { name: 'Short note' }), { target: { value: '123456' } }); + expect(document.getElementById(constraintID!)?.textContent).toBe('6 / 5 characters.'); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + expect(requests).toHaveLength(0); + expect(screen.getByRole('alert').textContent).toContain('Use 5 characters or fewer.'); + + await fireEvent.input(screen.getByRole('textbox', { name: 'Short note' }), { target: { value: '12345' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + await waitFor(() => expect(requests).toHaveLength(1)); + await expect(requests[0]!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: '12345' }, source: 'user' + }); + }); + + it('lets a textarea enter trimmed astral characters up to the store rune limit', async () => { + const requests: Request[] = []; + const shortNote = definition({ + slug: 'short_note', label: 'Short note', field_type: 'textarea', options: { max_length: 5 } + }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json(attributeWrite(body, shortNote)); + }); + const controller = controllerFor(fetchFn, shortNote); + + render(AttributeEditor, { controller, definition: shortNote }); + const textarea = screen.getByRole('textbox', { name: 'Short note' }); + expect(textarea.getAttribute('maxlength')).toBeNull(); + await fireEvent.input(textarea, { target: { value: ' 😀😀😀😀😀😀 ' } }); + expect(textarea).toHaveProperty('value', ' 😀😀😀😀😀😀 '); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + expect(requests).toHaveLength(0); + expect(screen.getByRole('alert').textContent).toContain('Use 5 characters or fewer.'); + + await fireEvent.input(textarea, { target: { value: ' 😀😀😀😀😀 ' } }); + const constraintID = textarea.getAttribute('aria-describedby'); + expect(document.getElementById(constraintID!)?.textContent).toBe('5 / 5 characters.'); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + await waitFor(() => expect(requests).toHaveLength(1)); + await expect(requests[0]!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: '😀😀😀😀😀' }, source: 'user' + }); + }); + + it('trims U+0085 NEL like Go for the visible count and exact text request', async () => { + const requests: Request[] = []; + const shortNote = definition({ + slug: 'short_note', label: 'Short note', field_type: 'text', options: { max_length: 1 } + }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json(attributeWrite(body, shortNote)); + }); + const controller = controllerFor(fetchFn, shortNote); + + render(AttributeEditor, { controller, definition: shortNote }); + const input = screen.getByRole('textbox', { name: 'Short note' }); + await fireEvent.input(input, { target: { value: '\u0085a\u0085' } }); + const constraintID = input.getAttribute('aria-describedby'); + expect(document.getElementById(constraintID!)?.textContent).toBe('1 / 1 characters.'); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + await waitFor(() => expect(requests).toHaveLength(1)); + await expect(requests[0]!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: 'a' }, source: 'user' + }); + }); + + it('preserves and counts U+FEFF BOM like Go while enforcing the rune limit', async () => { + const requests: Request[] = []; + const shortNote = definition({ + slug: 'short_note', label: 'Short note', field_type: 'text', options: { max_length: 2 } + }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json(attributeWrite(body, shortNote)); + }); + const controller = controllerFor(fetchFn, shortNote); + + render(AttributeEditor, { controller, definition: shortNote }); + const input = screen.getByRole('textbox', { name: 'Short note' }); + const constraintID = input.getAttribute('aria-describedby'); + await fireEvent.input(input, { target: { value: '\uFEFFa\uFEFF' } }); + expect(document.getElementById(constraintID!)?.textContent).toBe('3 / 2 characters.'); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + expect(requests).toHaveLength(0); + expect(screen.getByRole('alert').textContent).toContain('Use 2 characters or fewer.'); + + await fireEvent.input(input, { target: { value: '\uFEFFa' } }); + expect(document.getElementById(constraintID!)?.textContent).toBe('2 / 2 characters.'); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + await waitFor(() => expect(requests).toHaveLength(1)); + await expect(requests[0]!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: '\uFEFFa' }, source: 'user' + }); + }); + + it('associates a text-choice max length and uses store-aligned code-point counts', async () => { + const requests: Request[] = []; + const choice = definition({ + slug: 'symbol_choice', label: 'Symbol choice', field_type: 'select', + options: { max_length: 5, choices: [ + { value: '😀😀😀😀😀', label: 'Five symbols' }, + { value: '😀😀😀😀😀😀', label: 'Six symbols' }, + { value: '\uFEFF12345', label: 'BOM plus five characters' } + ] } + }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json(attributeWrite(body, choice)); + }); + const controller = controllerFor(fetchFn, choice); + + render(AttributeEditor, { controller, definition: choice }); + const select = screen.getByRole('combobox', { name: 'Symbol choice' }); + const constraintID = select.getAttribute('aria-describedby'); + expect(constraintID).not.toBeNull(); + expect(document.getElementById(constraintID!)?.textContent).toBe('5 / 5 characters.'); + + await fireEvent.change(select, { target: { value: '😀😀😀😀😀😀' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + expect(requests).toHaveLength(0); + expect(screen.getByRole('alert').textContent).toContain('Use 5 characters or fewer.'); + + await fireEvent.change(select, { target: { value: '\uFEFF12345' } }); + expect(document.getElementById(constraintID!)?.textContent).toBe('6 / 5 characters.'); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + expect(requests).toHaveLength(0); + + await fireEvent.change(select, { target: { value: '😀😀😀😀😀' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + await waitFor(() => expect(requests).toHaveLength(1)); + }); + + it('rebases a conflicted multi edit to the fresh current ID at the same ordinal', async () => { + const requests: Request[] = []; + let setAttempts = 0; + const note = definition({ + slug: 'note', universal_id: 'note-id', label: 'Note', field_type: 'text', cardinality: 'multi', options: undefined + }); + const original = personValue(note, 19, { type: 'text', text: 'Original' }, 4); + const server = personValue(note, 21, { type: 'text', text: 'Server value' }, 4); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const url = new URL(request.url); + if (request.method === 'PUT') { + setAttempts += 1; + if (setAttempts === 1) return Response.json({ + error: 'attribute_value_conflict', message: 'changed elsewhere', current_value_id: 21, current_value: server + }, { status: 409 }); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json(attributeWrite(body, note, 22)); + } + if (url.pathname.endsWith('/attributes')) return Response.json({ + person_id: 7, attributes: [{ definition: note, current: [server], history: [server] }] + }); + if (url.pathname === '/api/v1/attribute-definitions') return Response.json({ definitions: [note] }); + if (url.pathname.endsWith('/profile')) return new Response(JSON.stringify({ person: { id: 7 } }), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r4"' } }); + return new Response(JSON.stringify({ id: 7 }), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r4"' } }); + }); + const controller = controllerFor(fetchFn, note, [original]); + + render(AttributeEditor, { controller, definition: note, current: original }); + await fireEvent.input(screen.getByLabelText('Note'), { target: { value: 'Local draft' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + expect((await screen.findByRole('alert')).textContent).toContain('This person changed elsewhere. Reload and retry.'); + expect(screen.getByLabelText('Note')).toHaveProperty('value', 'Local draft'); + expect(screen.getByRole('button', { name: 'Save attribute' })).toHaveProperty('disabled', true); + + await fireEvent.click(screen.getByRole('button', { name: 'Reload attributes' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Save attribute' })).toHaveProperty('disabled', false)); + expect(screen.getByLabelText('Note')).toHaveProperty('value', 'Local draft'); + + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + await waitFor(() => expect(setAttempts).toBe(2)); + const setRequests = requests.filter((request) => request.method === 'PUT'); + await expect(setRequests[1]!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: 'Local draft' }, expected_value_id: 21, ordinal: 4, source: 'user' + }); + }); + + it('requires deliberate conversion to a new add when a conflicted multi lineage disappeared', async () => { + const requests: Request[] = []; + let setAttempts = 0; + const note = definition({ + slug: 'note', universal_id: 'note-id', label: 'Note', field_type: 'text', cardinality: 'multi', options: undefined + }); + const original = personValue(note, 19, { type: 'text', text: 'Original' }, 4); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const url = new URL(request.url); + if (request.method === 'PUT') { + setAttempts += 1; + if (setAttempts === 1) return Response.json({ + error: 'attribute_value_conflict', message: 'changed elsewhere', current_value_id: 19 + }, { status: 409 }); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json(attributeWrite(body, note, 22)); + } + if (url.pathname.endsWith('/attributes')) return Response.json({ + person_id: 7, attributes: [{ definition: note, current: [], history: [ + personValue(note, 19, { type: 'text', text: 'Original' }, 4, { active_until: when, superseded_at: when }) + ] }] + }); + if (url.pathname === '/api/v1/attribute-definitions') return Response.json({ definitions: [note] }); + if (url.pathname.endsWith('/profile')) return new Response(JSON.stringify({ person: { id: 7 } }), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r4"' } }); + return new Response(JSON.stringify({ id: 7 }), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r4"' } }); + }); + const controller = controllerFor(fetchFn, note, [original]); + + render(AttributeEditor, { controller, definition: note, current: original }); + await fireEvent.input(screen.getByLabelText('Note'), { target: { value: 'Local draft' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + await fireEvent.click(await screen.findByRole('button', { name: 'Reload attributes' })); + + expect(await screen.findByText('The selected value is no longer current. Cancel or add this draft as a new value.')).toBeDefined(); + expect(screen.getByLabelText('Note')).toHaveProperty('value', 'Local draft'); + expect(screen.getByRole('button', { name: 'Save attribute' })).toHaveProperty('disabled', true); + expect(setAttempts).toBe(1); + + await fireEvent.click(screen.getByRole('button', { name: 'Add draft as new value' })); + expect(screen.getByRole('form', { name: 'Add Note value' })).toBeDefined(); + expect(screen.queryByRole('form', { name: 'Edit Note value' })).toBeNull(); + expect(screen.getByRole('status').textContent).toContain('This draft will be added as a new value.'); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + await waitFor(() => expect(setAttempts).toBe(2)); + const setRequests = requests.filter((request) => request.method === 'PUT'); + await expect(setRequests[1]!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: 'Local draft' }, source: 'user' + }); + }); +}); diff --git a/web/src/lib/components/directory/AttributeSection.svelte b/web/src/lib/components/directory/AttributeSection.svelte new file mode 100644 index 000000000..f836f404e --- /dev/null +++ b/web/src/lib/components/directory/AttributeSection.svelte @@ -0,0 +1,345 @@ + + +
+
+

Attributes

+
+ + {#if creatingDefinition} + { creatingDefinition = false; }} /> + {/if} + + {#each fields as field (field.definition.universal_id)} +
+
+
+
+

{field.definition.label}

+ {#if field.definition.is_sensitive}Sensitive{/if} +
+ {#if field.definition.description}

{field.definition.description}

{/if} + {metadata(field.definition)} + {#if field.definition.options?.choices?.length} + Allowed choices: {field.definition.options.choices.map((choice) => choice.label).join(', ')} + {/if} + {#if field.definition.derived_source}Computed by {field.definition.derived_source}{/if} +
+
+ {#if field.definition.is_sensitive} +
+
+ +
    + {#each field.current as value, index (value.id)} +
  • +
    + {#if isRevealed(field.definition)} + {displayValue(field.definition, value.value)} + {:else} + Sensitive value concealed. + {/if} + {provenance(value)} +
    +
    + {#if isRevealed(field.definition)} +
    + {#if confirming?.universalID === field.definition.universal_id && confirming.current.id === value.id} +
    + Close this current value while keeping it in history? +
    + {/if} +
  • + {:else} +
  • No current value.
  • + {/each} +
+ + {#if editing?.universalID === field.definition.universal_id} + {#key editing.current?.id ?? 'new'} + { editing = undefined; }} + onCancel={() => discardEditor(field.definition)} + /> + {/key} + {/if} + + {#if field.history.length} +
+ History ({field.history.length}) +
    + {#each field.history as value (value.id)} +
  • + {#if isRevealed(field.definition)} + {displayValue(field.definition, value.value)} + {:else} + Sensitive value concealed. + {/if} + {provenance(value)} +
  • + {/each} +
+
+ {/if} + + {#if attributeConflict(field.definition) && editing?.universalID !== field.definition.universal_id} + + {/if} +
+ {:else} +

No attribute definitions are available.

+ {/each} +
+ + diff --git a/web/src/lib/components/directory/AttributeSection.test.ts b/web/src/lib/components/directory/AttributeSection.test.ts new file mode 100644 index 000000000..7b786edac --- /dev/null +++ b/web/src/lib/components/directory/AttributeSection.test.ts @@ -0,0 +1,414 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../../api/client'; +import type { components } from '../../api/generated/schema'; +import type { DirectoryReadBundle } from '../../directory/models'; +import { DirectoryProfileController } from '../../directory/profile-controller.svelte'; +import AttributeSection from './AttributeSection.svelte'; + +type AttributeDefinition = components['schemas']['AttributeDefinition']; +type AttributeValue = components['schemas']['AttributeValue']; +type PersonAttributeValue = components['schemas']['PersonAttributeValue']; + +const when = '2026-08-01T00:00:00Z'; + +afterEach(() => cleanup()); + +function definition(overrides: Partial = {}): AttributeDefinition { + return { + id: 8, + universal_id: '00000000-0000-4000-8000-000000000008', + object_type: 'person', + slug: 'private_note', + label: 'Private note', + description: 'A deliberately sensitive note', + value_type: 'text', + field_type: 'textarea', + cardinality: 'single', + display_order: 10, + is_required: false, + ownership: 'user', + ui_creatable: true, + ui_editable: true, + api_mutable: true, + is_searchable: false, + is_sensitive: true, + is_audited: true, + is_deletable: true, + history_exempt: false, + is_active: true, + revision: 1, + created_at: when, + updated_at: when, + ...overrides + }; +} + +function personValue( + definitionValue: AttributeDefinition, + id: number, + value: AttributeValue, + ordinal = 0, + overrides: Partial = {} +): PersonAttributeValue { + return { + id, + person_id: 7, + definition_id: definitionValue.id, + definition_slug: definitionValue.slug, + ordinal, + value, + active_from: when, + created_at: when, + source: 'user', + actor: 'synthetic-user', + ...overrides + }; +} + +function renderSection( + fetchFn: typeof fetch, + groups: components['schemas']['PersonAttributeGroup'][], + definitions: AttributeDefinition[] = groups.map((group) => group.definition) +) { + const bundle = { + attributes: { person_id: 7, attributes: groups }, + definitions: { definitions }, + etags: {}, + errors: {} + } satisfies DirectoryReadBundle; + const controller = new DirectoryProfileController(createAPIClient(fetchFn), 7, bundle); + render(AttributeSection, { controller }); + return controller; +} + +describe('AttributeSection', () => { + it('keeps current and historical sensitive values out of display, editor, DOM, and accessible names until reveal', async () => { + const sensitive = definition(); + const current = personValue(sensitive, 19, { type: 'text', text: 'synthetic current secret' }); + const historical = personValue(sensitive, 17, { type: 'text', text: 'synthetic historical secret' }, 0, { + active_until: '2026-07-01T00:00:00Z', superseded_at: '2026-07-01T00:00:00Z' + }); + renderSection(vi.fn(), [{ definition: sensitive, current: [current], history: [current, historical] }]); + + expect(screen.getByText('Sensitive')).toBeDefined(); + expect(screen.queryByText('synthetic current secret')).toBeNull(); + expect(screen.queryByText('synthetic historical secret')).toBeNull(); + expect(screen.queryByDisplayValue('synthetic current secret')).toBeNull(); + expect(document.body.innerHTML).not.toContain('synthetic current secret'); + expect(document.body.innerHTML).not.toContain('synthetic historical secret'); + expect(screen.queryByRole('button', { name: /Edit private note/i })).toBeNull(); + + const reveal = screen.getByRole('button', { name: 'Reveal Private note values' }); + reveal.focus(); + await fireEvent.click(reveal); + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Hide Private note values' })); + expect(screen.getByText('synthetic current secret')).toBeDefined(); + expect(screen.getByText('synthetic historical secret')).toBeDefined(); + + await fireEvent.click(screen.getByRole('button', { name: 'Edit Private note value 1' })); + expect(screen.getByRole('textbox', { name: 'Private note' })).toHaveProperty('value', 'synthetic current secret'); + + await fireEvent.click(screen.getByRole('button', { name: 'Hide Private note values' })); + expect(document.body.innerHTML).not.toContain('synthetic current secret'); + expect(document.body.innerHTML).not.toContain('synthetic historical secret'); + expect(screen.queryByRole('textbox', { name: 'Private note' })).toBeNull(); + }); + + it.each([ + ['request failure', () => Response.json({ message: 'Synthetic service failure' }, { status: 503 })], + ['CAS conflict', () => Response.json({ + error: 'attribute_value_conflict', message: 'changed elsewhere', current_value_id: 22 + }, { status: 409 })] + ])('scrubs a sensitive controller draft after %s when Hide explicitly discards it', async (_name, response) => { + const sensitive = definition(); + const current = personValue(sensitive, 19, { type: 'text', text: 'server sensitive value' }); + const controller = renderSection(vi.fn(async () => response()), [ + { definition: sensitive, current: [current], history: [current] } + ]); + + await fireEvent.click(screen.getByRole('button', { name: 'Reveal Private note values' })); + await fireEvent.click(screen.getByRole('button', { name: 'Edit Private note value 1' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Private note' }), { target: { value: 'failed local sensitive draft' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + await screen.findByRole('alert'); + + expect(JSON.stringify(controller.draft)).toContain('failed local sensitive draft'); + await fireEvent.click(screen.getByRole('button', { name: 'Hide Private note values' })); + + expect(controller.draft).toBeNull(); + expect(controller.conflict).toBeNull(); + expect(document.body.innerHTML).not.toContain('failed local sensitive draft'); + + await fireEvent.click(screen.getByRole('button', { name: 'Reveal Private note values' })); + await fireEvent.click(screen.getByRole('button', { name: 'Edit Private note value 1' })); + expect(screen.getByRole('textbox', { name: 'Private note' })).toHaveProperty('value', 'server sensitive value'); + expect(document.body.innerHTML).not.toContain('failed local sensitive draft'); + }); + + it.each([ + ['request failure', () => Response.json({ message: 'Deferred service failure' }, { status: 503 })], + ['CAS conflict', () => Response.json({ + error: 'attribute_value_conflict', message: 'deferred conflict', current_value_id: 23 + }, { status: 409 })] + ])('keeps sensitive plaintext discarded when Hide occurs before a deferred save %s settles', async (_name, response) => { + let settle: ((response: Response) => void) | undefined; + const pendingResponse = new Promise((resolve) => { settle = resolve; }); + const sensitive = definition(); + const current = personValue(sensitive, 19, { type: 'text', text: 'server sensitive value' }); + const controller = renderSection(vi.fn(async () => pendingResponse), [ + { definition: sensitive, current: [current], history: [current] } + ]); + + await fireEvent.click(screen.getByRole('button', { name: 'Reveal Private note values' })); + await fireEvent.click(screen.getByRole('button', { name: 'Edit Private note value 1' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Private note' }), { target: { value: 'in-flight sensitive draft' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + await waitFor(() => expect(controller.mutationPending).toBe(true)); + + await fireEvent.click(screen.getByRole('button', { name: 'Hide Private note values' })); + settle?.(response()); + await waitFor(() => expect(controller.mutationPending).toBe(false)); + + expect(controller.draft).toBeNull(); + expect(controller.conflict).toBeNull(); + expect(document.body.innerHTML).not.toContain('in-flight sensitive draft'); + expect(screen.queryByRole('textbox', { name: 'Private note' })).toBeNull(); + }); + + it('keeps sensitive plaintext discarded when Hide occurs during a deferred failed reload', async () => { + const reloadSettlers: Array<(response: Response) => void> = []; + const sensitive = definition(); + const current = personValue(sensitive, 19, { type: 'text', text: 'server sensitive value' }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + if (request.method === 'PUT') return Response.json({ + error: 'attribute_value_conflict', message: 'changed elsewhere', current_value_id: 22 + }, { status: 409 }); + return new Promise((resolve) => { reloadSettlers.push(resolve); }); + }); + const controller = renderSection(fetchFn, [ + { definition: sensitive, current: [current], history: [current] } + ]); + + await fireEvent.click(screen.getByRole('button', { name: 'Reveal Private note values' })); + await fireEvent.click(screen.getByRole('button', { name: 'Edit Private note value 1' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Private note' }), { target: { value: 'reload-sensitive draft' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + await screen.findByRole('alert'); + await fireEvent.click(screen.getByRole('button', { name: 'Reload attributes' })); + await waitFor(() => expect(controller.reloadPending).toBe(true)); + await waitFor(() => expect(reloadSettlers).toHaveLength(4)); + + await fireEvent.click(screen.getByRole('button', { name: 'Hide Private note values' })); + for (const settle of reloadSettlers) settle(Response.json({ message: 'Deferred reload failure' }, { status: 503 })); + await waitFor(() => expect(controller.reloadPending).toBe(false)); + + expect(controller.draft).toBeNull(); + expect(controller.conflict).toBeNull(); + expect(document.body.innerHTML).not.toContain('reload-sensitive draft'); + expect(screen.queryByRole('textbox', { name: 'Private note' })).toBeNull(); + }); + + it('requires reveal and deliberate selection before adding a sensitive choice', async () => { + const requests: Request[] = []; + const sensitiveChoice = definition({ + universal_id: 'sensitive-choice-id', slug: 'confidential_level', label: 'Confidential level', + field_type: 'select', options: { choices: [{ value: 'private', label: 'Private' }, { value: 'restricted', label: 'Restricted' }] } + }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json({ dry_run: false, value: personValue(sensitiveChoice, 71, body.value) }); + }); + renderSection(fetchFn, [{ definition: sensitiveChoice, current: [], history: [] }]); + + expect(screen.getByRole('button', { name: 'Add Confidential level value' })).toHaveProperty('disabled', true); + await fireEvent.click(screen.getByRole('button', { name: 'Reveal Confidential level values' })); + await fireEvent.click(screen.getByRole('button', { name: 'Add Confidential level value' })); + + const select = screen.getByRole('combobox', { name: 'Confidential level' }); + expect(select).toHaveProperty('value', ''); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + expect(requests).toHaveLength(0); + expect(screen.getByRole('alert').textContent).toContain('Choose an allowed value.'); + + await fireEvent.change(select, { target: { value: 'restricted' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + await waitFor(() => expect(requests).toHaveLength(1)); + await expect(requests[0]!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: 'restricted' }, source: 'user' + }); + }); + + it('joins registry definitions to grouped values by universal ID and renders definition metadata plus collapsed history', () => { + const stale = definition({ + universal_id: 'portable-field-id', slug: 'portable_field', label: 'Stale label', is_sensitive: false + }); + const registry = definition({ + universal_id: 'portable-field-id', slug: 'portable_field', label: 'Portable field', description: 'Registry description', + is_sensitive: false, cardinality: 'multi', options: { choices: [{ value: 'one', label: 'One' }, { value: 'two', label: 'Two' }] } + }); + const current = personValue(stale, 31, { type: 'text', text: 'one' }, 2); + const historical = personValue(stale, 29, { type: 'text', text: 'two' }, 2, { + source: 'vcard_import', source_ref: 'synthetic-source', active_until: when, superseded_at: when + }); + + renderSection(vi.fn(), [{ definition: stale, current: [current], history: [current, historical] }], [registry]); + + expect(screen.getByRole('heading', { name: 'Portable field' })).toBeDefined(); + expect(screen.queryByRole('heading', { name: 'Stale label' })).toBeNull(); + expect(screen.getByText('Registry description')).toBeDefined(); + expect(screen.getByText(/Type: text/)).toBeDefined(); + expect(screen.getByText(/Cardinality: multi/)).toBeDefined(); + expect(screen.getByText(/Ownership: user/)).toBeDefined(); + expect(screen.getByText('Allowed choices: One, Two')).toBeDefined(); + const history = screen.getByText('History (1)').closest('details'); + expect(history).not.toBeNull(); + expect(history?.hasAttribute('open')).toBe(false); + expect(screen.getByText(/Source: vcard_import/)).toBeDefined(); + expect(screen.getByText(/Reference: synthetic-source/)).toBeDefined(); + }); + + it('confirms and clears the selected multi-value with its exact CAS identity', async () => { + const requests: Request[] = []; + const multi = definition({ + universal_id: 'multi-field-id', slug: 'topics', label: 'Topics', is_sensitive: false, cardinality: 'multi', field_type: 'text' + }); + const current = personValue(multi, 41, { type: 'text', text: 'Databases' }, 3); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + return Response.json({ dry_run: false, superseded: { ...current, active_until: when, superseded_at: when } }); + }); + renderSection(fetchFn, [{ definition: multi, current: [current], history: [current] }]); + + await fireEvent.click(screen.getByRole('button', { name: 'Close Topics value 1' })); + expect(requests).toHaveLength(0); + expect(screen.getByRole('group', { name: 'Confirm closing Topics value 1' })).toBeDefined(); + + await fireEvent.click(screen.getByRole('button', { name: 'Confirm close attribute' })); + await waitFor(() => expect(requests).toHaveLength(1)); + const url = new URL(requests[0]!.url); + expect(requests[0]!.method).toBe('DELETE'); + expect(url.pathname).toBe('/api/v1/people/7/attributes/topics'); + expect([...url.searchParams.entries()]).toEqual([['expected_value_id', '41'], ['ordinal', '3']]); + await waitFor(() => expect(screen.getByText('No current value.')).toBeDefined()); + expect(screen.getByText('Databases')).toBeDefined(); + expect(screen.getByText('History (1)')).toBeDefined(); + }); + + it('keeps the selected clear target and reports a retryable request failure', async () => { + const note = definition({ is_sensitive: false }); + const current = personValue(note, 42, { type: 'text', text: 'Keep this draft' }); + renderSection(vi.fn(async () => Response.json({ message: 'Synthetic service failure' }, { status: 503 })), [ + { definition: note, current: [current], history: [current] } + ]); + + await fireEvent.click(screen.getByRole('button', { name: 'Close Private note value 1' })); + await fireEvent.click(screen.getByRole('button', { name: 'Confirm close attribute' })); + + expect((await screen.findByRole('alert')).textContent).toContain('Synthetic service failure'); + expect(screen.getByRole('group', { name: 'Confirm closing Private note value 1' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Confirm close attribute' })).toHaveProperty('disabled', false); + expect(screen.queryByRole('button', { name: 'Reload attributes' })).toBeNull(); + }); + + it('keeps the conflicted editor mounted and gates opening another attribute', async () => { + const requests: Request[] = []; + const alias = definition({ + universal_id: 'alias-id', slug: 'alias', label: 'Alias', is_sensitive: false, field_type: 'text' + }); + const nickname = definition({ + id: 9, universal_id: 'nickname-id', slug: 'nickname', label: 'Nickname', is_sensitive: false, + field_type: 'text', display_order: 20 + }); + const current = personValue(alias, 43, { type: 'text', text: 'Original alias' }); + const fetchFn = vi.fn(async (input) => { + requests.push(input instanceof Request ? input : new Request(input)); + return Response.json({ error: 'attribute_value_conflict', message: 'changed elsewhere', current_value_id: 44 }, { status: 409 }); + }); + const controller = renderSection(fetchFn, [ + { definition: alias, current: [current], history: [current] }, + { definition: nickname, current: [], history: [] } + ]); + + await fireEvent.click(screen.getByRole('button', { name: 'Edit Alias value 1' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Alias' }), { target: { value: 'Retained local alias' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + expect((await screen.findByRole('alert')).textContent).toContain('This person changed elsewhere.'); + + const retainedDraft = controller.draft; + expect(screen.getByRole('button', { name: 'Add Nickname value' })).toHaveProperty('disabled', true); + await fireEvent.click(screen.getByRole('button', { name: 'Add Nickname value' })); + + expect(requests).toHaveLength(1); + expect(controller.draft).toBe(retainedDraft); + expect(screen.getByRole('textbox', { name: 'Alias' })).toHaveProperty('value', 'Retained local alias'); + expect(screen.queryByRole('textbox', { name: 'Nickname' })).toBeNull(); + }); + + it('lets the server allocate a new multi-value ordinal and disables operations the definition does not permit', async () => { + const requests: Request[] = []; + const multi = definition({ + universal_id: 'multi-field-id', slug: 'topics', label: 'Topics', is_sensitive: false, cardinality: 'multi', field_type: 'text' + }); + const derived = definition({ + id: 9, universal_id: 'derived-field-id', slug: 'last_contacted', label: 'Last contacted', is_sensitive: false, + value_type: 'timestamp', field_type: 'timestamp', ownership: 'system', ui_creatable: false, ui_editable: false, + api_mutable: false, derived_source: 'activity_spine', history_exempt: true, display_order: 20 + }); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const body = await request.clone().json() as components['schemas']['SetPersonAttributeRequest']; + return Response.json({ dry_run: false, value: personValue(multi, 50, body.value, 5) }); + }); + renderSection(fetchFn, [ + { definition: multi, current: [], history: [] }, + { definition: derived, current: [personValue(derived, 49, { type: 'timestamp', timestamp: when })], history: [] } + ]); + + await fireEvent.click(screen.getByRole('button', { name: 'Add Topics value' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Topics' }), { target: { value: 'Search systems' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + await waitFor(() => expect(requests).toHaveLength(1)); + await expect(requests[0]!.clone().json()).resolves.toEqual({ + value: { type: 'text', text: 'Search systems' }, source: 'user' + }); + + expect(screen.getByRole('button', { name: 'Add Last contacted value' })).toHaveProperty('disabled', true); + expect(screen.getByRole('button', { name: 'Edit Last contacted value 1' })).toHaveProperty('disabled', true); + expect(screen.getByRole('button', { name: 'Close Last contacted value 1' })).toHaveProperty('disabled', true); + expect(screen.getByText(/Computed by activity_spine/)).toBeDefined(); + expect(screen.queryByRole('button', { name: /rename|delete.*definition/i })).toBeNull(); + }); + + it('opens user-writable timestamp and JSON definitions from the joined section', async () => { + const timestamp = definition({ + id: 10, universal_id: 'timestamp-id', slug: 'follow_up_at', label: 'Follow up at', is_sensitive: false, + value_type: 'timestamp', field_type: 'timestamp', options: undefined + }); + const json = definition({ + id: 11, universal_id: 'json-id', slug: 'preferences', label: 'Preferences', is_sensitive: false, + value_type: 'json', field_type: 'textarea', options: undefined, display_order: 20 + }); + renderSection(vi.fn(), [ + { definition: timestamp, current: [], history: [] }, + { definition: json, current: [], history: [] } + ]); + + const timestampAdd = screen.getByRole('button', { name: 'Add Follow up at value' }); + expect(timestampAdd).toHaveProperty('disabled', false); + await fireEvent.click(timestampAdd); + expect(screen.getByRole('textbox', { name: 'Follow up at' })).toBeDefined(); + + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + const jsonAdd = screen.getByRole('button', { name: 'Add Preferences value' }); + expect(jsonAdd).toHaveProperty('disabled', false); + await fireEvent.click(jsonAdd); + expect(screen.getByRole('textbox', { name: 'Preferences' })).toBeDefined(); + }); +}); diff --git a/web/src/lib/components/directory/CardDAVPublicationControl.svelte b/web/src/lib/components/directory/CardDAVPublicationControl.svelte new file mode 100644 index 000000000..ce6b66d15 --- /dev/null +++ b/web/src/lib/components/directory/CardDAVPublicationControl.svelte @@ -0,0 +1,181 @@ + + +
+ +
+
+
+

CardDAV publication

+

Publish this durable person to the selected CardDAV address book.

+
+ {#if controller.loading} + + + + {/if} +
+ + {#if controller.error} + + {/if} + + {#if controller.unavailable} +
+

CardDAV publication is unavailable. Configure or repair it in CardDAV settings.

+
+ {:else if controller.publication} + {@const publication = controller.publication} +
+ {stateText(publication.state)} + Desired publication: {publication.desired ? 'Published' : 'Unpublished'} + {#if publication.address_book} + Publication address book: {publication.address_book.name}. + {:else} + No publish address book is selected. + {/if} +
+ + {#if controller.pendingAction} +

+ + {controller.pendingAction === 'publish' ? 'Publishing this person to CardDAV…' : 'Removing this person from CardDAV…'} +

+ {/if} + + {#if publication.state === 'unpublished' && publication.address_book} + void togglePublication(checked)} + /> + {:else if publication.state === 'published'} + void togglePublication(checked)} + /> + {:else if publication.state === 'pending'} + +

{publication.pending_operation ? pendingText(publication.pending_operation) : 'CardDAV publication is pending.'}

+ {:else if publication.state === 'conflict'} + + {#if publication.conflict_id} +
+
+
+ + diff --git a/web/src/lib/components/directory/CardDAVPublicationControl.test.ts b/web/src/lib/components/directory/CardDAVPublicationControl.test.ts new file mode 100644 index 000000000..d8b2a3baa --- /dev/null +++ b/web/src/lib/components/directory/CardDAVPublicationControl.test.ts @@ -0,0 +1,186 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../../api/client'; +import CardDAVPublicationControl from './CardDAVPublicationControl.svelte'; + +const unsafe = { + raw_vcard: 'BEGIN:VCARD\nFN:FORBIDDEN\nEND:VCARD', + url: 'https://forbidden.example.test/dav', + href: '/forbidden/contact.vcf', + private_marker: 'forbidden-private-marker' +}; + +function state(kind: 'unpublished' | 'published' | 'pending' | 'conflict', overrides: Record = {}) { + return { + person_id: 7, + state: kind, + desired: kind === 'published', + address_book: { id: 5, name: 'Synthetic contacts', ...unsafe }, + ...unsafe, + ...overrides + }; +} + +describe('CardDAVPublicationControl', () => { + it.each([ + { + name: 'unpublished', + response: state('unpublished', { desired: true }), + label: 'Publish person to CardDAV', + checked: false, + disabled: false, + copy: ['Not published', 'Desired publication: Published', 'Publication address book: Synthetic contacts.'] + }, + { + name: 'published', + response: state('published', { desired: false }), + label: 'Remove person from CardDAV', + checked: true, + disabled: false, + copy: ['Published', 'Desired publication: Unpublished', 'Publication address book: Synthetic contacts.'] + }, + { + name: 'pending', + response: state('pending', { desired: true, pending_operation: 'create' }), + label: 'Publish person to CardDAV', + checked: true, + disabled: true, + copy: ['Publication pending', 'Desired publication: Published', 'CardDAV publication is waiting to create this contact.'] + }, + { + name: 'conflict', + response: state('conflict', { desired: false, conflict_id: 41 }), + label: 'Remove person from CardDAV', + checked: false, + disabled: true, + copy: ['Publication conflict', 'Desired publication: Unpublished'] + } + ])('renders the generated $name state without unsafe response fields', async ({ response, label, checked, disabled, copy }) => { + render(CardDAVPublicationControl, { + client: createAPIClient(vi.fn(async () => Response.json(response))), + personID: 7 + }); + + expect(await screen.findByRole('heading', { name: 'CardDAV publication' })).toBeDefined(); + expect(await screen.findByText(copy[0]!)).toBeDefined(); + for (const text of copy.slice(1)) expect(screen.getByText(text)).toBeDefined(); + const toggle = screen.getByRole('switch', { name: label }) as HTMLInputElement; + expect(toggle.checked).toBe(checked); + expect(toggle.disabled).toBe(disabled); + expect(document.body.textContent).not.toMatch(/FORBIDDEN|forbidden/i); + expect(document.body.innerHTML).not.toMatch(/forbidden\.example|forbidden\/contact/i); + }); + + it('offers only positive conflict and missing-book handoffs without inventing a target', async () => { + const onOpenConflict = vi.fn(); + const conflictRender = render(CardDAVPublicationControl, { + client: createAPIClient(vi.fn(async () => Response.json(state('conflict', { + conflict_id: 41 + })))), + personID: 7, + onOpenConflict + }); + await fireEvent.click(await screen.findByRole('button', { name: 'Review CardDAV conflict 41' })); + expect(onOpenConflict).toHaveBeenCalledOnce(); + expect(onOpenConflict).toHaveBeenCalledWith(41); + conflictRender.unmount(); + + const onOpenSettings = vi.fn(); + render(CardDAVPublicationControl, { + client: createAPIClient(vi.fn(async () => Response.json(state('unpublished', { + address_book: undefined + })))), + personID: 7, + onOpenSettings + }); + expect(await screen.findByText('No publish address book is selected.')).toBeDefined(); + expect(screen.queryByRole('switch')).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Open CardDAV settings' })); + expect(onOpenSettings).toHaveBeenCalledOnce(); + }); + + it('keeps a non-CardDAV-unavailable server failure actionable with the existing retry', async () => { + let reads = 0; + const fetchFn = vi.fn(async () => { + reads += 1; + if (reads === 1) { + return Response.json({ error: 'carddav_runtime_failed', message: unsafe.private_marker }, { status: 503 }); + } + return Response.json(state('unpublished')); + }); + render(CardDAVPublicationControl, { client: createAPIClient(fetchFn), personID: 7 }); + + expect((await screen.findByRole('alert')).textContent).toContain('Unable to load CardDAV publication state.'); + expect(document.body.textContent).not.toContain(unsafe.private_marker); + await fireEvent.click(screen.getByRole('button', { name: 'Retry CardDAV publication state' })); + expect(await screen.findByRole('switch', { name: 'Publish person to CardDAV' })).toBeDefined(); + expect(reads).toBe(2); + }); + + it('keeps a transport read failure actionable with the existing retry', async () => { + let reads = 0; + const fetchFn = vi.fn(async () => { + reads += 1; + if (reads === 1) throw new TypeError('synthetic connection reset'); + return Response.json(state('unpublished')); + }); + render(CardDAVPublicationControl, { client: createAPIClient(fetchFn), personID: 7 }); + + expect((await screen.findByRole('alert')).textContent).toContain('Unable to load CardDAV publication state.'); + await fireEvent.click(screen.getByRole('button', { name: 'Retry CardDAV publication state' })); + expect(await screen.findByRole('switch', { name: 'Publish person to CardDAV' })).toBeDefined(); + expect(reads).toBe(2); + }); + + it('announces a clean mutation once and preserves focus on its connected intent control', async () => { + let reads = 0; + const onAnnounce = vi.fn(); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + if (request.method === 'POST') return Response.json(state('published')); + reads += 1; + return Response.json(state('unpublished')); + }); + render(CardDAVPublicationControl, { + client: createAPIClient(fetchFn), personID: 7, onAnnounce + }); + + const toggle = await screen.findByRole('switch', { name: 'Publish person to CardDAV' }); + toggle.focus(); + await fireEvent.click(toggle); + + const updated = await screen.findByRole('switch', { name: 'Remove person from CardDAV' }); + await waitFor(() => expect(document.activeElement).toBe(updated)); + expect(onAnnounce).toHaveBeenCalledOnce(); + expect(onAnnounce).toHaveBeenCalledWith('Published this person to CardDAV in Synthetic contacts.'); + expect(reads).toBe(1); + }); + + it('uses a connected heading fallback and truthful status when clean success returns pending', async () => { + const onAnnounce = vi.fn(); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + if (request.method === 'POST') return Response.json(state('pending', { + desired: true, + pending_operation: 'create' + })); + return Response.json(state('unpublished')); + }); + render(CardDAVPublicationControl, { + client: createAPIClient(fetchFn), personID: 7, onAnnounce + }); + + const toggle = await screen.findByRole('switch', { name: 'Publish person to CardDAV' }); + toggle.focus(); + await fireEvent.click(toggle); + + const heading = await screen.findByRole('heading', { name: 'CardDAV publication' }); + await waitFor(() => expect(document.activeElement).toBe(heading)); + expect(onAnnounce).toHaveBeenCalledOnce(); + expect(onAnnounce).toHaveBeenCalledWith('CardDAV publication change is pending.'); + const pendingToggle = screen.getByRole('switch', { name: 'Publish person to CardDAV' }) as HTMLInputElement; + expect(pendingToggle.checked).toBe(true); + expect(pendingToggle.disabled).toBe(true); + }); +}); diff --git a/web/src/lib/components/directory/DirectoryList.svelte b/web/src/lib/components/directory/DirectoryList.svelte new file mode 100644 index 000000000..9844db914 --- /dev/null +++ b/web/src/lib/components/directory/DirectoryList.svelte @@ -0,0 +1,119 @@ + + +
+ {#if error && rows.length === 0} + + {:else} + {#if pageError} + + {/if} + {#if loading && rows.length > 0} +

Updating people…

+ {/if} + {#if loading && rows.length === 0} +

Loading people…

+ {:else if rows.length === 0} + + {:else} +
+ {#each rows as person (person.id)} +
{ activeID = person.id; onSelect(person.id); }} + > + {person.display_name ?? `Person ${person.id}`} + {person.primary_channel ?? 'No primary channel'} · {person.contact_state} + {person.last_contact_at ? `Last contact ${person.last_contact_at}` : 'Never contacted'} + {#if person.organizations?.length}{person.organizations.join(' · ')}{/if} + {#if person.categories?.length}{person.categories.join(' · ')}{/if} +
+ {/each} +
+ {/if} + {#if hasMore && rows.length > 0 && pageRecovery !== 'reload'} +
+ {/if} + {/if} +
+ + diff --git a/web/src/lib/components/directory/DirectoryList.test.ts b/web/src/lib/components/directory/DirectoryList.test.ts new file mode 100644 index 000000000..a6290dc46 --- /dev/null +++ b/web/src/lib/components/directory/DirectoryList.test.ts @@ -0,0 +1,62 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import DirectoryList from './DirectoryList.svelte'; + +const rows = [ + { id: 1, revision: 1, display_name: 'Alpha Fixture', contact_state: 'active', categories: [], organizations: [] }, + { id: 2, revision: 1, display_name: 'Bravo Fixture', contact_state: 'active', categories: [], organizations: [] }, + { id: 3, revision: 1, display_name: 'Charlie Fixture', contact_state: 'inactive', categories: [], organizations: [] } +]; + +describe('DirectoryList', () => { + it('uses roving row focus for arrows, Home/End, and Enter/Space selection', async () => { + const onSelect = vi.fn(); + render(DirectoryList, { + rows, loading: false, loadingMore: false, error: null, pageError: null, pageRecovery: null, + hasMore: false, selectedPersonID: null, onSelect, onLoadMore: vi.fn(), onReload: vi.fn() + }); + + const alpha = screen.getByRole('row', { name: /Alpha Fixture/ }); + const bravo = screen.getByRole('row', { name: /Bravo Fixture/ }); + const charlie = screen.getByRole('row', { name: /Charlie Fixture/ }); + await waitFor(() => expect(alpha.getAttribute('tabindex')).toBe('0')); + alpha.focus(); + await fireEvent.keyDown(alpha, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(bravo); + expect(bravo.getAttribute('tabindex')).toBe('0'); + await fireEvent.keyDown(bravo, { key: 'End' }); + expect(document.activeElement).toBe(charlie); + await fireEvent.keyDown(charlie, { key: 'Home' }); + expect(document.activeElement).toBe(alpha); + await fireEvent.keyDown(alpha, { key: ' ' }); + expect(onSelect).toHaveBeenCalledWith(1); + await fireEvent.keyDown(alpha, { key: 'Enter' }); + expect(onSelect).toHaveBeenCalledTimes(2); + }); + + it('offers Reload without Load more when the retained cursor needs page-one recovery', () => { + render(DirectoryList, { + rows, loading: false, loadingMore: false, error: null, + pageError: 'Directory reconciliation unavailable.', pageRecovery: 'reload', + hasMore: true, selectedPersonID: 1, onSelect: vi.fn(), onLoadMore: vi.fn(), onReload: vi.fn() + }); + + expect(screen.getByRole('button', { name: 'Reload directory' })).toBeDefined(); + expect(screen.queryByRole('button', { name: 'Load more people' })).toBeNull(); + }); + + it('shows the last-contact timestamp or an explicit never-contacted state', () => { + render(DirectoryList, { + rows: [ + { ...rows[0]!, last_contact_at: '2026-08-20T10:00:00Z' }, + rows[1]! + ], + loading: false, loadingMore: false, error: null, pageError: null, pageRecovery: null, + hasMore: false, selectedPersonID: null, onSelect: vi.fn(), onLoadMore: vi.fn(), onReload: vi.fn() + }); + + expect(screen.getByRole('row', { name: /Alpha Fixture/ }).textContent).toContain('Last contact 2026-08-20T10:00:00Z'); + expect(screen.getByRole('row', { name: /Bravo Fixture/ }).textContent).toContain('Never contacted'); + }); +}); diff --git a/web/src/lib/components/directory/DirectoryReviewCentre.svelte b/web/src/lib/components/directory/DirectoryReviewCentre.svelte new file mode 100644 index 000000000..78d6b051f --- /dev/null +++ b/web/src/lib/components/directory/DirectoryReviewCentre.svelte @@ -0,0 +1,272 @@ + + +
+ + + {#if controller.reviewKind === 'identity'} +
+
+
+

Identity matches

+

Review server-supplied evidence before linking or separating identities.

+
+ +
+ + {#if controller.status} +

{controller.status}

+ {/if} + + {#if controller.loading && controller.rows.length === 0} +

+ Loading identity matches… +

+ {:else if controller.error} + + {:else} + {#if controller.pageError} + + {/if} + + {#if controller.rows.length === 0} + + {:else} +
+ {#if controller.loading} +
+ Loading page… +
+ {/if} +
+ {#each controller.rows as row (row.id)} + openDecision(row, 'accept')} + onReject={() => openDecision(row, 'reject')} + /> + {/each} +
+
+ {/if} + + {#if controller.rows.length > 0 || controller.hasPreviousPage} + + {/if} + {/if} +
+ {:else if controller.reviewKind === 'fact'} + {#if factController} + + {/if} + {:else} + + {/if} +
+ +{#if activeDecision?.kind === 'decision'} + void closeDecision()} + onContextInvalidated={() => void invalidateDecision()} + onResolveMerge={resolveMerge} + /> +{:else if activeDecision?.kind === 'merge'} + void closeDecision()} + /> +{/if} + + diff --git a/web/src/lib/components/directory/DirectoryReviewCentre.test.ts b/web/src/lib/components/directory/DirectoryReviewCentre.test.ts new file mode 100644 index 000000000..dc66e0c04 --- /dev/null +++ b/web/src/lib/components/directory/DirectoryReviewCentre.test.ts @@ -0,0 +1,378 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../../api/client'; +import { FactLedgerController } from '../../directory/fact-ledger-controller.svelte'; +import { + DirectoryReviewController, + IDENTITY_REVIEW_PAGE_LIMIT, + type IdentityMatchCandidate +} from '../../directory/review-controller.svelte'; +import DirectoryReviewCentre from './DirectoryReviewCentre.svelte'; +import { RelationshipReviewController } from '../../directory/relationship-review-controller.svelte'; + +afterEach(() => cleanup()); + +function candidate(id: number, state = 'candidate'): IdentityMatchCandidate { + return { + id, + left_id: id * 10, + left_kind: 'beeper_user', + right_id: id * 10 + 1, + right_kind: 'participant', + basis: 'stable_provider_id', + source: 'synthetic', + state, + evidence: [], + created_at: '2026-08-01T10:00:00Z', + updated_at: '2026-08-02T11:00:00Z' + }; +} + +function page(rows: IdentityMatchCandidate[], offset = 0): Response { + return Response.json({ candidates: rows, limit: IDENTITY_REVIEW_PAGE_LIMIT, offset }); +} + +function deferredResponse() { + let resolve!: (response: Response) => void; + const promise = new Promise((next) => { resolve = next; }); + return { promise, resolve }; +} + +function requestOf(input: RequestInfo | URL): Request { + return input instanceof Request ? input : new Request(input); +} + +function renderReview(controller: DirectoryReviewController) { + return render(DirectoryReviewCentre, { + controller, + relationshipController: new RelationshipReviewController(controller.apiClient), + factController: new FactLedgerController(controller.apiClient), + directoryPersonID: null + }); +} + +describe('DirectoryReviewCentre', () => { + it('selects the read-only imported relationship queue without identity requests', async () => { + const calls: Array<{ method: string; path: string; status: string | null }> = []; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const url = new URL(request.url); + calls.push({ method: request.method, path: url.pathname, status: url.searchParams.get('status') }); + return Response.json({ reviews: [] }); + }); + const review = new DirectoryReviewController(createAPIClient(fetchFn)); + const relationships = new RelationshipReviewController(createAPIClient(fetchFn)); + render(DirectoryReviewCentre, { controller: review, relationshipController: relationships }); + + await fireEvent.click(screen.getByRole('radio', { name: 'Imported relationships' })); + + expect(await screen.findByRole('heading', { name: 'Imported relationships' })).toBeDefined(); + expect(calls).toEqual([{ method: 'GET', path: '/api/v1/person-relationship-reviews', status: 'pending' }]); + expect(screen.getByText('Imported relationship reviews are read-only in the browser until generated decision operations are available.')).toBeDefined(); + }); + it('replaces an accept decision with the shared merge modal without replaying acceptance', async () => { + const conflict = { + error: 'person_merge_required', + message: 'Choose a survivor', + profiles: [ + { etag: '"person-7-r4"', person: { id: 7, revision: 4, display_name: 'Synthetic One' } }, + { etag: '"person-9-r2"', person: { id: 9, revision: 2, display_name: 'Synthetic Two' } } + ] + }; + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + requests.push(request); + return Response.json(conflict, { status: 409 }); + }); + const controller = new DirectoryReviewController(createAPIClient(fetchFn)); + controller.rows = [candidate(17)]; + renderReview(controller); + + await fireEvent.click(screen.getByRole('button', { name: 'Link identities' })); + await fireEvent.click(screen.getByRole('dialog', { name: 'Link identities' }).querySelector('button.kit-button--solid')!); + await fireEvent.click(await screen.findByRole('button', { name: 'Resolve merge' })); + + expect(screen.queryByRole('dialog', { name: 'Link identities' })).toBeNull(); + expect(screen.getByRole('dialog', { name: 'Resolve person merge' })).toBeDefined(); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(requests.filter((request) => new URL(request.url).pathname.endsWith('/accept'))).toHaveLength(1); + }); + + it('changes identity state through the controller and commits the URL filter', async () => { + const requests: Request[] = []; + const commit = vi.fn(); + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + requests.push(request); + const state = new URL(request.url).searchParams.get('state') ?? 'candidate'; + return page([candidate(state === 'conflict' ? 22 : 17, state)]); + }); + const controller = new DirectoryReviewController(createAPIClient(fetchFn), commit); + await controller.loadIdentityPage(); + renderReview(controller); + + expect(screen.getByRole('radiogroup', { name: 'Review type' })).toBeDefined(); + expect(screen.getByRole('radiogroup', { name: 'Identity review state' })).toBeDefined(); + await fireEvent.click(screen.getByRole('radio', { name: 'Conflict' })); + + await screen.findByRole('heading', { name: 'Identity match 22' }); + expect(controller.reviewKind).toBe('identity'); + expect(controller.identityState).toBe('conflict'); + expect(commit).toHaveBeenLastCalledWith({ reviewKind: 'identity', identityState: 'conflict' }); + const last = requests.at(-1)!; + expect(new URL(last.url).searchParams.get('state')).toBe('conflict'); + expect(new URL(last.url).searchParams.get('offset')).toBe('0'); + }); + + it.each([{ mode: 'selection' as const }, { mode: 'restoration' as const }])( + 'shows the fact-contract gate after $mode without inventing requests, while identity still loads normally', + async ({ mode }) => { + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + requests.push(request); + return page([candidate(17)]); + }); + const commit = vi.fn(); + const apiClient = createAPIClient(fetchFn); + const controller = new DirectoryReviewController(apiClient, commit); + const factController = new FactLedgerController(apiClient); + if (mode === 'restoration') { + controller.applyURLState({ reviewKind: 'fact', identityState: 'candidate' }, true); + } + render(DirectoryReviewCentre, { + controller, + relationshipController: new RelationshipReviewController(apiClient), + factController, + directoryPersonID: null + }); + + if (mode === 'selection') { + await fireEvent.click(screen.getByRole('radio', { name: 'Fact review' })); + } + + expect(screen.getByRole('region', { name: 'Fact review' })).toBeDefined(); + expect(screen.getByText('Choose a person in Directory to inspect their fact ledger')).toBeDefined(); + expect(screen.queryByRole('button', { name: /accept|reject|unsure|link identities|keep separate/i })).toBeNull(); + expect(fetchFn).not.toHaveBeenCalled(); + if (mode === 'selection') expect(commit).toHaveBeenCalledWith({ reviewKind: 'fact' }); + + await fireEvent.click(screen.getByRole('radio', { name: 'Identity matches' })); + expect(await screen.findByRole('heading', { name: 'Identity match 17' })).toBeDefined(); + expect(requests).toHaveLength(1); + expect(new URL(requests[0]!.url).pathname).toBe('/api/v1/identity/match-candidates'); + } + ); + + it('retains the current rows under a page loading overlay, then exposes page failure and retry', async () => { + const nextPage = deferredResponse(); + let nextAttempts = 0; + const firstRows = [candidate(1)]; + const fetchFn = vi.fn(async (input) => { + const offset = new URL(requestOf(input).url).searchParams.get('offset'); + if (offset === '100') { + nextAttempts += 1; + if (nextAttempts === 1) return nextPage.promise; + return page([candidate(101)], 100); + } + return page(firstRows); + }); + const controller = new DirectoryReviewController(createAPIClient(fetchFn)); + controller.rows = firstRows; + renderReview(controller); + + void controller.loadIdentityPage(100); + await waitFor(() => expect(controller.loading).toBe(true)); + expect(screen.getByRole('status', { name: 'Loading next review page' })).toBeDefined(); + expect(screen.getByRole('heading', { name: 'Identity match 1' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Previous page' })).toHaveProperty('disabled', true); + expect(screen.getByRole('button', { name: 'Next page' })).toHaveProperty('disabled', true); + + nextPage.resolve(Response.json({ error: 'unavailable', message: 'Next page unavailable' }, { status: 503 })); + expect((await screen.findByRole('alert')).textContent).toContain('Next page unavailable'); + expect(screen.getByRole('heading', { name: 'Identity match 1' })).toBeDefined(); + await fireEvent.click(screen.getByRole('button', { name: 'Retry identity matches' })); + await screen.findByRole('heading', { name: 'Identity match 101' }); + expect(controller.offset).toBe(100); + expect(screen.getByRole('button', { name: 'Previous page' })).toHaveProperty('disabled', false); + }); + + it('distinguishes initial load failure from an empty queue and retries page zero', async () => { + let attempts = 0; + const fetchFn = vi.fn(async () => { + attempts += 1; + if (attempts === 1) { + return Response.json({ error: 'unavailable', message: 'Queue unavailable' }, { status: 503 }); + } + return page([]); + }); + const controller = new DirectoryReviewController(createAPIClient(fetchFn)); + await controller.loadIdentityPage(); + renderReview(controller); + + expect(screen.getByRole('alert').textContent).toContain('Queue unavailable'); + expect(screen.queryByText('No identity matches in this queue.')).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Retry identity matches' })); + + expect(await screen.findByText('No identity matches in this queue.')).toBeDefined(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(attempts).toBe(2); + }); + + it('keeps Previous navigation when a successful nonzero page is empty', async () => { + const offsets: number[] = []; + const fetchFn = vi.fn(async (input) => { + const offset = Number(new URL(requestOf(input).url).searchParams.get('offset')); + offsets.push(offset); + return offset === 100 ? page([], 100) : page([candidate(17)], 0); + }); + const controller = new DirectoryReviewController(createAPIClient(fetchFn)); + await controller.loadIdentityPage(100); + renderReview(controller); + + expect(screen.getByText('No identity matches in this queue.')).toBeDefined(); + const previous = screen.getByRole('button', { name: 'Previous page' }); + expect(previous).toHaveProperty('disabled', false); + await fireEvent.click(previous); + + expect(await screen.findByRole('heading', { name: 'Identity match 17' })).toBeDefined(); + expect(controller.offset).toBe(0); + expect(offsets).toEqual([100, 0]); + }); + + it.each([ + { + name: 'identity review to fact review', + target: { reviewKind: 'fact' as const, identityState: 'candidate' as const }, + focusHeading: 'Fact review' + }, + { + name: 'candidate review to conflict review', + target: { reviewKind: 'identity' as const, identityState: 'conflict' as const }, + focusHeading: 'Identity matches' + }, + { + name: 'the same visible candidate state with a new history generation', + target: { reviewKind: 'identity' as const, identityState: 'candidate' as const }, + focusHeading: 'Identity matches' + } + ])('invalidates an open decision across $name', async ({ target, focusHeading }) => { + const requests: Request[] = []; + const current = candidate(17); + const accepted = candidate(17, 'accepted'); + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + requests.push(request); + if (request.method === 'POST') { + return Response.json({ candidate: accepted, identity_revision: 4, cache_state: 'stale' }); + } + const state = new URL(request.url).searchParams.get('state') ?? 'candidate'; + return page([candidate(state === 'conflict' ? 88 : 17, state)]); + }); + const apiClient = createAPIClient(fetchFn); + const controller = new DirectoryReviewController(apiClient); + const factController = new FactLedgerController(apiClient); + controller.rows = [current]; + render(DirectoryReviewCentre, { + controller, + relationshipController: new RelationshipReviewController(apiClient), + factController, + directoryPersonID: null + }); + + await fireEvent.click(screen.getByRole('button', { name: 'Link identities' })); + controller.applyURLState(target, true); + + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Link identities' })).toBeNull()); + const heading = screen.getByRole('heading', { name: focusHeading }); + await waitFor(() => expect(document.activeElement).toBe(heading)); + expect(document.activeElement?.isConnected).toBe(true); + expect(controller.reviewKind).toBe(target.reviewKind); + expect(controller.identityState).toBe(target.identityState); + expect(requests.filter((request) => request.method === 'POST')).toHaveLength(0); + }); + + it('closes a successful decision and returns focus to the originating row action', async () => { + const requests: Request[] = []; + const current = candidate(17); + const accepted = candidate(17, 'accepted'); + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + requests.push(request); + if (request.method === 'POST') { + return Response.json({ candidate: accepted, identity_revision: 4, cache_state: 'stale' }); + } + return page([current]); + }); + const controller = new DirectoryReviewController(createAPIClient(fetchFn)); + controller.rows = [current]; + renderReview(controller); + const trigger = screen.getByRole('button', { name: 'Link identities' }); + trigger.focus(); + + await fireEvent.click(trigger); + await fireEvent.input(screen.getByRole('textbox', { name: 'Decision notes' }), { + target: { value: 'Confirmed by synthetic fixture' } + }); + await fireEvent.click(screen.getByRole('dialog', { name: 'Link identities' }).querySelector('button.kit-button--solid')!); + + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Link identities' })).toBeNull()); + const restoredAction = screen.getByRole('button', { name: 'Link identities' }); + await waitFor(() => expect(document.activeElement).toBe(restoredAction)); + expect(screen.getByRole('status').textContent).toContain('Identity match accepted.'); + expect(requests.filter((request) => request.method === 'POST')).toHaveLength(1); + }); + + it('returns focus to a stable live fallback when reconciliation removes the originating row', async () => { + const current = candidate(17); + const accepted = candidate(17, 'accepted'); + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + if (request.method === 'POST') { + return Response.json({ candidate: accepted, identity_revision: 4, cache_state: 'stale' }); + } + return page([]); + }); + const controller = new DirectoryReviewController(createAPIClient(fetchFn)); + controller.rows = [current]; + renderReview(controller); + const trigger = screen.getByRole('button', { name: 'Link identities' }); + trigger.focus(); + + await fireEvent.click(trigger); + await fireEvent.click(screen.getByRole('dialog', { name: 'Link identities' }).querySelector('button.kit-button--solid')!); + + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Link identities' })).toBeNull()); + expect(screen.getByText('No identity matches in this queue.')).toBeDefined(); + const heading = screen.getByRole('heading', { name: 'Identity matches' }); + await waitFor(() => expect(document.activeElement).toBe(heading)); + expect(document.activeElement?.isConnected).toBe(true); + }); + + it('keeps committed success visible when reconciliation fails and returns focus to the row', async () => { + const current = candidate(17); + const accepted = candidate(17, 'accepted'); + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + if (request.method === 'POST') { + return Response.json({ candidate: accepted, identity_revision: 4, cache_state: 'stale' }); + } + return Response.json({ error: 'unavailable', message: 'Reload failed' }, { status: 503 }); + }); + const controller = new DirectoryReviewController(createAPIClient(fetchFn)); + controller.rows = [current]; + renderReview(controller); + + await fireEvent.click(screen.getByRole('button', { name: 'Link identities' })); + await fireEvent.click(screen.getByRole('dialog', { name: 'Link identities' }).querySelector('button.kit-button--solid')!); + + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Link identities' })).toBeNull()); + const row = screen.getByRole('article', { name: 'Identity match 17' }); + expect(row.textContent).toContain('accepted'); + expect(screen.getByRole('status').textContent).toContain('Identity match accepted.'); + expect(screen.getByRole('alert').textContent).toContain('Reload failed'); + await waitFor(() => expect(document.activeElement).toBe(row)); + }); +}); diff --git a/web/src/lib/components/directory/DirectoryReviewWorkspace.svelte b/web/src/lib/components/directory/DirectoryReviewWorkspace.svelte new file mode 100644 index 000000000..e6878f7e0 --- /dev/null +++ b/web/src/lib/components/directory/DirectoryReviewWorkspace.svelte @@ -0,0 +1,35 @@ + + + diff --git a/web/src/lib/components/directory/DirectoryWorkspace.svelte b/web/src/lib/components/directory/DirectoryWorkspace.svelte new file mode 100644 index 000000000..30a38181c --- /dev/null +++ b/web/src/lib/components/directory/DirectoryWorkspace.svelte @@ -0,0 +1,194 @@ + + +
+
+

Directory

Durable people and their recorded contact context.

+ {#if promotionParticipantID !== undefined} +
+
+ editTextFilter('directoryQuery', value)} /> + selectFilter({ directoryContactState: value })} /> + editTextFilter('directoryCategory', value)} /> + editTextFilter('directoryOrganization', value)} /> + selectFilter({ directoryPrimaryChannel: value })} /> + editTextFilter('directoryLastContactAfter', value)} /> + editTextFilter('directoryLastContactBefore', value)} /> + selectFilter({ directorySort: value as DirectoryURLState['directorySort'] })} /> +
+ {#if controller.promotionResult && !controller.promotionResult.ok} + + {/if} +
+ void controller.selectPerson(personID)} + onLoadMore={() => void controller.loadNextPage()} + onReload={() => void controller.reloadFirstPage()} + /> + {#if controller.selectedPersonID !== null && !narrow} + + {/if} +
+ {#if controller.selectedPersonID !== null && narrow} + void closeDetail()}> + {#if controller.detailLoading}

Loading person detail…

{:else if controller.detail} void controller.selectPerson(personID)} onSplitCommitted={(context) => controller.reconcilePersonSplit(context)} {onOpenCardDAVConflict} {onOpenCardDAVSettings} {onAnnounce} />{/if} +
+ {/if} +
+ + diff --git a/web/src/lib/components/directory/DirectoryWorkspace.test.ts b/web/src/lib/components/directory/DirectoryWorkspace.test.ts new file mode 100644 index 000000000..817c98276 --- /dev/null +++ b/web/src/lib/components/directory/DirectoryWorkspace.test.ts @@ -0,0 +1,652 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../../api/client'; +import { DirectoryController } from '../../directory/controller.svelte'; +import type { DirectoryURLState } from '../../directory/models'; +import { chooseSelectOption } from '../../../test/kit-ui'; +import DirectoryWorkspace from './DirectoryWorkspace.svelte'; + +const state: DirectoryURLState = { + directoryQuery: '', directoryContactState: '', directoryCategory: '', + directoryOrganization: '', directoryPrimaryChannel: '', directoryLastContactAfter: '', directoryLastContactBefore: '', directorySort: 'name', directoryPersonID: null +}; + +function pathOf(request: Request): string { + return new URL(request.url, document.baseURI).pathname; +} + +function directoryResponse() { + return Response.json({ + people: [{ + id: 7, revision: 2, display_name: 'Synthetic Person', contact_state: 'active', + categories: ['friend'], organizations: ['Example Org'], primary_channel: 'email' + }] + }); +} + +describe('DirectoryWorkspace', () => { + afterEach(() => { vi.useRealTimers(); }); + + it('sends one request and replaces one history entry after a burst of typing', async () => { + vi.useFakeTimers(); + const commits: Array<[Partial, string]> = []; + const requests: Request[] = []; + const client = createAPIClient(vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + return directoryResponse(); + })); + const controller = new DirectoryController(client, (patch, history) => commits.push([patch, history])); + render(DirectoryWorkspace, { client, controller, state }); + await vi.advanceTimersByTimeAsync(0); + expect(requests).toHaveLength(1); + + const search = screen.getByRole('searchbox', { name: 'Search directory' }) as HTMLInputElement; + for (const typed of ['a', 'al', 'ali', 'alic', 'alice']) { + await fireEvent.input(search, { target: { value: typed } }); + await vi.advanceTimersByTimeAsync(100); + } + expect(search.value).toBe('alice'); + expect(requests).toHaveLength(1); + expect(commits).toEqual([]); + + await vi.advanceTimersByTimeAsync(250); + expect(commits).toEqual([[{ + directoryQuery: 'alice', directoryCategory: '', directoryOrganization: '', + directoryLastContactAfter: '', directoryLastContactBefore: '' + }, 'replace']]); + expect(requests).toHaveLength(2); + expect(new URL(requests[1]!.url).searchParams.get('q')).toBe('alice'); + expect(controller.query).toBe('alice'); + }); + + it('applies a pending text edit before a select filter pushes', async () => { + vi.useFakeTimers(); + const commits: Array<[Partial, string]> = []; + const requests: Request[] = []; + const client = createAPIClient(vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + return directoryResponse(); + })); + const controller = new DirectoryController(client, (patch, history) => commits.push([patch, history])); + render(DirectoryWorkspace, { client, controller, state }); + await vi.advanceTimersByTimeAsync(0); + + await fireEvent.input(screen.getByRole('textbox', { name: 'Organization filter' }), { target: { value: 'Example Org' } }); + await chooseSelectOption(screen.getByRole('combobox', { name: /^Contact state:/ }), 'Active'); + await vi.advanceTimersByTimeAsync(0); + + expect(commits.map(([, history]) => history)).toEqual(['replace', 'push']); + const last = new URL(requests.at(-1)!.url).searchParams; + expect(last.get('organization')).toBe('Example Org'); + expect(last.get('contact_state')).toBe('active'); + }); + + it('keeps the current rows visible while a filter change loads', async () => { + let resolveFiltered: ((response: Response) => void) | undefined; + const client = createAPIClient(vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + if (new URL(request.url).searchParams.get('contact_state') === 'active') { + return new Promise((resolve) => { resolveFiltered = resolve; }); + } + return directoryResponse(); + })); + const controller = new DirectoryController(client); + render(DirectoryWorkspace, { client, controller, state }); + await screen.findByRole('row', { name: /Synthetic Person/ }); + + await chooseSelectOption(screen.getByRole('combobox', { name: /^Contact state:/ }), 'Active'); + await waitFor(() => expect(resolveFiltered).toBeDefined()); + expect(screen.getByRole('row', { name: /Synthetic Person/ })).toBeDefined(); + expect(screen.getByRole('status').textContent).toBe('Updating people…'); + expect(screen.queryByText('Loading people…')).toBeNull(); + + resolveFiltered?.(Response.json({ people: [{ id: 8, revision: 1, display_name: 'Filtered Person', contact_state: 'active', categories: [], organizations: [] }] })); + await screen.findByRole('row', { name: /Filtered Person/ }); + expect(screen.queryByRole('row', { name: /Synthetic Person/ })).toBeNull(); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('offers free-text category and organization filters when no server facet catalog exists', () => { + const client = createAPIClient(vi.fn(async () => directoryResponse())); + const controller = new DirectoryController(client); + + render(DirectoryWorkspace, { client, controller, state }); + + expect(screen.getByRole('textbox', { name: 'Category filter' })).toBeDefined(); + expect(screen.getByRole('textbox', { name: 'Organization filter' })).toBeDefined(); + }); + + it('offers last-contact range and ordering controls', () => { + const client = createAPIClient(vi.fn(async () => directoryResponse())); + const controller = new DirectoryController(client); + + render(DirectoryWorkspace, { client, controller, state }); + + expect(screen.getByRole('textbox', { name: 'Last contacted after' })).toBeDefined(); + expect(screen.getByRole('textbox', { name: 'Last contacted before' })).toBeDefined(); + expect(screen.getByRole('combobox', { name: /^Directory order:/ })).toBeDefined(); + }); + + it('offers only contact states accepted by the Directory handler contract', async () => { + const requests: Request[] = []; + const client = createAPIClient(vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + return directoryResponse(); + })); + const controller = new DirectoryController(client); + render(DirectoryWorkspace, { client, controller, state }); + + const contactState = screen.getByRole('combobox', { name: /^Contact state:/ }); + await chooseSelectOption(contactState, 'Active'); + await waitFor(() => expect(new URL(requests.at(-1)!.url).searchParams.get('contact_state')).toBe('active')); + await chooseSelectOption(contactState, 'Inactive'); + await waitFor(() => expect(new URL(requests.at(-1)!.url).searchParams.get('contact_state')).toBe('inactive')); + await fireEvent.click(contactState); + expect(screen.queryByRole('option', { name: /unknown/i })).toBeNull(); + }); + + it('promotes an explicitly supplied participant context and commits the returned person ID', async () => { + const commits: Array> = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + if (pathOf(request) === '/api/v1/people') return Response.json({ id: 42, revision: 1 }, { status: 201 }); + if (pathOf(request) === '/api/v1/people/directory') return directoryResponse(); + if (pathOf(request).endsWith('/files/search')) return Response.json({ files: [], total_count: 0, cache_revision: 'synthetic', search_provenance: {} }); + return Response.json({ id: 42, revision: 1, participant_ids: [], vcard_uid: '', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' }); + }); + const client = createAPIClient(fetchFn); + const controller = new DirectoryController(client, (patch) => commits.push(patch)); + + render(DirectoryWorkspace, { client, controller, state, promotionParticipantID: 11 }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Promote to person' })); + + await waitFor(() => expect(commits).toContainEqual({ directoryPersonID: 42 })); + await waitFor(() => expect(controller.selectedPersonID).toBe(42)); + }); + + it('keeps loaded rows visible when loading another page fails and retries that page', async () => { + let pageRequests = 0; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + if (pathOf(request) !== '/api/v1/people/directory') throw new Error(`unexpected ${pathOf(request)}`); + pageRequests += 1; + if (pageRequests === 1) return Response.json({ + people: [{ id: 7, revision: 2, display_name: 'Synthetic Person', contact_state: 'active', categories: [], organizations: [] }], + next_cursor: 'next' + }); + if (pageRequests === 2) throw new Error('network offline'); + return Response.json({ people: [{ id: 8, revision: 1, display_name: 'Fixture Person', contact_state: 'unknown', categories: [], organizations: [] }] }); + }); + const client = createAPIClient(fetchFn); + const controller = new DirectoryController(client); + render(DirectoryWorkspace, { client, controller, state }); + + expect(await screen.findByText('Synthetic Person')).toBeDefined(); + await fireEvent.click(screen.getByRole('button', { name: 'Load more people' })); + expect((await screen.findByRole('alert')).textContent).toContain('network offline'); + expect(screen.getByText('Synthetic Person')).toBeDefined(); + await fireEvent.click(screen.getByRole('button', { name: 'Retry loading more people' })); + expect(await screen.findByText('Fixture Person')).toBeDefined(); + }); + + it('offers a page-one reload after a terminal cursor failure and retains rows until success', async () => { + let pageRequests = 0; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + if (pathOf(request) !== '/api/v1/people/directory') throw new Error(`unexpected ${pathOf(request)}`); + pageRequests += 1; + if (pageRequests === 1) return Response.json({ + people: [{ id: 7, revision: 2, display_name: 'Retained Person', contact_state: 'active', categories: [], organizations: [] }], + next_cursor: 'invalidated' + }); + if (pageRequests === 2) return Response.json({ error: 'invalid_cursor', message: 'Directory changed' }, { status: 400 }); + return Response.json({ + people: [{ id: 8, revision: 1, display_name: 'Reloaded Person', contact_state: 'active', categories: [], organizations: [] }] + }); + }); + const client = createAPIClient(fetchFn); + const controller = new DirectoryController(client); + render(DirectoryWorkspace, { client, controller, state }); + + expect(await screen.findByText('Retained Person')).toBeDefined(); + await fireEvent.click(screen.getByRole('button', { name: 'Load more people' })); + expect((await screen.findByRole('alert')).textContent).toContain('Directory changed'); + expect(screen.getByText('Retained Person')).toBeDefined(); + await fireEvent.click(screen.getByRole('button', { name: 'Reload directory' })); + expect(await screen.findByText('Reloaded Person')).toBeDefined(); + }); + + it('renders actionable binding guidance from the structured promotion code', async () => { + const client = createAPIClient(vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + if (pathOf(request) === '/api/v1/people') { + return Response.json({ error: 'person_binding_conflict', message: 'Different durable profiles own this cluster.' }, { status: 409 }); + } + return directoryResponse(); + })); + const controller = new DirectoryController(client); + render(DirectoryWorkspace, { client, controller, state, promotionParticipantID: 11 }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Promote to person' })); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Different durable profiles own this cluster.'); + expect(alert.textContent).toContain('already belongs to another durable person'); + }); + + it('renders structured editing through the selection-owned profile controller', async () => { + const selectedState = { ...state, directoryPersonID: 7 }; + const selectedPerson = { + id: 7, revision: 2, display_name: 'Synthetic Person', participant_ids: [], vcard_uid: 'person-7', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' + }; + const client = createAPIClient(vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = pathOf(request); + if (path === '/api/v1/people/directory') return directoryResponse(); + if (path === '/api/v1/people/7/profile') return new Response(JSON.stringify({ + person: selectedPerson, names: [], contact_points: [], addresses: [], dates: [], categories: [], media: [] + }), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r2"' } }); + if (path === '/api/v1/people/7') return new Response(JSON.stringify(selectedPerson), { + headers: { 'Content-Type': 'application/json', ETag: '"person-7-r2"' } + }); + if (path.endsWith('/attributes')) return Response.json({ person_id: 7, attributes: [] }); + if (path.endsWith('/files/search')) return Response.json({ files: [], total_count: 0, cache_revision: 'synthetic', search_provenance: {} }); + if (path.endsWith('/employments')) return Response.json({ employments: [] }); + if (path.endsWith('/relationships')) return Response.json({ relationships: [] }); + if (path.endsWith('/days')) return Response.json({ person_id: 7, total_count: 0, days: [] }); + if (path.endsWith('/contact-state')) return Response.json({ person_id: 7, cadence_status: 'unknown', interaction_count: 0, computed_at: '2026-01-01T00:00:00Z', stale: false }); + throw new Error(`unexpected ${path}`); + })); + const controller = new DirectoryController(client); + + render(DirectoryWorkspace, { client, controller, state: selectedState }); + + expect(await screen.findByRole('heading', { name: 'Structured profile' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Add name' })).toHaveProperty('disabled', false); + expect(controller.profile).not.toBeNull(); + await fireEvent.click(screen.getByRole('tab', { name: 'Organizations' })); + expect(screen.getByRole('tabpanel', { name: 'Organizations' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Add employment' })).toBeDefined(); + expect(controller.entity).not.toBeNull(); + }); + + it('removes a confirmed deleted person from the Directory and clears its URL selection', async () => { + const selectedState = { ...state, directoryPersonID: 7 }; + const commits: Array> = []; + const selectedPerson = { + id: 7, revision: 2, display_name: 'Synthetic Person', participant_ids: [], vcard_uid: 'person-7', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' + }; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = pathOf(request); + if (path === '/api/v1/people/directory') return directoryResponse(); + if (path === '/api/v1/people/7' && request.method === 'DELETE') return new Response(null, { status: 204 }); + if (path === '/api/v1/people/7') return new Response(JSON.stringify(selectedPerson), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r2"' } }); + if (path === '/api/v1/people/7/profile') return new Response(JSON.stringify({ + person: selectedPerson, names: [], contact_points: [], addresses: [], dates: [], categories: [], media: [] + }), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r2"' } }); + if (path.endsWith('/attributes')) return Response.json({ person_id: 7, attributes: [] }); + if (path.endsWith('/files/search')) return Response.json({ files: [], total_count: 0, cache_revision: 'synthetic', search_provenance: {} }); + if (path.endsWith('/employments')) return Response.json({ employments: [] }); + if (path.endsWith('/relationships')) return Response.json({ relationships: [] }); + if (path.endsWith('/days')) return Response.json({ person_id: 7, total_count: 0, days: [] }); + if (path.endsWith('/contact-state')) return Response.json({ person_id: 7, cadence_status: 'unknown', interaction_count: 0, computed_at: '2026-01-01T00:00:00Z', stale: false }); + return Response.json({ error: 'unavailable', message: 'not rendered' }, { status: 503 }); + }); + const controller = new DirectoryController(createAPIClient(fetchFn), (patch) => commits.push(patch)); + render(DirectoryWorkspace, { client: createAPIClient(fetchFn), controller, state: selectedState }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Delete person' })); + await fireEvent.click(screen.getByRole('button', { name: 'Confirm delete person' })); + + await waitFor(() => expect(controller.selectedPersonID).toBeNull()); + expect(controller.rows).toEqual([]); + expect(commits).toContainEqual({ directoryPersonID: null }); + }); + + it('renders unfiltered Directory category summaries immediately after add and close', async () => { + const selectedState = { ...state, directoryPersonID: 7 }; + const selectedPerson = { + id: 7, revision: 2, display_name: 'Synthetic Person', participant_ids: [], vcard_uid: 'person-7', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' + }; + const friend = { + person_id: 7, original_value: 'Friend', normalized_value: 'friend', + envelope: { id: 31, ordinal: 0, source: 'user', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', vcard: {} } + }; + const vip = { + person_id: 7, original_value: 'VIP', normalized_value: 'vip', + envelope: { id: 32, ordinal: 1, source: 'user', created_at: '2026-01-02T00:00:00Z', updated_at: '2026-01-02T00:00:00Z', vcard: {} } + }; + let profileWrite = 0; + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const path = pathOf(request); + if (path === '/api/v1/people/directory') return Response.json({ people: [{ + id: 7, revision: 2, display_name: 'Synthetic Person', contact_state: 'active', + categories: ['Friend'], organizations: ['Example Org'], primary_channel: 'email' + }] }); + if (path === '/api/v1/people/7/profile' && request.method === 'PATCH') { + profileWrite += 1; + const categories = profileWrite === 1 ? [friend, vip] : [friend]; + return new Response(JSON.stringify({ + person: { ...selectedPerson, revision: 2 + profileWrite }, names: [], contact_points: [], addresses: [], dates: [], categories, media: [] + }), { headers: { 'Content-Type': 'application/json', ETag: `"person-7-r${2 + profileWrite}"` } }); + } + if (path === '/api/v1/people/7/profile') return new Response(JSON.stringify({ + person: selectedPerson, names: [], contact_points: [], addresses: [], dates: [], categories: [friend], media: [] + }), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r2"' } }); + if (path === '/api/v1/people/7') return new Response(JSON.stringify(selectedPerson), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r2"' } }); + if (path.endsWith('/attributes')) return Response.json({ person_id: 7, attributes: [] }); + if (path.endsWith('/files/search')) return Response.json({ files: [], total_count: 0, cache_revision: 'synthetic', search_provenance: {} }); + if (path.endsWith('/employments')) return Response.json({ employments: [] }); + if (path.endsWith('/relationships')) return Response.json({ relationships: [] }); + if (path.endsWith('/days')) return Response.json({ person_id: 7, total_count: 0, days: [] }); + if (path.endsWith('/contact-state')) return Response.json({ person_id: 7, cadence_status: 'unknown', interaction_count: 0, computed_at: '2026-01-01T00:00:00Z', stale: false }); + throw new Error(`unexpected ${request.method} ${path}`); + }); + const client = createAPIClient(fetchFn); + const controller = new DirectoryController(client); + render(DirectoryWorkspace, { client, controller, state: selectedState }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Add category' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Category' }), { target: { value: 'VIP' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save category' })); + + const row = await screen.findByRole('row', { name: /Synthetic Person/ }); + await waitFor(() => expect(row.textContent).toContain('Friend · VIP')); + expect(requests.filter((request) => pathOf(request) === '/api/v1/people/directory')).toHaveLength(1); + + await fireEvent.click(screen.getByRole('button', { name: 'Close category VIP' })); + await fireEvent.click(screen.getByRole('button', { name: 'Confirm close category' })); + await waitFor(() => expect(row.textContent).not.toContain('VIP')); + expect(row.textContent).toContain('Friend'); + expect(requests.filter((request) => pathOf(request) === '/api/v1/people/directory')).toHaveLength(1); + }); + + it('keeps a server-returned Unicode category match visible after the exact write overlay', async () => { + const selectedState = { ...state, directoryCategory: 'STRASSE', directoryPersonID: 7 }; + const selectedPerson = { + id: 7, revision: 2, display_name: 'Synthetic Person', participant_ids: [], vcard_uid: 'person-7', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' + }; + const street = { + person_id: 7, original_value: 'Straße', normalized_value: 'strasse', + envelope: { id: 31, ordinal: 0, source: 'user', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', vcard: {} } + }; + let directoryRead = 0; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = pathOf(request); + if (path === '/api/v1/people/directory') { + directoryRead += 1; + return Response.json({ people: [{ + id: 7, revision: 2, display_name: 'Synthetic Person', contact_state: 'active', + categories: [], organizations: ['Example Org'], primary_channel: 'email' + }] }); + } + if (path === '/api/v1/people/7/profile' && request.method === 'PATCH') { + return new Response(JSON.stringify({ + person: { ...selectedPerson, revision: 3 }, names: [], contact_points: [], addresses: [], dates: [], categories: [street], media: [] + }), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r3"' } }); + } + if (path === '/api/v1/people/7/profile') return new Response(JSON.stringify({ + person: selectedPerson, names: [], contact_points: [], addresses: [], dates: [], categories: [], media: [] + }), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r2"' } }); + if (path === '/api/v1/people/7') return new Response(JSON.stringify(selectedPerson), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r2"' } }); + if (path.endsWith('/attributes')) return Response.json({ person_id: 7, attributes: [] }); + if (path.endsWith('/files/search')) return Response.json({ files: [], total_count: 0, cache_revision: 'synthetic', search_provenance: {} }); + if (path.endsWith('/employments')) return Response.json({ employments: [] }); + if (path.endsWith('/relationships')) return Response.json({ relationships: [] }); + if (path.endsWith('/days')) return Response.json({ person_id: 7, total_count: 0, days: [] }); + if (path.endsWith('/contact-state')) return Response.json({ person_id: 7, cadence_status: 'unknown', interaction_count: 0, computed_at: '2026-01-01T00:00:00Z', stale: false }); + throw new Error(`unexpected ${request.method} ${path}`); + }); + const client = createAPIClient(fetchFn); + const controller = new DirectoryController(client); + render(DirectoryWorkspace, { client, controller, state: selectedState }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Add category' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Category' }), { target: { value: 'Straße' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save category' })); + + const row = await screen.findByRole('row', { name: /Synthetic Person/ }); + await waitFor(() => expect(row.textContent).toContain('Straße')); + expect(directoryRead).toBe(2); + expect(controller.category).toBe('STRASSE'); + expect(controller.selectedPersonID).toBe(7); + }); + + it('keeps prior rows and reloads current filters after filtered write reconciliation fails', async () => { + const selectedState = { + ...state, + directoryQuery: 'synthetic', + directoryContactState: 'active', + directoryCategory: 'STRASSE', + directoryOrganization: 'Example Org', + directoryPrimaryChannel: 'email', + directoryPersonID: 7 + }; + const selectedPerson = { + id: 7, revision: 2, display_name: 'Synthetic Person', participant_ids: [], vcard_uid: 'person-7', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' + }; + const street = { + person_id: 7, original_value: 'Straße', normalized_value: 'strasse', + envelope: { id: 31, ordinal: 0, source: 'user', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', vcard: {} } + }; + const directoryRequests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = pathOf(request); + if (path === '/api/v1/people/directory') { + directoryRequests.push(request); + if (directoryRequests.length === 1) return Response.json({ people: [{ + id: 7, revision: 2, display_name: 'Synthetic Person', contact_state: 'active', + categories: [], organizations: ['Example Org'], primary_channel: 'email' + }], next_cursor: 'old-next' }); + if (directoryRequests.length === 2) { + return Response.json({ error: 'unavailable', message: 'Directory reconciliation unavailable.' }, { status: 503 }); + } + return Response.json({ people: [{ + id: 7, revision: 3, display_name: 'Synthetic Person', contact_state: 'active', + categories: ['Straße'], organizations: ['Example Org'], primary_channel: 'email' + }], next_cursor: 'recovered-next' }); + } + if (path === '/api/v1/people/7/profile' && request.method === 'PATCH') { + return new Response(JSON.stringify({ + person: { ...selectedPerson, revision: 3 }, names: [], contact_points: [], addresses: [], dates: [], categories: [street], media: [] + }), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r3"' } }); + } + if (path === '/api/v1/people/7/profile') return new Response(JSON.stringify({ + person: selectedPerson, names: [], contact_points: [], addresses: [], dates: [], categories: [], media: [] + }), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r2"' } }); + if (path === '/api/v1/people/7') return new Response(JSON.stringify(selectedPerson), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r2"' } }); + if (path.endsWith('/attributes')) return Response.json({ person_id: 7, attributes: [] }); + if (path.endsWith('/files/search')) return Response.json({ files: [], total_count: 0, cache_revision: 'synthetic', search_provenance: {} }); + if (path.endsWith('/employments')) return Response.json({ employments: [] }); + if (path.endsWith('/relationships')) return Response.json({ relationships: [] }); + if (path.endsWith('/days')) return Response.json({ person_id: 7, total_count: 0, days: [] }); + if (path.endsWith('/contact-state')) return Response.json({ person_id: 7, cadence_status: 'unknown', interaction_count: 0, computed_at: '2026-01-01T00:00:00Z', stale: false }); + throw new Error(`unexpected ${request.method} ${path}`); + }); + const client = createAPIClient(fetchFn); + const controller = new DirectoryController(client); + render(DirectoryWorkspace, { client, controller, state: selectedState }); + + const row = await screen.findByRole('row', { name: /Synthetic Person/ }); + await fireEvent.click(await screen.findByRole('button', { name: 'Add category' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Category' }), { target: { value: 'Straße' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save category' })); + + expect(await screen.findByText('Directory reconciliation unavailable.')).toBeDefined(); + expect(row.textContent).not.toContain('Straße'); + expect(await screen.findByRole('button', { name: 'Close category Straße' })).toBeDefined(); + expect(controller.profile?.conflict).toBeNull(); + expect(controller.selectedPersonID).toBe(7); + expect(screen.getByRole('button', { name: 'Reload directory' })).toBeDefined(); + expect(screen.queryByRole('button', { name: 'Load more people' })).toBeNull(); + + await controller.loadNextPage(); + expect(directoryRequests).toHaveLength(2); + expect(controller.pageError).toBe('Directory reconciliation unavailable.'); + expect(controller.pageRecovery).toBe('reload'); + + await fireEvent.click(screen.getByRole('button', { name: 'Reload directory' })); + + await waitFor(() => expect(row.textContent).toContain('Straße')); + expect(controller.pageError).toBeNull(); + expect(controller.pageRecovery).toBeNull(); + expect(controller.cursor).toBe('recovered-next'); + const reloadParameters = new URL(directoryRequests[2]!.url).searchParams; + expect(reloadParameters.get('q')).toBe('synthetic'); + expect(reloadParameters.get('contact_state')).toBe('active'); + expect(reloadParameters.get('category')).toBe('STRASSE'); + expect(reloadParameters.get('organization')).toBe('Example Org'); + expect(reloadParameters.get('primary_channel')).toBe('email'); + }); + + it('updates the preferred channel without changing the observed Directory channel', async () => { + const selectedState = { ...state, directoryPrimaryChannel: 'email', directoryPersonID: 7 }; + const selectedPerson = { + id: 7, revision: 2, display_name: 'Synthetic Person', participant_ids: [], vcard_uid: 'person-7', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' + }; + const definition = { + id: 1, universal_id: 'primary-channel-id', object_type: 'person', slug: 'primary_channel', label: 'Primary channel', + value_type: 'text', field_type: 'select', cardinality: 'single', display_order: 0, is_required: false, + ownership: 'system', ui_creatable: true, ui_editable: true, api_mutable: true, is_searchable: false, + is_sensitive: false, is_audited: true, is_deletable: false, history_exempt: false, + options: { choices: [{ value: 'email', label: 'Email' }, { value: 'chat', label: 'Chat' }] }, is_active: true, + revision: 1, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' + }; + const email = { + id: 41, person_id: 7, definition_id: 1, definition_slug: 'primary_channel', ordinal: 0, + value: { type: 'text', text: 'email' }, active_from: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z', source: 'user' + }; + const chat = { + ...email, id: 42, value: { type: 'text', text: 'chat' }, active_from: '2026-01-02T00:00:00Z', created_at: '2026-01-02T00:00:00Z' + }; + let attributeWrite = 0; + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const path = pathOf(request); + if (path === '/api/v1/people/directory') return directoryResponse(); + if (path === '/api/v1/people/7/profile') return new Response(JSON.stringify({ + person: selectedPerson, names: [], contact_points: [], addresses: [], dates: [], categories: [], media: [] + }), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r2"' } }); + if (path === '/api/v1/people/7') return new Response(JSON.stringify(selectedPerson), { headers: { 'Content-Type': 'application/json', ETag: '"person-7-r2"' } }); + if (path === '/api/v1/people/7/attributes/primary_channel') { + attributeWrite += 1; + return attributeWrite === 1 + ? Response.json({ dry_run: false, value: chat, superseded: { ...email, active_until: '2026-01-02T00:00:00Z', superseded_at: '2026-01-02T00:00:00Z' } }) + : Response.json({ dry_run: false, superseded: { ...chat, active_until: '2026-01-03T00:00:00Z', superseded_at: '2026-01-03T00:00:00Z' } }); + } + if (path.endsWith('/attributes')) return Response.json({ person_id: 7, attributes: [{ definition, current: [email], history: [] }] }); + if (path.endsWith('/files/search')) return Response.json({ files: [], total_count: 0, cache_revision: 'synthetic', search_provenance: {} }); + if (path.endsWith('/employments')) return Response.json({ employments: [] }); + if (path.endsWith('/relationships')) return Response.json({ relationships: [] }); + if (path.endsWith('/days')) return Response.json({ person_id: 7, total_count: 0, days: [] }); + if (path.endsWith('/contact-state')) return Response.json({ person_id: 7, cadence_status: 'unknown', interaction_count: 0, computed_at: '2026-01-01T00:00:00Z', stale: false }); + throw new Error(`unexpected ${request.method} ${path}`); + }); + const client = createAPIClient(fetchFn); + const controller = new DirectoryController(client); + render(DirectoryWorkspace, { client, controller, state: selectedState }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Edit Primary channel value 1' })); + await fireEvent.change(screen.getByRole('combobox', { name: 'Primary channel' }), { target: { value: 'chat' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save attribute' })); + + const row = screen.getByRole('row', { name: /Synthetic Person/ }); + await waitFor(() => expect( + controller.profile?.attributes?.attributes?.[0]?.current?.[0]?.value + ).toEqual({ type: 'text', text: 'chat' })); + expect(row.textContent).toContain('email · active'); + expect(requests.filter((request) => pathOf(request) === '/api/v1/people/directory')).toHaveLength(1); + + await fireEvent.click(screen.getByRole('button', { name: 'Close Primary channel value 1' })); + await fireEvent.click(screen.getByRole('button', { name: 'Confirm close attribute' })); + await waitFor(() => expect(controller.profile?.attributes?.attributes?.[0]?.current).toEqual([])); + expect(row.textContent).toContain('email · active'); + expect(requests.filter((request) => pathOf(request) === '/api/v1/people/directory')).toHaveLength(1); + }); + + it('returns focus to the roving row after a narrow detail drawer closes', async () => { + const changeListeners = new Set<(event: MediaQueryListEvent) => void>(); + vi.stubGlobal('matchMedia', () => ({ + matches: true, + addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => changeListeners.add(listener), + removeEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => changeListeners.delete(listener) + })); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + if (pathOf(request) === '/api/v1/people/directory') return directoryResponse(); + return Response.json({ id: 7, revision: 1, participant_ids: [], vcard_uid: '', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' }); + }); + const client = createAPIClient(fetchFn); + const controller = new DirectoryController(client); + const rendered = render(DirectoryWorkspace, { client, controller, state }); + + const row = await screen.findByRole('row', { name: /Synthetic Person/ }); + await fireEvent.click(row); + expect(await screen.findByRole('dialog', { name: 'Person detail' })).toBeDefined(); + await fireEvent.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(controller.selectedPersonID).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(row)); + + rendered.unmount(); + vi.unstubAllGlobals(); + }); + + it('destroys the person publication context when a narrow detail drawer closes', async () => { + vi.stubGlobal('matchMedia', () => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + })); + let publicationSignal: AbortSignal | undefined; + let resolvePublication!: (response: Response) => void; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = pathOf(request); + if (path === '/api/v1/people/directory') return directoryResponse(); + if (path === '/api/v1/carddav/publications/7') { + publicationSignal = request.signal; + return new Promise((resolve) => { resolvePublication = resolve; }); + } + return Response.json({ + id: 7, revision: 1, participant_ids: [], vcard_uid: '', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' + }); + }); + const client = createAPIClient(fetchFn); + const controller = new DirectoryController(client); + const rendered = render(DirectoryWorkspace, { client, controller, state }); + + await fireEvent.click(await screen.findByRole('row', { name: /Synthetic Person/ })); + expect(await screen.findByRole('dialog', { name: 'Person detail' })).toBeDefined(); + await waitFor(() => expect(publicationSignal).toBeDefined()); + await fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(publicationSignal?.aborted).toBe(true); + resolvePublication(Response.json({ + person_id: 7, state: 'published', desired: true, + address_book: { id: 5, name: 'Old contacts' } + })); + await Promise.resolve(); + expect(screen.queryByText('Old contacts')).toBeNull(); + + rendered.unmount(); + vi.unstubAllGlobals(); + }); +}); diff --git a/web/src/lib/components/directory/EmploymentCurrentData.svelte b/web/src/lib/components/directory/EmploymentCurrentData.svelte new file mode 100644 index 000000000..cb9bf366f --- /dev/null +++ b/web/src/lib/components/directory/EmploymentCurrentData.svelte @@ -0,0 +1,47 @@ + + +
+
Organization
{organizationText()}
+
Person
{employment.person_id}
+
Title
{employment.title || 'None'}
+
Role
{employment.role || 'None'}
+
Department
{employment.department || 'None'}
+
Location
{employment.location || 'None'}
+
Description
{employment.description || 'None'}
+
Dates
{partialDate(employment.start_date) || 'Unspecified'} – {employment.is_current ? 'present' : partialDate(employment.end_date) || 'unspecified'}
+
Status
{employment.is_current ? 'Current' : 'Historical'}; {employment.is_primary ? 'primary' : 'not primary'}
+
Address
{employment.address_id === undefined ? 'None' : `Address ID ${employment.address_id}`}
+
Confidence
{employment.confidence === undefined ? 'None' : employment.confidence}
+
Source
{employment.source}{#if employment.source_ref} · {employment.source_ref}{/if}
+
+ + diff --git a/web/src/lib/components/directory/EmploymentEditor.svelte b/web/src/lib/components/directory/EmploymentEditor.svelte new file mode 100644 index 000000000..1faaddd67 --- /dev/null +++ b/web/src/lib/components/directory/EmploymentEditor.svelte @@ -0,0 +1,219 @@ + + + +
{ event.preventDefault(); void submit(); }}> + {#if loading} +

Loading current employment…

+ {:else if initialAction === 'end'} + + {:else} + + + + + + + + +
{ isCurrent = checked; }} disabled={submitting} /> { isPrimary = checked; }} disabled={submitting} />
+ {/if} + {#if message} +
+

{message}

+ {#if conflictCurrent} + + {/if} +
+ {/if} + {#if controller.createBlocked.employments}
+ + + + + + + {#each Object.entries(bundle.errors) as [section, message]} + + {/each} + + {#if activeTab === 'media'} +
+ + +
+ {:else if activeTab === 'network'} +
+ {#if entityController} + {:else}

Network

The curated network is unavailable for this selection.

{/if} +
+ {:else if activeTab === 'relationships'} +
+ {#if entityController} + {:else}

Relationships

Relationships are unavailable for this selection.

{/if} +
+ {:else if activeTab === 'organizations'} +
+ {#if entityController} + {:else}

Organizations

Organizations are unavailable for this selection.

{/if} +
+ {:else} +
+ {#if bundle.person || profile} +

{bundle.person?.display_name ?? profile?.person?.display_name ?? `Person ${personID}`}

+ {/if} + + + {#if profileController} + + {:else if profile?.names?.length} +

Names

    {#each profile.names as name}
  • {nameText(name)} {name.name_kind}
  • {/each}
+ {/if} + {#if !profileController && groupedContacts().length} +

Contact observations

{#each groupedContacts() as [service, points]}

{service}

    {#each points as point}
  • {point.original_value} {point.address_kind}
  • {/each}
{/each}
+ {/if} + {#if !profileController && profile?.addresses?.length} +

Addresses

    {#each profile.addresses as address}
  • {address.original_value} {address.address_kind}
  • {/each}
+ {/if} + {#if !profileController && profile?.dates?.length} +

Dates

    {#each profile.dates as date}
  • {date.label ?? date.date_kind}: {date.date_text ?? valueText(date.date)}
  • {/each}
+ {/if} + {#if !profileController && profile?.categories?.length} +

Categories

    {#each profile.categories as category}
  • {category.original_value}
  • {/each}
+ {/if} + {#if profileController && profileController.attributes} + + {:else if bundle.attributes?.attributes?.length} +

Attributes

    {#each bundle.attributes.attributes as group}
  • {group.definition.label}{#if group.definition.is_sensitive} Sensitive: concealed{:else}: {group.current?.map((value) => valueText(value.value)).join(', ')}{/if}
  • {/each}
+ {/if} + {#if entityController?.employments.length} +

Organizations and employment

    {#each entityController.employments as employment}
  • {employment.title ?? employment.role ?? 'Employment'} · {employmentOrganization(employment.id) ?? `Organization ${employment.organization_id}`}{#if employment.is_current} Current{/if}
  • {/each}
+ {/if} + {#if entityController?.relationships.length} +

Relationships

    {#each entityController.relationships as view}
  • {view.counterpart_display_name?.trim() || view.counterpart_vcard_uid || `Person ${view.counterpart_person_id}`} · {view.counterpart_label}
  • {/each}
+ {/if} + {#if bundle.contactState} +

Contact state

{bundle.contactState.cadence_status} · {bundle.contactState.interaction_count} interactions{#if bundle.contactState.last_contact_at} · last contact {bundle.contactState.last_contact_at}{/if}

+ {/if} + {#if bundle.activity} +

Activity

{bundle.activity.total_count} recorded days

+ {/if} + +
+ {/if} + + + diff --git a/web/src/lib/components/directory/PersonDetail.test.ts b/web/src/lib/components/directory/PersonDetail.test.ts new file mode 100644 index 000000000..1e5686699 --- /dev/null +++ b/web/src/lib/components/directory/PersonDetail.test.ts @@ -0,0 +1,318 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../../api/client'; +import { DirectoryEntityController } from '../../directory/entity-controller.svelte'; +import type { DirectoryReadBundle } from '../../directory/models'; +import PersonDetail from './PersonDetail.svelte'; + +function profileMaintenanceResponse(request: Request): Response | undefined { + const path = new URL(request.url).pathname; + if (path === '/api/v1/people/7/tracking') { + return Response.json({ person_id: 7, tracked: false, tracked_at: null }); + } + if (path === '/api/v1/person-fact-targets') { + return Response.json({ version: 'v1', fingerprint: 'not-rendered', targets: [] }); + } + return undefined; +} + +describe('PersonDetail', () => { + it('renders available read sections, marks sensitive attributes, and keeps Media & Files person-scoped', async () => { + const requestPaths: string[] = []; + const client = createAPIClient(vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = new URL(request.url).pathname; + requestPaths.push(path); + const maintenance = profileMaintenanceResponse(request); + if (maintenance) return maintenance; + if (path.endsWith('/merges')) return Response.json({ merges: [], limit: 100, offset: 0 }); + if (path === '/api/v1/people/7/employments') return Response.json({ employments: [{ id: 3, person_id: 7, organization_id: 2, is_current: true, is_primary: true, source: 'user', revision: 1, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', title: 'Engineer' }], projection: { employment_id: 3, organization_id: 2, organization_name: 'Example Org', vcard: {} } }); + if (path === '/api/v1/people/7/relationships') return Response.json({ relationships: [ + { counterpart_person_id: 9, counterpart_display_name: 'Synthetic Child', counterpart_label: 'child', counterpart_vcard_uid: 'urn:uuid:child', direction: 'outgoing', relationship: { id: 4, source_person_id: 7, target_person_id: 9, relationship_type_id: 1, type_slug: 'parent', forward_label: 'parent', reverse_label: 'child', is_symmetric: false, status: 'active', source: 'user', created_by: 'user', updated_by: 'user', revision: 1, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', vcard_identity: {} } }, + { counterpart_person_id: 10, counterpart_label: 'parent', counterpart_vcard_uid: 'urn:uuid:parent', direction: 'incoming', relationship: { id: 5, source_person_id: 10, target_person_id: 7, relationship_type_id: 1, type_slug: 'parent', forward_label: 'parent', reverse_label: 'child', is_symmetric: false, status: 'active', source: 'user', created_by: 'user', updated_by: 'user', revision: 1, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', vcard_identity: {} } } + ] }); + if (path === '/api/v1/relationship-types') return Response.json({ relationship_types: [] }); + return Response.json({ files: [], total_count: 0, cache_revision: 'synthetic', search_provenance: {} }); + })); + const bundle = { + person: { id: 7, revision: 2, display_name: 'Synthetic Person', participant_ids: [], vcard_uid: '', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' }, + structuredProfile: { + person: { id: 7, revision: 2, participant_ids: [], vcard_uid: '', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' }, + names: [{ person_id: 7, name_kind: 'legal', original_value: 'Synthetic Person', is_derived: false, envelope: { id: 1, ordinal: 0, source: 'user', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', vcard: {} } }], + contact_points: [{ person_id: 7, address_kind: 'email', original_value: 'person@example.test', normalized_value: 'person@example.test', normalization: 'email', normalization_version: 1, service_slug: 'email', envelope: { id: 2, ordinal: 0, source: 'archive_observation', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', vcard: {} } }], + addresses: [], dates: [], categories: [], media: [] + }, + attributes: { person_id: 7, attributes: [{ definition: { + id: 1, slug: 'private-note', label: 'Private note', value_type: 'text', field_type: 'text', + api_mutable: true, cardinality: 'single', display_order: 0, history_exempt: false, + is_sensitive: true, is_active: true, is_audited: true, is_deletable: true, is_required: false, + is_searchable: false, object_type: 'person', ownership: 'user', revision: 1, + ui_creatable: true, ui_editable: true, universal_id: 'synthetic-private-note', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' + }, current: [{ id: 1, person_id: 7, definition_id: 1, definition_slug: 'private-note', ordinal: 0, source: 'user', active_from: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z', value: { type: 'text', text: 'Synthetic value' } }] }] }, + contactState: { person_id: 7, cadence_status: 'active', computed_at: '2026-01-01T00:00:00Z', interaction_count: 4, stale: false }, + activity: { person_id: 7, total_count: 1, days: [{ local_date: '2026-01-01', entry_count: 1, event_count: 0, direct_count: 1 }] }, + etags: {}, errors: {} + } satisfies DirectoryReadBundle; + + const entityController = new DirectoryEntityController(client, 7); + void entityController.load(); + render(PersonDetail, { client, bundle, personID: 7, entityController }); + + expect(screen.getByText('Names')).toBeDefined(); + expect(screen.getByText('person@example.test')).toBeDefined(); + expect(screen.getByText('Sensitive')).toBeDefined(); + expect(document.body.innerHTML).not.toContain('Synthetic value'); + expect(await screen.findByText(/Example Org/)).toBeDefined(); + expect(screen.getByText('Synthetic Child · child')).toBeDefined(); + expect(screen.getByText('urn:uuid:parent · parent')).toBeDefined(); + expect(screen.queryByText(/outgoing: parent/)).toBeNull(); + expect(requestPaths.filter((path) => path === '/api/v1/people/7/employments')).toHaveLength(1); + expect(requestPaths.filter((path) => path === '/api/v1/people/7/relationships')).toHaveLength(1); + expect(requestPaths).not.toContain('/api/v1/people/7/network'); + expect(screen.getByText('Activity')).toBeDefined(); + expect(screen.queryByText('Provenance and history')).toBeNull(); + await fireEvent.click(screen.getByRole('tab', { name: 'Media & Files' })); + await waitFor(() => expect(requestPaths).toContain('/api/v1/people/7/files/search')); + expect(requestPaths).not.toContain('/api/v1/participants/7/files/search'); + expect(requestPaths).not.toContain('/api/v1/files/search'); + }); + + it('does not claim an organization name for an employment outside the primary projection', async () => { + const client = createAPIClient(vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const maintenance = profileMaintenanceResponse(request); + if (maintenance) return maintenance; + const path = new URL(request.url).pathname; + if (path === '/api/v1/people/7/employments') return Response.json({ + employments: [{ id: 4, person_id: 7, organization_id: 9, is_current: true, is_primary: false, source: 'user', revision: 1, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', role: 'Contributor' }], + projection: { employment_id: 3, organization_id: 2, organization_name: 'Incorrect organization', vcard: {} } + }); + if (path === '/api/v1/people/7/relationships') return Response.json({ relationships: [] }); + if (path === '/api/v1/relationship-types') return Response.json({ relationship_types: [] }); + return Response.json({ merges: [], limit: 100, offset: 0 }); + })); + const entityController = new DirectoryEntityController(client, 7); + void entityController.load(); + + render(PersonDetail, { client, bundle: { etags: {}, errors: {} }, personID: 7, entityController }); + + expect(await screen.findByText(/Contributor/)).toBeDefined(); + expect(screen.queryByText('Incorrect organization')).toBeNull(); + expect(screen.getByText(/Organization 9/)).toBeDefined(); + }); + + it('shows the exact failed section without inventing missing data', () => { + render(PersonDetail, { + client: createAPIClient(vi.fn()), personID: 7, + bundle: { etags: {}, errors: { structuredProfile: 'profile service unavailable' } } + }); + + expect(screen.getByRole('alert').textContent).toContain('Profile: profile service unavailable'); + expect(screen.queryByText('Names')).toBeNull(); + }); + + it('implements roving keyboard tabs with linked tabpanels', async () => { + const client = createAPIClient(vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const maintenance = profileMaintenanceResponse(request); + if (maintenance) return maintenance; + if (new URL(request.url).pathname.endsWith('/merges')) return Response.json({ merges: [], limit: 100, offset: 0 }); + if (new URL(request.url).pathname.endsWith('/network')) return Response.json({ + root_person_id: 7, depth: 1, truncated: false, + nodes: [{ id: 'person:7', kind: 'person', entity_id: 7, label: 'Synthetic Person', hop: 0 }], edges: [] + }); + return Response.json({ files: [], total_count: 0, cache_revision: 'synthetic', search_provenance: {} }); + })); + const entityController = new DirectoryEntityController(client, 7); + render(PersonDetail, { client, personID: 7, bundle: { etags: {}, errors: {} }, entityController }); + + const overview = screen.getByRole('tab', { name: 'Overview' }); + const organizations = screen.getByRole('tab', { name: 'Organizations' }); + const relationships = screen.getByRole('tab', { name: 'Relationships' }); + const network = screen.getByRole('tab', { name: 'Network' }); + const media = screen.getByRole('tab', { name: 'Media & Files' }); + expect(overview.getAttribute('tabindex')).toBe('0'); + expect(screen.getAllByRole('tab')).toHaveLength(5); + expect(await screen.findByRole('heading', { name: 'Merge history' })).toBeDefined(); + expect(organizations.getAttribute('tabindex')).toBe('-1'); + expect(relationships.getAttribute('tabindex')).toBe('-1'); + expect(network.getAttribute('tabindex')).toBe('-1'); + expect(media.getAttribute('tabindex')).toBe('-1'); + expect(overview.getAttribute('aria-controls')).toBe(screen.getByRole('tabpanel', { name: 'Overview' }).id); + + overview.focus(); + await fireEvent.keyDown(overview, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(organizations); + expect(organizations.getAttribute('aria-selected')).toBe('true'); + await fireEvent.keyDown(organizations, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(relationships); + await waitFor(() => expect(relationships.getAttribute('aria-controls')).toBe(screen.getByRole('tabpanel', { name: 'Relationships' }).id)); + await fireEvent.keyDown(relationships, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(network); + await waitFor(() => expect(network.getAttribute('aria-controls')).toBe(screen.getByRole('tabpanel', { name: 'Network' }).id)); + await fireEvent.keyDown(network, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(media); + expect(media.getAttribute('aria-controls')).toBe(screen.getByRole('tabpanel', { name: 'Media & Files' }).id); + + await fireEvent.keyDown(media, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(overview); + await fireEvent.keyDown(overview, { key: 'ArrowLeft' }); + expect(document.activeElement).toBe(media); + + await fireEvent.keyDown(media, { key: 'Home' }); + expect(document.activeElement).toBe(overview); + expect(overview.getAttribute('aria-selected')).toBe('true'); + + await fireEvent.keyDown(overview, { key: 'End' }); + expect(document.activeElement).toBe(media); + }); + + it('selects durable people and opens the exact organization editor from network actions', async () => { + const onOpenPerson = vi.fn(); + const client = createAPIClient(vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = new URL(request.url).pathname; + const maintenance = profileMaintenanceResponse(request); + if (maintenance) return maintenance; + if (path.endsWith('/network')) return Response.json({ + root_person_id: 7, depth: 1, truncated: false, + nodes: [ + { id: 'person:7', kind: 'person', entity_id: 7, label: 'Selected Person', hop: 0 }, + { id: 'person:8', kind: 'person', entity_id: 8, label: 'Curated Peer', hop: 1 }, + { id: 'organization:21', kind: 'organization', entity_id: 21, label: 'Shared Organization', hop: 1 } + ], + edges: [ + { id: 'relationship:31', kind: 'relationship', source_node_id: 'person:7', target_node_id: 'person:8', label: 'works with' }, + { id: 'employment:41', kind: 'employment', source_node_id: 'person:8', target_node_id: 'organization:21', label: 'Engineer' } + ] + }); + if (path === '/api/v1/organizations/21') return new Response(JSON.stringify({ + organization: { id: 21, revision: 2, name: 'Shared Organization', kind: 'company', created_at: '2026-08-01T00:00:00Z', updated_at: '2026-08-01T00:00:00Z' }, + names: [], contact_points: [], addresses: [], categories: [], identifiers: [], media: [] + }), { headers: { ETag: '"organization-21-r2"' } }); + throw new Error(`unexpected ${request.method} ${path}`); + })); + const entityController = new DirectoryEntityController(client, 7); + render(PersonDetail, { + client, personID: 7, bundle: { etags: {}, errors: {} }, entityController, onOpenPerson + }); + + await fireEvent.click(screen.getByRole('tab', { name: 'Network' })); + await fireEvent.click((await screen.findAllByRole('button', { name: 'Open person Curated Peer' }))[0]!); + expect(onOpenPerson).toHaveBeenCalledWith(8); + + await fireEvent.click(screen.getByRole('tab', { name: 'Network' })); + await fireEvent.click(await screen.findByRole('button', { name: 'Open organization Shared Organization' })); + await waitFor(() => expect(screen.getByRole('tab', { name: 'Organizations' }).getAttribute('aria-selected')).toBe('true')); + expect(await screen.findByRole('dialog', { name: 'Edit Shared Organization' })).toBeDefined(); + expect(entityController.organizationETags.get(21)).toBe('"organization-21-r2"'); + }); + + it('mounts one compact profile-maintenance card before CardDAV without adding a tab', async () => { + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = new URL(request.url).pathname; + if (path === '/api/v1/people/7/tracking') { + return Response.json({ person_id: 7, tracked: false, tracked_at: null }); + } + if (path === '/api/v1/person-fact-targets') return Response.json({ + version: 'v1', fingerprint: 'not-rendered', targets: [] + }); + if (path === '/api/v1/carddav/publications/7') { + return Response.json({ error: 'carddav_unavailable', message: 'not rendered' }, { status: 503 }); + } + if (path === '/api/v1/people/7/merges') return Response.json({ merges: [], limit: 100, offset: 0 }); + throw new Error(`Unexpected ${request.method} ${path}`); + }); + render(PersonDetail, { + client: createAPIClient(fetchFn), personID: 7, + bundle: { + person: { + id: 7, revision: 1, display_name: 'Synthetic Person', participant_ids: [], vcard_uid: '', + created_at: '2026-08-29T00:00:00Z', updated_at: '2026-08-29T00:00:00Z' + }, + etags: {}, errors: {} + } + }); + + const maintenance = await screen.findByRole('heading', { name: 'Profile maintenance' }); + const publication = await screen.findByRole('heading', { name: 'CardDAV publication' }); + expect(maintenance.compareDocumentPosition(publication) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getAllByRole('tab')).toHaveLength(5); + expect(screen.queryByRole('tab', { name: /maintenance/i })).toBeNull(); + }); + + it('mounts compact CardDAV publication in Overview and threads conflict and status callbacks', async () => { + const onOpenCardDAVConflict = vi.fn(); + const onAnnounce = vi.fn(); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = new URL(request.url).pathname; + const maintenance = profileMaintenanceResponse(request); + if (maintenance) return maintenance; + if (path === '/api/v1/carddav/publications/7') return Response.json({ + person_id: 7, + state: 'conflict', + desired: true, + conflict_id: 41, + address_book: { id: 5, name: 'Synthetic contacts' } + }); + if (path === '/api/v1/people/7/merges') return Response.json({ merges: [], limit: 100, offset: 0 }); + throw new Error(`Unexpected ${request.method} ${path}`); + }); + render(PersonDetail, { + client: createAPIClient(fetchFn), + personID: 7, + bundle: { + person: { + id: 7, revision: 1, display_name: 'Synthetic Person', participant_ids: [], vcard_uid: '', + created_at: '2026-08-28T10:00:00Z', updated_at: '2026-08-28T10:00:00Z' + }, + etags: {}, + errors: {} + }, + onOpenCardDAVConflict, + onAnnounce + }); + + expect(await screen.findByRole('heading', { name: 'CardDAV publication' })).toBeDefined(); + expect(screen.getAllByRole('tab')).toHaveLength(5); + expect(screen.queryByRole('tab', { name: /CardDAV/i })).toBeNull(); + await fireEvent.click(await screen.findByRole('button', { name: 'Review CardDAV conflict 41' })); + expect(onOpenCardDAVConflict).toHaveBeenCalledWith(41); + expect(onAnnounce).not.toHaveBeenCalled(); + }); + + it('renders unconfigured CardDAV publication as an optional Settings handoff without an operational alert', async () => { + const onOpenCardDAVSettings = vi.fn(); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = new URL(request.url).pathname; + const maintenance = profileMaintenanceResponse(request); + if (maintenance) return maintenance; + if (path === '/api/v1/carddav/publications/7') { + return Response.json({ + error: 'carddav_unavailable', + message: 'synthetic private setup detail must not render' + }, { status: 503 }); + } + if (path === '/api/v1/people/7/merges') return Response.json({ merges: [], limit: 100, offset: 0 }); + throw new Error(`Unexpected ${request.method} ${path}`); + }); + render(PersonDetail, { + client: createAPIClient(fetchFn), + personID: 7, + bundle: { etags: {}, errors: {} }, + onOpenCardDAVSettings + }); + + expect(await screen.findByText('CardDAV publication is unavailable. Configure or repair it in CardDAV settings.')).toBeDefined(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Retry CardDAV publication' })).toBeNull(); + expect(document.body.textContent).not.toContain('synthetic private setup detail'); + await fireEvent.click(screen.getByRole('button', { name: 'Open CardDAV settings' })); + expect(onOpenCardDAVSettings).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/src/lib/components/directory/PersonMergeHistory.svelte b/web/src/lib/components/directory/PersonMergeHistory.svelte new file mode 100644 index 000000000..4e486034f --- /dev/null +++ b/web/src/lib/components/directory/PersonMergeHistory.svelte @@ -0,0 +1,180 @@ + + +
+
+
+

Merge history

+

Inspect durable merge provenance and explicitly restore eligible absorbed lineage.

+
+
+ + {#if controller.historyLoading && controller.history.length === 0} +

Loading merge history…

+ {:else if controller.historyError && controller.history.length === 0} + + {:else} + {#if controller.historyError} + + {/if} + {#if controller.history.length === 0} +

No merge history on this page.

+ {:else} +
+ + + + {#each controller.history as item (item.merge.id)} + + + + + + + + + {/each} + +
MergeCreatedSurvivorAbsorbedCurrentParticipantsRowsRow actionsReviewSplitsAction
{item.merge.id}{item.merge.created_at}Person {item.merge.survivor_person_id}Person {item.merge.absorbed_person_id}{item.merge.current_person_id ? `Person ${item.merge.current_person_id}` : 'None'}{item.participant_count}{item.row_count}{rowActionCounts(item.row_action_counts)}{item.pending_candidate_count} pending{item.split_count}
+
+ {/if} + + {/if} + + {#if controller.detailLoading} +

Loading merge detail…

+ {:else if controller.detailError && !controller.detail} + + {:else if controller.detail} +
+
+

Merge {controller.detail.merge.id} detail

Recorded by {controller.detail.merge.actor} at {controller.detail.merge.created_at}.

+ {#if controller.canOfferSplit}
+ {#if controller.detailError}{/if} + +
+ + {#each controller.detail.participants ?? [] as participant}{/each} +
ParticipantOriginDisposition
{participant.participant_id}{participant.origin_side}{disposition(participant.split_id)}
+
+
+ + {#each controller.detail.rows ?? [] as row}{/each} +
TableActionOriginProvenanceParticipantDisposition
{row.table_name}{row.action}{row.origin_side}{row.provenance_kind}{row.participant_id ?? 'None'}{disposition(row.split_id)}
+
+
+ + {#each controller.detail.splits ?? [] as split}{/each} +
SplitSourceCreated personRevision changeRestorationActorCreated
{split.id}Person {split.source_person_id}Person {split.new_person_id}{split.source_revision_before} → {split.source_revision_after}{split.exact_reversal ? 'Exact' : 'Partial'}{split.actor}{split.created_at}
+
+
+ + {#each controller.detail.review_candidates ?? [] as candidate}{/each} +
CandidatePersonDefinitionSurvivor valueAbsorbed valueResolutionStateReviewedReviewerCreated
{candidate.id}{candidate.person_id}{candidate.definition_id}{candidate.survivor_value_id}{candidate.absorbed_value_id}{candidate.resolution_value_id ?? 'None'}{candidate.state}{candidate.reviewed_at ?? 'Not reviewed'}{candidate.reviewed_by ?? 'None'}{candidate.created_at}
+
+ + {#if controller.snapshot} +

Verified snapshot version {controller.snapshot.version}. SHA-256 {controller.snapshot.sha256}.

+ + +
{snapshotText(controller.snapshot.snapshot)}
+ {:else if controller.snapshotLoading}

Loading verified snapshot…

+ {:else} + {#if controller.snapshotError}{/if} +
+ {/if} +
+ +{#if controller.splitOpen} + void closeSplit()} /> +{/if} + + diff --git a/web/src/lib/components/directory/PersonMergeHistory.test.ts b/web/src/lib/components/directory/PersonMergeHistory.test.ts new file mode 100644 index 000000000..c8c0d0d47 --- /dev/null +++ b/web/src/lib/components/directory/PersonMergeHistory.test.ts @@ -0,0 +1,161 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../../api/client'; +import type { components } from '../../api/generated/schema'; +import PersonMergeHistory from './PersonMergeHistory.svelte'; + +type MergeDetail = components['schemas']['PersonMergeDetail']; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function detail(currentPersonID: number | null = 12): MergeDetail { + return { + merge: { + id: 41, + survivor_person_id: 7, + absorbed_person_id: 9, + current_person_id: currentPersonID ?? undefined, + survivor_vcard_uid: 'synthetic-7', + absorbed_vcard_uid: 'synthetic-9', + survivor_revision_before: 3, + absorbed_revision_before: 2, + survivor_revision_after: 4, + actor: 'web', + snapshot_version: 1, + snapshot_sha256: 'synthetic-digest', + created_at: '2026-08-03T00:00:00Z' + }, + participants: [ + { merge_id: 41, participant_id: 701, origin_side: 'absorbed' }, + { merge_id: 41, participant_id: 702, origin_side: 'survivor', split_id: 55 } + ], + rows: [{ + merge_id: 41, + table_name: 'person_names', + original_row_key: 'opaque-row-key-must-not-appear', + snapshot_path: 'private/snapshot/path-must-not-appear', + action: 'restored', + origin_side: 'absorbed', + provenance_kind: 'copied', + participant_id: 701 + }], + splits: [{ + id: 55, merge_id: 41, source_person_id: 12, new_person_id: 19, + new_person_uid: 'synthetic-19', source_revision_before: 4, source_revision_after: 5, + exact_reversal: false, actor: 'web', created_at: '2026-08-04T00:00:00Z' + }], + review_candidates: [{ + id: 61, merge_id: 41, person_id: 12, definition_id: 3, + survivor_value_id: 81, absorbed_value_id: 82, resolution_value_id: 83, + state: 'resolved', reviewed_at: '2026-08-05T00:00:00Z', reviewed_by: 'reviewer', + created_at: '2026-08-03T00:00:00Z' + }] + }; +} + +function summary(mergeDetail: MergeDetail) { + return { + merge: mergeDetail.merge, + participant_count: 2, + pending_candidate_count: 0, + row_action_counts: { restored: 1 }, + row_count: 1, + split_count: 1 + }; +} + +function renderHistory(currentPersonID: number | null = 12) { + const mergeDetail = detail(currentPersonID); + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const path = new URL(request.url).pathname; + if (path === '/api/v1/people/7/merges') { + return Response.json({ merges: [summary(mergeDetail)], limit: 100, offset: 0 }); + } + if (path === '/api/v1/person-merges/41/snapshot') { + return Response.json({ version: 1, sha256: 'synthetic-digest', snapshot: { explicitly_revealed: 'synthetic content' } }); + } + return Response.json(mergeDetail); + }); + render(PersonMergeHistory, { client: createAPIClient(fetchFn), personID: 7 }); + return { requests }; +} + +describe('PersonMergeHistory', () => { + it('renders semantic safe history/detail tables without eagerly fetching or exposing provenance internals', async () => { + const { requests } = renderHistory(); + + const history = await screen.findByRole('table', { name: 'Person merge history' }); + expect(within(history).getByRole('columnheader', { name: 'Merge' })).toBeDefined(); + await fireEvent.click(within(history).getByRole('button', { name: 'Inspect merge 41' })); + + const participants = await screen.findByRole('table', { name: 'Merge participants' }); + expect(within(participants).getByText('absorbed')).toBeDefined(); + expect(within(participants).getByText('Not split')).toBeDefined(); + const rows = screen.getByRole('table', { name: 'Merge row dispositions' }); + for (const heading of ['Table', 'Action', 'Origin', 'Provenance', 'Participant', 'Disposition']) { + expect(within(rows).getByRole('columnheader', { name: heading })).toBeDefined(); + } + expect(screen.getByRole('table', { name: 'Prior splits' })).toBeDefined(); + expect(screen.getByRole('table', { name: 'Merge review candidates' })).toBeDefined(); + expect(document.body.textContent).not.toContain('opaque-row-key-must-not-appear'); + expect(document.body.textContent).not.toContain('private/snapshot/path-must-not-appear'); + expect(requests.map((request) => new URL(request.url).pathname)).not.toContain('/api/v1/person-merges/41/snapshot'); + }); + + it('reveals the verified opaque snapshot only after the explicit action', async () => { + const { requests } = renderHistory(); + await fireEvent.click(await screen.findByRole('button', { name: 'Inspect merge 41' })); + + expect(screen.queryByText(/explicitly_revealed/)).toBeNull(); + await fireEvent.click(await screen.findByRole('button', { name: 'View verified snapshot' })); + + const region = await screen.findByRole('region', { name: 'Verified merge snapshot content' }); + expect(region.textContent).toContain('explicitly_revealed'); + expect(screen.getByText(/SHA-256 synthetic-digest/)).toBeDefined(); + expect(requests.filter((request) => new URL(request.url).pathname.endsWith('/snapshot'))).toHaveLength(1); + }); + + it('does not offer split when detail has no current person', async () => { + renderHistory(null); + await fireEvent.click(await screen.findByRole('button', { name: 'Inspect merge 41' })); + + await screen.findByRole('table', { name: 'Merge participants' }); + expect(screen.queryByRole('button', { name: 'Split merged profile' })).toBeNull(); + expect(screen.getByText(/No current source profile is recorded/)).toBeDefined(); + }); + + it('retains a connected focus target when an idle split dialog closes', async () => { + const mergeDetail = detail(); + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = new URL(request.url).pathname; + if (path.endsWith('/merges')) return Response.json({ merges: [summary(mergeDetail)], limit: 100, offset: 0 }); + if (path === '/api/v1/people/12') { + return Response.json({ + id: 12, revision: 4, display_name: 'Synthetic Source', participant_ids: [701], + created_at: '2026-08-01T00:00:00Z', updated_at: '2026-08-02T00:00:00Z', vcard_uid: 'synthetic-12' + }, { headers: { ETag: '"person-12-r4"' } }); + } + return Response.json(mergeDetail); + }); + render(PersonMergeHistory, { client: createAPIClient(fetchFn), personID: 7 }); + await fireEvent.click(await screen.findByRole('button', { name: 'Inspect merge 41' })); + const trigger = await screen.findByRole('button', { name: 'Split merged profile' }); + await fireEvent.click(trigger); + await screen.findByRole('dialog', { name: 'Split merged profile' }); + const cancel = screen.getByRole('button', { name: 'Cancel' }); + await waitFor(() => expect(cancel).toHaveProperty('disabled', false)); + + await fireEvent.click(cancel); + + await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(trigger.isConnected).toBe(true); + }); +}); diff --git a/web/src/lib/components/directory/PersonNetwork.svelte b/web/src/lib/components/directory/PersonNetwork.svelte new file mode 100644 index 000000000..78f7bb23e --- /dev/null +++ b/web/src/lib/components/directory/PersonNetwork.svelte @@ -0,0 +1,177 @@ + + +
+
+
+

Network

+

Curated typed relationships and employments only. Messages and co-occurrence never create connections.

+
+
+ + { includeEnded = checked; }} /> +
+
+ +
+ {#if controller.network} + + {/if} + + {#if controller.networkLoading}

Loading network…

{/if} +
+ + {#if controller.errors.network} + + {/if} + + {#if controller.network?.truncated} +

This is a bounded prefix with at most 250 nodes and 500 connections at depth {controller.network.depth}.

+ {/if} + +
    + {#if edges.length === 0 && rootNode} +
  • +

    Hop 0

    +
  • + {:else} + {#each edgeGroups as [hop, hopEdges] (hop)} +
  • +

    Hop {hop}

    +
      + {#each hopEdges as edge (edge.id)} + {@const source = nodeByID.get(edge.source_node_id)} + {@const target = nodeByID.get(edge.target_node_id)} + {#if source && target} +
    • +
    • + {/if} + {/each} +
    +
  • + {/each} + {/if} +
+ + {#if controller.network && edges.length === 0}

No curated connections at this depth.

{/if} +
+ + diff --git a/web/src/lib/components/directory/PersonNetwork.test.ts b/web/src/lib/components/directory/PersonNetwork.test.ts new file mode 100644 index 000000000..92cf0f446 --- /dev/null +++ b/web/src/lib/components/directory/PersonNetwork.test.ts @@ -0,0 +1,196 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../../api/client'; +import { DirectoryEntityController } from '../../directory/entity-controller.svelte'; +import type { PersonNetwork as PersonNetworkProjection } from '../../directory/models'; +import { chooseSelectOption } from '../../../test/kit-ui'; +import PersonNetwork from './PersonNetwork.svelte'; + +function requestOf(input: RequestInfo | URL): Request { + return input instanceof Request ? input : new Request(input); +} + +function rootOnly(depth = 1): PersonNetworkProjection { + return { + root_person_id: 7, + depth, + truncated: false, + nodes: [{ id: 'person:7', kind: 'person', entity_id: 7, label: 'Selected Person', hop: 0 }], + edges: [] + }; +} + +function connected(depth = 2, truncated = false): PersonNetworkProjection { + return { + root_person_id: 7, + depth, + truncated, + nodes: [ + { id: 'person:7', kind: 'person', entity_id: 7, label: 'Selected Person', hop: 0 }, + { id: 'person:8', kind: 'person', entity_id: 8, label: 'Curated Peer', hop: 1 }, + { id: 'organization:21', kind: 'organization', entity_id: 21, label: 'Shared Organization', hop: 1 }, + { id: 'person:9', kind: 'person', entity_id: 9, label: 'Second Hop Person', hop: 2 } + ], + edges: [ + { id: 'relationship:31', kind: 'relationship', source_node_id: 'person:7', target_node_id: 'person:8', relationship_type_slug: 'colleague', label: 'works with' }, + { id: 'employment:41', kind: 'employment', source_node_id: 'person:8', target_node_id: 'organization:21', label: 'Engineer' }, + { id: 'relationship:32', kind: 'relationship', source_node_id: 'person:8', target_node_id: 'person:9', relationship_type_slug: 'mentor', label: 'mentors' } + ] + }; +} + +function depthThree(): PersonNetworkProjection { + return { + root_person_id: 7, + depth: 3, + truncated: false, + nodes: [ + { id: 'person:7', kind: 'person', entity_id: 7, label: 'Selected Person', hop: 0 }, + { id: 'person:10', kind: 'person', entity_id: 10, label: 'Depth Three Person', hop: 3 } + ], + edges: [ + { id: 'relationship:33', kind: 'relationship', source_node_id: 'person:7', target_node_id: 'person:10', relationship_type_slug: 'knows', label: 'knows' } + ] + }; +} + +function deferredResponse() { + let resolve!: (response: Response) => void; + const promise = new Promise((next) => { resolve = next; }); + return { promise, resolve }; +} + +describe('PersonNetwork', () => { + it('sizes the SVG for a hop-three node, its circle, and a bounded label lane', async () => { + const client = createAPIClient(vi.fn(async (input) => { + const depth = Number(new URL(requestOf(input).url).searchParams.get('depth')); + return Response.json(depth === 3 ? depthThree() : rootOnly(depth)); + })); + const controller = new DirectoryEntityController(client, 7); + + render(PersonNetwork, { controller, onOpenPerson: vi.fn(), onOpenOrganization: vi.fn() }); + const initialSVG = await waitFor(() => { + const svg = document.querySelector('.projection > svg'); + expect(svg).not.toBeNull(); + return svg!; + }); + expect(initialSVG.viewBox.baseVal.width).toBe(650); + + await chooseSelectOption(screen.getByRole('combobox', { name: /^Network depth:/ }), '3 hops'); + await screen.findByRole('button', { name: 'Open person Depth Three Person' }); + const svg = document.querySelector('.projection > svg')!; + const group = [...svg.querySelectorAll('g')].find((item) => item.textContent?.includes('Depth Three Person'))!; + const originX = Number(group.getAttribute('transform')?.match(/translate\((\d+)/)?.[1]); + const radius = Number(group.querySelector('circle')?.getAttribute('r')); + const labelX = Number(group.querySelector('text')?.getAttribute('x')); + const width = svg.viewBox.baseVal.width; + + expect(width).toBeGreaterThanOrEqual(originX + radius); + expect(width).toBeGreaterThanOrEqual(originX + labelX + 160); + expect(Number(svg.getAttribute('width'))).toBe(width); + }); + + it('keeps a root-only projection in the semantic list while hiding the progressive SVG', async () => { + const client = createAPIClient(vi.fn(async () => Response.json(rootOnly()))); + const controller = new DirectoryEntityController(client, 7); + + render(PersonNetwork, { controller, onOpenPerson: vi.fn(), onOpenOrganization: vi.fn() }); + + const list = await screen.findByRole('list', { name: 'Directory network connections' }); + await waitFor(() => expect(list.textContent).toContain('Selected Person')); + expect(screen.getByText('No curated connections at this depth.')).toBeDefined(); + expect(document.querySelector('.projection > svg')?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('groups every typed relationship and employment by hop and makes both entity kinds actionable', async () => { + const client = createAPIClient(vi.fn(async () => Response.json(connected()))); + const controller = new DirectoryEntityController(client, 7); + const onOpenPerson = vi.fn(); + const onOpenOrganization = vi.fn(); + + render(PersonNetwork, { controller, onOpenPerson, onOpenOrganization }); + + const list = await screen.findByRole('list', { name: 'Directory network connections' }); + await waitFor(() => expect(list.textContent).toContain('Hop 1')); + const listText = list.textContent?.replace(/\s+/g, ' ') ?? ''; + expect(listText).toContain('Hop 2'); + expect(listText).toContain('Selected Person works with Curated Peer'); + expect(listText).toContain('Curated Peer Engineer Shared Organization'); + expect(listText).toContain('Curated Peer mentors Second Hop Person'); + + await fireEvent.click(screen.getAllByRole('button', { name: 'Open person Curated Peer' })[0]!); + await fireEvent.click(screen.getByRole('button', { name: 'Open organization Shared Organization' })); + expect(onOpenPerson).toHaveBeenCalledWith(8); + expect(onOpenOrganization).toHaveBeenCalledWith(21); + }); + + it('sends exact depth and ended-data queries and retains the last projection through loading, error, and retry', async () => { + const depthTwo = deferredResponse(); + const requests: Request[] = []; + let depthTwoAttempts = 0; + const client = createAPIClient(vi.fn(async (input) => { + const request = requestOf(input); + requests.push(request); + const url = new URL(request.url); + const depth = Number(url.searchParams.get('depth')); + const includeEnded = url.searchParams.get('include_ended') === 'true'; + if (depth === 1) return Response.json(connected(1)); + if (depth === 2 && !includeEnded) return depthTwo.promise; + if (depth === 2 && includeEnded) { + depthTwoAttempts += 1; + return depthTwoAttempts === 1 + ? Response.json({ error: 'unavailable', message: 'Synthetic network unavailable.' }, { status: 503 }) + : Response.json(connected(2)); + } + return Response.json(connected(depth)); + })); + const controller = new DirectoryEntityController(client, 7); + + render(PersonNetwork, { controller, onOpenPerson: vi.fn(), onOpenOrganization: vi.fn() }); + expect((await screen.findAllByText('Curated Peer')).length).toBeGreaterThan(0); + + await chooseSelectOption(screen.getByRole('combobox', { name: /^Network depth:/ }), '2 hops'); + expect((await screen.findByRole('status')).textContent).toContain('Loading network'); + expect(screen.getAllByText('Curated Peer').length).toBeGreaterThan(0); + depthTwo.resolve(Response.json(connected(2))); + await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); + + await fireEvent.click(screen.getByRole('checkbox', { name: 'Include ended connections' })); + expect((await screen.findByRole('alert')).textContent).toContain('Synthetic network unavailable.'); + expect(screen.getAllByText('Curated Peer').length).toBeGreaterThan(0); + await fireEvent.click(screen.getByRole('button', { name: 'Retry network' })); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + + const queries = requests.map((request) => new URL(request.url).searchParams); + expect(queries.some((query) => query.get('depth') === '1' && query.get('include_ended') === 'false')).toBe(true); + expect(queries.some((query) => query.get('depth') === '2' && query.get('include_ended') === 'false')).toBe(true); + expect(queries.filter((query) => query.get('depth') === '2' && query.get('include_ended') === 'true')).toHaveLength(2); + + await chooseSelectOption(screen.getByRole('combobox', { name: /^Network depth:/ }), '3 hops'); + await waitFor(() => expect(requests.some((request) => new URL(request.url).searchParams.get('depth') === '3')).toBe(true)); + }); + + it('discards a stale depth response and describes truncation as an at-most bounded prefix', async () => { + const depthTwo = deferredResponse(); + const requests: Request[] = []; + const client = createAPIClient(vi.fn(async (input) => { + const request = requestOf(input); + requests.push(request); + const depth = Number(new URL(request.url).searchParams.get('depth')); + if (depth === 2) return depthTwo.promise; + return Response.json(depth === 3 ? connected(3, true) : rootOnly(1)); + })); + const controller = new DirectoryEntityController(client, 7); + + render(PersonNetwork, { controller, onOpenPerson: vi.fn(), onOpenOrganization: vi.fn() }); + await screen.findByText('No curated connections at this depth.'); + await chooseSelectOption(screen.getByRole('combobox', { name: /^Network depth:/ }), '2 hops'); + await waitFor(() => expect(requests.some((request) => new URL(request.url).searchParams.get('depth') === '2')).toBe(true)); + await chooseSelectOption(screen.getByRole('combobox', { name: /^Network depth:/ }), '3 hops'); + expect((await screen.findAllByText('Curated Peer')).length).toBeGreaterThan(0); + depthTwo.resolve(Response.json(connected(2))); + await waitFor(() => expect(screen.getByText(/bounded prefix/).textContent).toContain('at most 250 nodes and 500 connections at depth 3')); + expect(screen.getByRole('list', { name: 'Directory network connections' }).textContent).toContain('Second Hop Person'); + }); +}); diff --git a/web/src/lib/components/directory/PersonRelationshipEditor.svelte b/web/src/lib/components/directory/PersonRelationshipEditor.svelte new file mode 100644 index 000000000..b8070b29f --- /dev/null +++ b/web/src/lib/components/directory/PersonRelationshipEditor.svelte @@ -0,0 +1,282 @@ + + + +
{ event.preventDefault(); void submit(); }}> + {#if initialRelationship} +

{initialView?.counterpart_display_name?.trim() || initialView?.counterpart_vcard_uid || `Person ${initialView?.counterpart_person_id}`} · {initialView?.counterpart_label}

+

Type: {initialRelationship.type_slug}. End date and notes are editable; edge direction and type stay fixed.

+ {:else} + + + + + {/if} + + + {#if message} +
+

{message}

+ {#if conflictCurrent} +

Current record: {partialDate(conflictCurrent.end_date) || 'no end date'} · {conflictCurrent.notes || 'no notes'}.

+ {/if} +
+ {/if} + {#if controller.createBlocked.relationships || committed} +
+ {/each} + + {/if} + + +
+

Conflict comparison

+

Only display name, email addresses, and phone numbers are shown. Your choice applies to the whole card.

+ + {#if controller.detailLoading && !controller.selectedDetail} +

Loading conflict details…

+ {/if} + {#if controller.detailError} + + {/if} + {#if controller.resolutionError && !controller.resolutionUnknown} + + {/if} + {#if controller.resolutionUnknown} + + {/if} + + {#if controller.selectedDetail} + {@const selected = controller.selectedDetail} +
+ {#each comparisonCards as card (card.label)} + +
+

{card.label}

+ {#if card.summary.state === 'present'} +

Present

+
+
Display name
{card.summary.display_name || 'No display name'}
+
+
Email addresses
+
+ {#if card.summary.emails.length === 0}No email addresses + {:else}
    {#each card.summary.emails as email}
  • {email}
  • {/each}
{/if} +
+
+
+
Phone numbers
+
+ {#if card.summary.phones.length === 0}No phone numbers + {:else}
    {#each card.summary.phones as phone}
  • {phone}
  • {/each}
{/if} +
+
+
+ {#if card.summary.truncated}

Additional name, email, or phone values are not shown.

{/if} + {:else if card.summary.state === 'deleted'} +

Deleted. This side is a deletion tombstone.

+ {:else} +

Unavailable. No safe comparison summary is available.

+ {/if} +
+
+ {/each} +
+ + {#if selected.status === 'resolved'} +

Resolved{selected.resolution ? ` by keeping the ${resolutionLabel(selected.resolution)} card.` : '.'}

+ {:else if !controller.resolutionUnknown} +
+ {#each selected.allowed_resolutions as choice (choice)} +
+ {/if} + {:else if !controller.detailLoading && !controller.detailError} +

Select a conflict to inspect its safe comparison summary.

+ {/if} +
+ + {/if} + + + {#if controller.announcement} +

{controller.announcement}

+ {/if} + + +{#if activeChoice && controller.selectedDetail} +
+ void closeDecision()} + /> +
+{/if} + + diff --git a/web/src/lib/components/settings/CardDAVConflicts.test.ts b/web/src/lib/components/settings/CardDAVConflicts.test.ts new file mode 100644 index 000000000..cef73b1af --- /dev/null +++ b/web/src/lib/components/settings/CardDAVConflicts.test.ts @@ -0,0 +1,551 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../../api/client'; +import { CardDAVConflictsController } from '../../carddav/conflicts-controller.svelte'; +import CardDAVConflicts from './CardDAVConflicts.svelte'; +import CardDAVSettingsWorkspace from './CardDAVSettingsWorkspace.svelte'; + +const forbidden = { + raw_vcard: 'BEGIN:VCARD\nFN:FORBIDDEN-VCARD\nEND:VCARD', + url: 'https://forbidden-url.example.test/dav', + href: '/forbidden-href/contact.vcf', + etag: 'forbidden-etag', + hash: 'forbidden-hash', + uid: 'forbidden-uid', + header: 'Authorization: forbidden-header', + credential: 'forbidden-credential' +}; + +function listItem(id: number, overrides: Record = {}) { + return { + id, + address_book: { id: 7, name: 'Synthetic contacts', ...forbidden }, + status: 'unresolved', + local_state: 'present', + remote_state: 'deleted', + allowed_resolutions: ['keep_local', 'keep_remote'], + updated_at: '2026-08-28T10:00:00Z', + ...forbidden, + ...overrides + }; +} + +function contactSummary(state: 'present' | 'deleted' | 'unavailable', overrides: Record = {}) { + return { state, emails: [], phones: [], ...forbidden, ...overrides }; +} + +function conflictDetail(id: number, overrides: Record = {}) { + return { + id, + address_book: { id: 7, name: 'Synthetic contacts', ...forbidden }, + status: 'unresolved', + base: contactSummary('present', { + display_name: 'Synthetic Contact', + emails: ['contact@example.test'], + phones: ['+1 555 0100'], + truncated: true + }), + local: contactSummary('deleted', { display_name: 'MUST-NOT-RENDER-LOCAL' }), + remote: contactSummary('unavailable', { emails: ['must-not-render@example.test'] }), + allowed_resolutions: ['keep_remote'], + created_at: '2026-08-27T10:00:00Z', + updated_at: '2026-08-28T10:00:00Z', + ...forbidden, + ...overrides + }; +} + +function requestOf(input: RequestInfo | URL): Request { + return input instanceof Request ? input : new Request(input); +} + +function deferredResponse() { + let resolve!: (response: Response) => void; + const promise = new Promise((settle) => { resolve = settle; }); + return { promise, resolve }; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('CardDAVConflicts', () => { + it('renders only safe comparison summaries with explicit present, deleted, unavailable, and truncated text', async () => { + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path.endsWith('/41')) return Response.json(conflictDetail(41)); + return Response.json({ conflicts: [listItem(41)] }); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + const rendered = render(CardDAVConflicts, { controller }); + + await fireEvent.click(screen.getByRole('button', { name: 'Review conflict 41 in Synthetic contacts' })); + const detail = await screen.findByRole('region', { name: 'CardDAV conflict 41 comparison' }); + + expect(within(detail).getByRole('heading', { name: 'Base' })).toBeDefined(); + expect(within(detail).getByRole('heading', { name: 'Local' })).toBeDefined(); + expect(within(detail).getByRole('heading', { name: 'Remote' })).toBeDefined(); + expect(within(detail).getByText('Present')).toBeDefined(); + expect(within(detail).getByText('Synthetic Contact')).toBeDefined(); + expect(within(detail).getByText('contact@example.test')).toBeDefined(); + expect(within(detail).getByText('+1 555 0100')).toBeDefined(); + expect(within(detail).getByText('Additional name, email, or phone values are not shown.')).toBeDefined(); + expect(within(detail).getByText('Deleted. This side is a deletion tombstone.')).toBeDefined(); + expect(within(detail).getByText('Unavailable. No safe comparison summary is available.')).toBeDefined(); + expect(screen.getByText('Only display name, email addresses, and phone numbers are shown. Your choice applies to the whole card.')).toBeDefined(); + expect(screen.getByRole('button', { name: 'Keep remote card' })).toBeDefined(); + expect(screen.queryByRole('button', { name: 'Keep local card' })).toBeNull(); + expect(rendered.container.textContent).not.toMatch(/FORBIDDEN-VCARD|MUST-NOT-RENDER|must-not-render/i); + expect(rendered.container.innerHTML).not.toMatch(/forbidden-(?:url|href|etag|hash|uid|header|credential)/i); + controller.destroy(); + }); + + it('shows honest empty safe fields and no actions for a resolved detail', async () => { + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path.endsWith('/52')) return Response.json(conflictDetail(52, { + status: 'resolved', + resolution: 'keep_local', + resolved_at: '2026-08-28T11:00:00Z', + base: contactSummary('present'), + local: contactSummary('present'), + remote: contactSummary('present'), + allowed_resolutions: [] + })); + return Response.json({ conflicts: [listItem(52, { status: 'resolved', allowed_resolutions: [] })] }); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVConflicts, { controller }); + + await fireEvent.click(screen.getByRole('button', { name: 'Review conflict 52 in Synthetic contacts' })); + await screen.findByRole('region', { name: 'CardDAV conflict 52 comparison' }); + + expect(screen.getAllByText('No display name')).toHaveLength(3); + expect(screen.getAllByText('No email addresses')).toHaveLength(3); + expect(screen.getAllByText('No phone numbers')).toHaveLength(3); + expect(screen.getByText('Resolved by keeping the local card.')).toBeDefined(); + expect(screen.queryByRole('button', { name: /Keep (?:local|remote) card/ })).toBeNull(); + controller.destroy(); + }); + + it('renders accessible loading, fixed error, GET-only retry, and empty queue states', async () => { + const first = deferredResponse(); + let listReads = 0; + const fetchFn = vi.fn(async () => { + listReads += 1; + if (listReads === 1) return first.promise; + return Response.json({ conflicts: [] }); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + const rendered = render(CardDAVConflicts, { controller }); + + expect(screen.getByLabelText('CardDAV conflict queue').getAttribute('aria-busy')).toBe('true'); + expect(screen.getByText('Loading CardDAV conflicts…')).toBeDefined(); + const load = controller.load(); + first.resolve(Response.json({ error: 'unavailable', message: forbidden.credential }, { status: 503 })); + await load; + expect((await screen.findByRole('alert')).textContent).toContain('Unable to load CardDAV conflicts.'); + expect(rendered.container.textContent).not.toContain(forbidden.credential); + + await fireEvent.click(screen.getByRole('button', { name: 'Retry CardDAV conflicts' })); + expect(await screen.findByText('No unresolved CardDAV conflicts.')).toBeDefined(); + expect(listReads).toBe(2); + controller.destroy(); + }); + + it('renders typed unavailable as optional setup without detail or mutation and recovers on load', async () => { + let configured = false; + const requests: Array<{ method: string; path: string }> = []; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + requests.push({ method: request.method, path }); + if (path === '/api/v1/carddav/conflicts') { + if (!configured) { + return Response.json({ error: 'carddav_unavailable', message: forbidden.credential }, { status: 503 }); + } + return Response.json({ conflicts: [listItem(41)] }); + } + if (path === '/api/v1/carddav/conflicts/41') return Response.json(conflictDetail(41)); + throw new Error(`Unexpected ${request.method} ${path}`); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + const rendered = render(CardDAVConflicts, { controller }); + + expect(screen.getByText('CardDAV conflict review is unavailable.')).toBeDefined(); + expect(screen.getByText('Configure or repair CardDAV in Settings before reviewing conflicts.')).toBeDefined(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Retry CardDAV conflicts' })).toBeNull(); + expect(rendered.container.textContent).not.toContain(forbidden.credential); + expect(requests).toEqual([{ method: 'GET', path: '/api/v1/carddav/conflicts' }]); + + configured = true; + await controller.load(); + await fireEvent.click(await screen.findByRole('button', { name: 'Review conflict 41 in Synthetic contacts' })); + expect(await screen.findByRole('region', { name: 'CardDAV conflict 41 comparison' })).toBeDefined(); + expect(requests).toEqual([ + { method: 'GET', path: '/api/v1/carddav/conflicts' }, + { method: 'GET', path: '/api/v1/carddav/conflicts' }, + { method: 'GET', path: '/api/v1/carddav/conflicts/41' } + ]); + controller.destroy(); + }); + + it('moves focus to the unavailable status when an in-queue request replaces the focused surface', async () => { + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path === '/api/v1/carddav/conflicts') { + return Response.json({ conflicts: [listItem(41)] }); + } + if (path === '/api/v1/carddav/conflicts/41') { + return Response.json({ error: 'carddav_unavailable' }, { status: 503 }); + } + throw new Error(`Unexpected ${path}`); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVConflicts, { controller }); + + const row = screen.getByRole('button', { name: 'Review conflict 41 in Synthetic contacts' }); + row.focus(); + expect(document.activeElement).toBe(row); + await fireEvent.click(row); + + const status = await screen.findByRole('status', { name: 'CardDAV conflict review is unavailable.' }); + await waitFor(() => expect(document.activeElement).toBe(status)); + expect(document.activeElement?.isConnected).toBe(true); + expect(screen.getAllByRole('status', { name: 'CardDAV conflict review is unavailable.' })).toHaveLength(1); + controller.destroy(); + }); + + it('does not steal focus when unavailable is the initial surface state', async () => { + const fetchFn = vi.fn(async () => ( + Response.json({ error: 'carddav_unavailable' }, { status: 503 }) + )); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVConflicts, { controller }); + + const status = screen.getByRole('status', { name: 'CardDAV conflict review is unavailable.' }); + expect(document.activeElement).not.toBe(status); + expect(screen.getAllByRole('status', { name: 'CardDAV conflict review is unavailable.' })).toHaveLength(1); + controller.destroy(); + }); + + it('does not reclaim focus after an ordinary null-target departure from the available surface', async () => { + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path === '/api/v1/carddav/conflicts') return Response.json({ conflicts: [listItem(41)] }); + if (path === '/api/v1/carddav/conflicts/41') { + return Response.json({ error: 'carddav_unavailable' }, { status: 503 }); + } + throw new Error(`Unexpected ${path}`); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVConflicts, { controller }); + + const row = screen.getByRole('button', { name: 'Review conflict 41 in Synthetic contacts' }); + row.focus(); + row.blur(); + expect(document.activeElement).not.toBe(row); + await controller.select(41); + + const status = await screen.findByRole('status', { name: 'CardDAV conflict review is unavailable.' }); + expect(document.activeElement).not.toBe(status); + controller.destroy(); + }); + + it('does not overwrite focus moved outside while an unavailable response is pending', async () => { + const detail = deferredResponse(); + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path === '/api/v1/carddav/conflicts') return Response.json({ conflicts: [listItem(41)] }); + if (path === '/api/v1/carddav/conflicts/41') return detail.promise; + throw new Error(`Unexpected ${path}`); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVConflicts, { controller }); + const outside = document.createElement('button'); + outside.textContent = 'Outside conflict surface'; + document.body.append(outside); + + const row = screen.getByRole('button', { name: 'Review conflict 41 in Synthetic contacts' }); + row.focus(); + const selection = controller.select(41); + outside.focus(); + detail.resolve(Response.json({ error: 'carddav_unavailable' }, { status: 503 })); + await selection; + + await screen.findByRole('status', { name: 'CardDAV conflict review is unavailable.' }); + await waitFor(() => expect(document.activeElement).toBe(outside)); + outside.remove(); + controller.destroy(); + }); + + it('does not overwrite connected external focus moved after unavailable replacement renders', async () => { + const fetchFn = vi.fn(async (input) => { + const path = new URL(requestOf(input).url).pathname; + if (path === '/api/v1/carddav/conflicts') return Response.json({ conflicts: [listItem(41)] }); + if (path === '/api/v1/carddav/conflicts/41') { + return Response.json({ error: 'carddav_unavailable' }, { status: 503 }); + } + throw new Error(`Unexpected ${path}`); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVConflicts, { controller }); + const outside = document.createElement('button'); + outside.textContent = 'External race target'; + document.body.append(outside); + let unavailableFocusEvents = 0; + const recordUnavailableFocus = (event: FocusEvent) => { + if (event.target instanceof Element && event.target.matches( + '[role="status"][aria-label="CardDAV conflict review is unavailable."]' + )) unavailableFocusEvents += 1; + }; + document.addEventListener('focusin', recordUnavailableFocus); + const observer = new MutationObserver(() => { + const renderedStatus = document.querySelector( + '[role="status"][aria-label="CardDAV conflict review is unavailable."]' + ); + if (renderedStatus) outside.focus(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + + const row = screen.getByRole('button', { name: 'Review conflict 41 in Synthetic contacts' }); + row.focus(); + await fireEvent.click(row); + + await screen.findByRole('status', { name: 'CardDAV conflict review is unavailable.' }); + await waitFor(() => expect(document.activeElement).toBe(outside)); + expect(unavailableFocusEvents).toBe(0); + observer.disconnect(); + document.removeEventListener('focusin', recordUnavailableFocus); + outside.remove(); + controller.destroy(); + }); + + it('moves modal-owned focus to unavailable status when ambiguous resolution reconciliation loses CardDAV', async () => { + let resolving = false; + let posts = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (request.method === 'POST') { + posts += 1; + resolving = true; + return Response.json({ error: 'carddav_write_failed' }, { status: 503 }); + } + if (resolving) return Response.json({ error: 'carddav_unavailable' }, { status: 503 }); + if (path === '/api/v1/carddav/conflicts') return Response.json({ conflicts: [listItem(41)] }); + if (path === '/api/v1/carddav/conflicts/41') return Response.json(conflictDetail(41)); + throw new Error(`Unexpected ${request.method} ${path}`); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVConflicts, { controller }); + + await fireEvent.click(screen.getByRole('button', { name: 'Review conflict 41 in Synthetic contacts' })); + await fireEvent.click(await screen.findByRole('button', { name: 'Keep remote card' })); + const dialog = screen.getByRole('dialog', { name: 'Keep remote CardDAV card' }); + const confirm = within(dialog).getByRole('button', { name: 'Keep remote card' }); + confirm.focus(); + expect(document.activeElement).toBe(confirm); + await fireEvent.click(confirm); + + const status = await screen.findByRole('status', { name: 'CardDAV conflict review is unavailable.' }); + await waitFor(() => expect(document.activeElement).toBe(status)); + expect(posts).toBe(1); + controller.destroy(); + }); + + it('clears an open decision intent across unavailable and requires a fresh choice after recovery', async () => { + let configured = true; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (path === '/api/v1/carddav/conflicts') { + return configured + ? Response.json({ conflicts: [listItem(41)] }) + : Response.json({ error: 'carddav_unavailable' }, { status: 503 }); + } + if (path === '/api/v1/carddav/conflicts/41') return Response.json(conflictDetail(41)); + throw new Error(`Unexpected ${request.method} ${path}`); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVConflicts, { controller }); + await fireEvent.click(screen.getByRole('button', { name: 'Review conflict 41 in Synthetic contacts' })); + await fireEvent.click(await screen.findByRole('button', { name: 'Keep remote card' })); + expect(screen.getByRole('dialog')).toBeDefined(); + + configured = false; + await controller.retryList(); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(screen.getByText('CardDAV conflict review is unavailable.')).toBeDefined(); + + configured = true; + await controller.load(); + await fireEvent.click(await screen.findByRole('button', { name: 'Review conflict 41 in Synthetic contacts' })); + expect(await screen.findByRole('button', { name: 'Keep remote card' })).toBeDefined(); + expect(screen.queryByRole('dialog')).toBeNull(); + + await fireEvent.click(screen.getByRole('button', { name: 'Keep remote card' })); + expect(screen.getByRole('dialog')).toBeDefined(); + controller.destroy(); + }); + + it('keeps a transport list failure actionable with GET-only retry', async () => { + let reads = 0; + const fetchFn = vi.fn(async () => { + reads += 1; + if (reads === 1) throw new TypeError('synthetic connection reset'); + return Response.json({ conflicts: [] }); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVConflicts, { controller }); + + expect(screen.getByRole('alert').textContent).toContain('Unable to load CardDAV conflicts.'); + await fireEvent.click(screen.getByRole('button', { name: 'Retry CardDAV conflicts' })); + expect(await screen.findByText('No unresolved CardDAV conflicts.')).toBeDefined(); + expect(reads).toBe(2); + controller.destroy(); + }); + + it('removes a resolved row, announces the exact receipt once, and focuses the next connected row', async () => { + let posts = 0; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (request.method === 'POST') { + posts += 1; + return Response.json({ id: 41, status: 'resolved', resolution: 'keep_remote' }); + } + if (path.endsWith('/41')) return Response.json(conflictDetail(41)); + return Response.json({ conflicts: [listItem(41), listItem(42)] }); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVConflicts, { controller }); + + await fireEvent.click(screen.getByRole('button', { name: 'Review conflict 41 in Synthetic contacts' })); + await screen.findByRole('button', { name: 'Keep remote card' }); + await fireEvent.click(screen.getByRole('button', { name: 'Keep remote card' })); + await fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Keep remote card' })); + + await waitFor(() => expect(screen.queryByRole('button', { name: 'Review conflict 41 in Synthetic contacts' })).toBeNull()); + const next = screen.getByRole('button', { name: 'Review conflict 42 in Synthetic contacts' }); + await waitFor(() => expect(document.activeElement).toBe(next)); + expect(screen.getAllByRole('status')).toHaveLength(1); + expect(screen.getByRole('status').textContent).toBe('CardDAV conflict 41 resolved by keeping the remote card.'); + expect(posts).toBe(1); + controller.destroy(); + }); + + it('focuses the stable queue heading when the resolved row has no connected neighbor', async () => { + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (request.method === 'POST') { + return Response.json({ id: 41, status: 'resolved', resolution: 'keep_remote' }); + } + if (path.endsWith('/41')) return Response.json(conflictDetail(41)); + return Response.json({ conflicts: [listItem(41)] }); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVConflicts, { controller }); + + await fireEvent.click(screen.getByRole('button', { name: 'Review conflict 41 in Synthetic contacts' })); + await fireEvent.click(await screen.findByRole('button', { name: 'Keep remote card' })); + await fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Keep remote card' })); + + const heading = screen.getByRole('heading', { name: 'Unresolved conflicts' }); + await waitFor(() => expect(document.activeElement).toBe(heading)); + expect(screen.getByText('No unresolved CardDAV conflicts.')).toBeDefined(); + controller.destroy(); + }); + + it('closes stale intent, locks actions after failed reconciliation, and retries GETs only', async () => { + let posts = 0; + let listReads = 0; + let detailReads = 0; + let failReads = true; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (request.method === 'POST') { + posts += 1; + return Response.json({ error: 'carddav_conflict_stale' }, { status: 409 }); + } + if (path === '/api/v1/carddav/conflicts') { + listReads += 1; + if (listReads > 1 && failReads) return Response.json({ error: 'unavailable' }, { status: 503 }); + return Response.json({ conflicts: [listItem(41)] }); + } + detailReads += 1; + if (detailReads > 1 && failReads) return Response.json({ error: 'unavailable' }, { status: 503 }); + return Response.json(conflictDetail(41, { allowed_resolutions: ['keep_local'] })); + }); + const controller = new CardDAVConflictsController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVConflicts, { controller }); + await fireEvent.click(screen.getByRole('button', { name: 'Review conflict 41 in Synthetic contacts' })); + await screen.findByRole('button', { name: 'Keep local card' }); + await fireEvent.click(screen.getByRole('button', { name: 'Keep local card' })); + await fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Keep local card' })); + + expect(await screen.findByText('Current CardDAV conflict state is unknown. Retry state before resolving it.')).toBeDefined(); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(screen.queryByRole('button', { name: 'Keep local card' })).toBeNull(); + expect(posts).toBe(1); + + failReads = false; + await fireEvent.click(screen.getByRole('button', { name: 'Retry conflict state' })); + expect(await screen.findByRole('button', { name: 'Keep local card' })).toBeDefined(); + expect([posts, listReads, detailReads]).toEqual([1, 3, 3]); + controller.destroy(); + }); + + it('aborts a pending resolution and prevents late focus after the Settings CardDAV context is destroyed', async () => { + const mutation = deferredResponse(); + let mutationSignal: AbortSignal | undefined; + const fetchFn = vi.fn(async (input) => { + const request = requestOf(input); + const path = new URL(request.url).pathname; + if (request.method === 'POST') { + mutationSignal = request.signal; + return mutation.promise; + } + if (path === '/api/v1/carddav/status') return Response.json({ configured: false, available: false, credential_configured: false, enabled: false, scheduled: false, schedule: '' }); + if (path === '/api/v1/carddav/books') return Response.json({ books: [] }); + if (path === '/api/v1/carddav/runs') return Response.json({ runs: [] }); + if (path.endsWith('/41')) return Response.json(conflictDetail(41)); + if (path === '/api/v1/carddav/conflicts') return Response.json({ conflicts: [listItem(41)] }); + throw new Error(`Unexpected ${request.method} ${path}`); + }); + const focus = vi.spyOn(HTMLElement.prototype, 'focus'); + const rendered = render(CardDAVSettingsWorkspace, { client: createAPIClient(fetchFn), settings: [] }); + await fireEvent.click(await screen.findByRole('button', { name: 'Review conflict 41 in Synthetic contacts' })); + await fireEvent.click(await screen.findByRole('button', { name: 'Keep remote card' })); + await fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Keep remote card' })); + await waitFor(() => expect(mutationSignal).toBeDefined()); + + rendered.unmount(); + const focusCallsAfterDestroy = focus.mock.calls.length; + expect(mutationSignal?.aborted).toBe(true); + mutation.resolve(Response.json({ id: 41, status: 'resolved', resolution: 'keep_remote' })); + await Promise.resolve(); + + expect(focus).toHaveBeenCalledTimes(focusCallsAfterDestroy); + expect(document.body.textContent).not.toContain('resolved by keeping'); + }); +}); diff --git a/web/src/lib/components/settings/CardDAVOperations.svelte b/web/src/lib/components/settings/CardDAVOperations.svelte new file mode 100644 index 000000000..16622ede5 --- /dev/null +++ b/web/src/lib/components/settings/CardDAVOperations.svelte @@ -0,0 +1,207 @@ + + + +
+ {#if controller.statusLoading}

Loading CardDAV status…

{/if} + {#if controller.statusError} + + {/if} + {#if controller.status} + {@const status = controller.status} +
+ {status.configured ? 'Configured' : 'Not configured'} + {status.available ? 'Runtime available' : 'Runtime unavailable'} + {status.credential_configured ? 'Credential ready' : 'Credential needs attention'} + {status.enabled ? 'Scheduled sync enabled' : 'Scheduled sync disabled'} + {status.scheduled ? `Scheduled · ${status.schedule || 'Schedule unavailable'}` : 'Manual sync only'} + {#if status.next_scheduled_at} + Next run + {/if} +
+ {#if status.repair_reason}{/if} + {#if status.active} +
+ Running + {counters(status.active)} + +
+ {:else if status.latest} +
+ {statusLabel(status.latest.state)} + {counters(status.latest)} + + {#if status.latest.error_message}{status.latest.error_message}{/if} +
+ {:else}

No CardDAV sync has run yet.

{/if} + {#if status.latest_successful && status.latest_successful.id !== status.latest?.id} +

Last successful sync:

+ {/if} + {/if} + {#if controller.syncError}{/if} + {#if controller.syncStatus}

{controller.syncStatus}

{/if} + {#if controller.syncUnknown} + + {/if} +
+
+
+
+ + +
+ {#if controller.booksLoading}

Loading address books…

{/if} + {#if controller.booksError} + + {/if} + {#if controller.bookError}{/if} + {#if controller.bookStatus}

{controller.bookStatus}

{/if} + {#if controller.booksUnknown}{/if} + {#if !controller.booksLoading && controller.books.length === 0}

No discovered address books.

{/if} +
+ {#each controller.books as book (book.id)} + {@const roles = controller.rolesFor(book)} + {@const draft = controller.bookDraft(book.id)} + + {#snippet actions()}{#if book.needs_full_reconcile}Full reconciliation required{/if}{/snippet} +
+ changeRole(book, 'subscribed', checked)} /> + changeRole(book, 'lookup_source', checked)} /> + changeRole(book, 'write_target', checked)} /> +
+ {#if roles.write_target && !book.write_target && !book.subscribed}

Publishing here also enables contact sync for this book.

{/if} +
+
+
+ + +
+ {#if controller.runsLoading}

Loading sync history…

{/if} + {#if controller.runsError} + + {/if} + {#if !controller.runsLoading && controller.runs.length === 0}

No CardDAV sync history yet.

{/if} + {#if controller.runs.length > 0} + +
+ + {#snippet header()} + + + + + {/snippet} + {#each controller.runs as historyRun (historyRun.id)} + + + + + + + {/each} +
{statusLabel(historyRun.state)}{#if historyRun.full} Full{/if}{historyRun.trigger === 'scheduled' ? 'Scheduled' : 'Manual'}{counters(historyRun)}{#if historyRun.error_message}{historyRun.error_message}{/if}
+
+ {/if} + {#if controller.runsPageError}{/if} + {#if controller.nextBeforeID !== undefined || controller.runsPageError} +
+
+ + diff --git a/web/src/lib/components/settings/CardDAVOperations.test.ts b/web/src/lib/components/settings/CardDAVOperations.test.ts new file mode 100644 index 000000000..48c7cda7d --- /dev/null +++ b/web/src/lib/components/settings/CardDAVOperations.test.ts @@ -0,0 +1,120 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../../api/client'; +import { CardDAVController } from '../../carddav/controller.svelte'; +import CardDAVOperations from './CardDAVOperations.svelte'; + +describe('CardDAVOperations', () => { + it('renders independent status, roles, history and never exposes a book URL marker', async () => { + const forbidden = 'forbidden-url-marker.example.test/private'; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = new URL(request.url).pathname; + if (path.endsWith('/status')) return Response.json({ + configured: true, available: true, credential_configured: true, enabled: false, + scheduled: false, schedule: '', latest: { + id: 4, trigger: 'manual', full: false, state: 'partial', started_at: '2026-08-28T10:00:00Z', + finished_at: '2026-08-28T10:01:00Z', books: 2, created: 1, updated: 3, removed: 1, + error_code: 'sync_failed', error_message: 'Some contacts need attention.' + } + }); + if (path.endsWith('/books')) return Response.json({ books: [{ + id: 7, name: 'Personal', url: `https://${forbidden}`, subscribed: true, + lookup_source: false, write_target: false, needs_full_reconcile: true + }] }); + if (path.endsWith('/runs')) return Response.json({ runs: [] }); + throw new Error(`Unexpected ${request.method} ${path}`); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + const rendered = render(CardDAVOperations, { controller }); + + expect(screen.getByText('Configured')).toBeDefined(); + expect(screen.getByText('Runtime available')).toBeDefined(); + expect(screen.getByText('Manual sync only')).toBeDefined(); + expect(screen.getByText('Partial')).toBeDefined(); + expect(screen.getByText('2 books · 1 created · 3 updated · 1 removed')).toBeDefined(); + expect(screen.getByText('Full reconciliation required')).toBeDefined(); + expect(screen.getByText('No CardDAV sync history yet.')).toBeDefined(); + expect(rendered.container.textContent).not.toContain(forbidden); + expect(rendered.container.innerHTML).not.toContain(forbidden); + expect((screen.getByRole('button', { name: 'Sync now' }) as HTMLButtonElement).disabled).toBe(false); + controller.destroy(); + }); + + it('enforces publish-implies-sync in the visible draft and applies the exact row intent', async () => { + const requests: Request[] = []; + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + requests.push(request); + const path = new URL(request.url).pathname; + if (path.endsWith('/status')) return Response.json({ configured: true, available: true, credential_configured: true, enabled: true, scheduled: true, schedule: '0 2 * * *' }); + if (request.method === 'GET' && path.endsWith('/books')) return Response.json({ books: [{ id: 7, name: 'Personal', url: 'https://forbidden.example.test', subscribed: false, lookup_source: false, write_target: false, needs_full_reconcile: false }] }); + if (path.endsWith('/runs')) return Response.json({ runs: [] }); + if (request.method === 'PATCH') return Response.json({ id: 7, name: 'Personal', url: 'https://forbidden.example.test', subscribed: true, lookup_source: false, write_target: true, needs_full_reconcile: false }); + throw new Error(`Unexpected ${request.method} ${path}`); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVOperations, { controller }); + + await fireEvent.click(screen.getByRole('checkbox', { name: 'Publish here for Personal' })); + expect(screen.getByText('Publishing here also enables contact sync for this book.')).toBeDefined(); + expect((screen.getByRole('checkbox', { name: 'Sync contacts for Personal' }) as HTMLInputElement).checked).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: 'Apply roles for Personal' })); + await waitFor(() => expect(requests.some((request) => request.method === 'PATCH')).toBe(true)); + const patch = requests.find((request) => request.method === 'PATCH')!; + await expect(patch.clone().json()).resolves.toEqual({ subscribed: true, lookup_source: false, write_target: true }); + controller.destroy(); + }); + + it('shows fixed repair copy and keeps sync disabled without a ready runtime', async () => { + const fetchFn = vi.fn(async (input) => { + const path = new URL((input instanceof Request ? input : new Request(input)).url).pathname; + if (path.endsWith('/status')) return Response.json({ configured: true, available: false, credential_configured: false, enabled: true, scheduled: false, schedule: '', repair_reason: 'credential_missing' }); + if (path.endsWith('/books')) return Response.json({ books: [] }); + return Response.json({ runs: [] }); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVOperations, { controller }); + + expect(screen.getByRole('alert').textContent).toContain('No CardDAV password is stored. Enter the password and save the account.'); + expect((screen.getByRole('button', { name: 'Sync now' }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText('No discovered address books.')).toBeDefined(); + controller.destroy(); + }); + + it('keeps retained books read-only until status confirms runtime readiness', async () => { + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = new URL(request.url).pathname; + if (path.endsWith('/status')) { + return Response.json({ error: 'unavailable', message: 'unsafe status detail' }, { status: 503 }); + } + if (path.endsWith('/books')) { + return Response.json({ books: [{ + id: 8, name: 'Retained book', url: 'https://forbidden.example.test', subscribed: true, + lookup_source: false, write_target: false, needs_full_reconcile: false + }] }); + } + return Response.json({ runs: [{ + id: 5, trigger: 'scheduled', full: false, state: 'succeeded', + started_at: '2026-08-28T09:00:00Z', finished_at: '2026-08-28T09:01:00Z', + books: 1, created: 1, updated: 0, removed: 0 + }] }); + }); + const controller = new CardDAVController(createAPIClient(fetchFn)); + await controller.load(); + render(CardDAVOperations, { controller }); + + expect((screen.getByRole('button', { name: 'Retry CardDAV status' }) as HTMLButtonElement).disabled).toBe(false); + expect(screen.getByText('1 books · 1 created · 0 updated · 0 removed')).toBeDefined(); + for (const control of screen.getAllByRole('checkbox')) { + expect((control as HTMLInputElement).disabled).toBe(true); + } + expect((screen.getByRole('button', { name: 'Apply roles for Retained book' }) as HTMLButtonElement).disabled).toBe(true); + controller.destroy(); + }); +}); diff --git a/web/src/lib/components/settings/CardDAVSettingsWorkspace.svelte b/web/src/lib/components/settings/CardDAVSettingsWorkspace.svelte new file mode 100644 index 000000000..14a61d6da --- /dev/null +++ b/web/src/lib/components/settings/CardDAVSettingsWorkspace.svelte @@ -0,0 +1,61 @@ + + +
+ + + +
+ + diff --git a/web/src/lib/components/settings/PersonEnrichmentProviderCard.svelte b/web/src/lib/components/settings/PersonEnrichmentProviderCard.svelte new file mode 100644 index 000000000..f960eca7f --- /dev/null +++ b/web/src/lib/components/settings/PersonEnrichmentProviderCard.svelte @@ -0,0 +1,350 @@ + + +
+
+
+

{provider.name}

+

{provider.kind === 'exa' ? 'Exa' : 'SixtyFour'} · stable provider name

+
+ { dirty = true; draft = { ...draft, enabled }; }} + /> +
+ +
+ + {#if provider.kind === 'sixtyfour'} + + + {:else} + + + {/if} + + + + + + + {#if provider.kind === 'sixtyfour'} + + + {/if} + + + +
+
+ Allow sensitive targets + Only enable when every listed target is intentionally approved for provider disclosure. +
+ { + dirty = true; + draft = { ...draft, allow_sensitive_targets }; + }} + /> +
+
+ +
+
Provider credential
+ +
+ + {#if error}{/if} +
+
+
+ + diff --git a/web/src/lib/components/settings/PersonEnrichmentProviderCreator.svelte b/web/src/lib/components/settings/PersonEnrichmentProviderCreator.svelte new file mode 100644 index 000000000..242de1c25 --- /dev/null +++ b/web/src/lib/components/settings/PersonEnrichmentProviderCreator.svelte @@ -0,0 +1,170 @@ + + +
+ {#if expanded} +
+ Add {providerLabel} provider + The provider starts disabled. Review its disclosure policy and add a credential before enabling it. +
+ + {#if error}{error}{/if} +
+
+ {:else} +
+ + diff --git a/web/src/lib/components/settings/ProviderCredentialControl.svelte b/web/src/lib/components/settings/ProviderCredentialControl.svelte new file mode 100644 index 000000000..fdbd2b0e2 --- /dev/null +++ b/web/src/lib/components/settings/ProviderCredentialControl.svelte @@ -0,0 +1,159 @@ + + +
+ {sourceLabel(credentialState)} + + {#if disabledReason}{disabledReason}{/if} +
+
+ {#if error}{error}{/if} +
+ + diff --git a/web/src/lib/components/settings/SettingsWorkspace.svelte b/web/src/lib/components/settings/SettingsWorkspace.svelte index b18cf6d6b..861080ee0 100644 --- a/web/src/lib/components/settings/SettingsWorkspace.svelte +++ b/web/src/lib/components/settings/SettingsWorkspace.svelte @@ -1,7 +1,6 @@ +{#snippet settingsFooter()} +