diff --git a/spec/openapi.yaml b/spec/openapi.yaml index d1a524b..8305ec2 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -362,12 +362,23 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminAgentPostResponse' - /admin/agents/{agent_address}: + /admin/agents/{local_address}: get: tags: - admin summary: Get a specific registered agent by local address (name@swarm) - operationId: get_agent_admin_agents__agent_address__get + operationId: get_agent_admin_agents__local_address__get + parameters: + - name: local_address + in: path + required: true + schema: + type: string + description: Agent local address (name@swarm); host is implied. + examples: + - researcher@acme + title: Local Address + description: Agent local address (name@swarm); host is implied. responses: '200': description: Successful Response @@ -375,11 +386,28 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminAgentGetResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' delete: tags: - admin summary: Delete an existing MAIL agent on this server - operationId: delete_agent_admin_agents__agent_address__delete + operationId: delete_agent_admin_agents__local_address__delete + parameters: + - name: local_address + in: path + required: true + schema: + type: string + description: Agent local address (name@swarm); host is implied. + examples: + - researcher@acme + title: Local Address + description: Agent local address (name@swarm); host is implied. responses: '200': description: Successful Response @@ -387,6 +415,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminAgentDeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /admin/daemons: get: tags: @@ -418,6 +452,17 @@ paths: - admin summary: Get a specific registered daemon by worker name operationId: get_daemon_admin_daemons__worker_name__get + parameters: + - name: worker_name + in: path + required: true + schema: + type: string + description: Daemon worker name; prefix and host are implied. + examples: + - indexer + title: Worker Name + description: Daemon worker name; prefix and host are implied. responses: '200': description: Successful Response @@ -425,11 +470,28 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminDaemonGetResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' delete: tags: - admin summary: Delete an existing MAIL daemon on this server operationId: delete_daemon_admin_daemons__worker_name__delete + parameters: + - name: worker_name + in: path + required: true + schema: + type: string + description: Daemon worker name; prefix and host are implied. + examples: + - indexer + title: Worker Name + description: Daemon worker name; prefix and host are implied. responses: '200': description: Successful Response @@ -437,6 +499,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminDaemonDeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /admin/users: get: tags: @@ -468,6 +536,17 @@ paths: - admin summary: Get a specific registered user by ID operationId: get_user_admin_users__user_id__get + parameters: + - name: user_id + in: path + required: true + schema: + type: string + description: User id; prefix and host are implied. + examples: + - addison + title: User Id + description: User id; prefix and host are implied. responses: '200': description: Successful Response @@ -475,11 +554,28 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminUserGetResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' delete: tags: - admin summary: Delete an existing MAIL user on this server operationId: delete_user_admin_users__user_id__delete + parameters: + - name: user_id + in: path + required: true + schema: + type: string + description: User id; prefix and host are implied. + examples: + - addison + title: User Id + description: User id; prefix and host are implied. responses: '200': description: Successful Response @@ -487,6 +583,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminUserDeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /admin/swarms: post: tags: @@ -506,6 +608,17 @@ paths: - admin summary: Delete an existing MAIL swarm on this server by name operationId: delete_swarm_admin_swarms__swarm_name__delete + parameters: + - name: swarm_name + in: path + required: true + schema: + type: string + description: Swarm name. + examples: + - acme + title: Swarm Name + description: Swarm name. responses: '200': description: Successful Response @@ -513,6 +626,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminSwarmDeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /admin/webhooks: get: tags: @@ -544,6 +663,17 @@ paths: - admin summary: Get a specific existing server webhook by ID operationId: get_webhook_admin_webhooks__webhook_id__get + parameters: + - name: webhook_id + in: path + required: true + schema: + type: string + description: Webhook id (wh_). + examples: + - wh_123e4567-e89b-12d3-a456-426614174000 + title: Webhook Id + description: Webhook id (wh_). responses: '200': description: Successful Response @@ -551,30 +681,70 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminWebhookGetResponse' - delete: + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + patch: tags: - admin - summary: Delete an existing webhook by ID from this server - operationId: delete_webhook_admin_webhooks__webhook_id__delete + summary: Update an existing webhook by ID on this server + operationId: patch_webhook_admin_webhooks__webhook_id__patch + parameters: + - name: webhook_id + in: path + required: true + schema: + type: string + description: Webhook id (wh_). + examples: + - wh_123e4567-e89b-12d3-a456-426614174000 + title: Webhook Id + description: Webhook id (wh_). responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AdminWebhooksDeleteResponse' - patch: + $ref: '#/components/schemas/AdminWebhooksPatchResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: tags: - admin - summary: Update an existing webhook by ID on this server - operationId: patch_webhook_admin_webhooks__webhook_id__patch + summary: Delete an existing webhook by ID from this server + operationId: delete_webhook_admin_webhooks__webhook_id__delete + parameters: + - name: webhook_id + in: path + required: true + schema: + type: string + description: Webhook id (wh_). + examples: + - wh_123e4567-e89b-12d3-a456-426614174000 + title: Webhook Id + description: Webhook id (wh_). responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AdminWebhooksPatchResponse' + $ref: '#/components/schemas/AdminWebhooksDeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /admin/lists: get: tags: @@ -600,12 +770,25 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminListPostResponse' - /admin/lists/{list_address}: + /admin/lists/{local_address}: get: tags: - admin-lists - summary: Get a specific MAIL list by address - operationId: admin_get_list_admin_lists__list_address__get + summary: Get a specific MAIL list by local address (name@swarm) + operationId: admin_get_list_admin_lists__local_address__get + parameters: + - name: local_address + in: path + required: true + schema: + type: string + description: 'List local address (name@swarm); the list: prefix and host + are implied.' + examples: + - announce@acme + title: Local Address + description: 'List local address (name@swarm); the list: prefix and host are + implied.' responses: '200': description: Successful Response @@ -613,36 +796,93 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminListGetResponse' - delete: + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + patch: tags: - admin-lists - summary: Delete a MAIL list - operationId: admin_delete_list_admin_lists__list_address__delete + summary: Update a MAIL list's policy + operationId: admin_patch_list_admin_lists__local_address__patch + parameters: + - name: local_address + in: path + required: true + schema: + type: string + description: 'List local address (name@swarm); the list: prefix and host + are implied.' + examples: + - announce@acme + title: Local Address + description: 'List local address (name@swarm); the list: prefix and host are + implied.' responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AdminListDeleteResponse' - patch: + $ref: '#/components/schemas/AdminListPatchResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: tags: - admin-lists - summary: Update a MAIL list's policy - operationId: admin_patch_list_admin_lists__list_address__patch + summary: Delete a MAIL list + operationId: admin_delete_list_admin_lists__local_address__delete + parameters: + - name: local_address + in: path + required: true + schema: + type: string + description: 'List local address (name@swarm); the list: prefix and host + are implied.' + examples: + - announce@acme + title: Local Address + description: 'List local address (name@swarm); the list: prefix and host are + implied.' responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AdminListPatchResponse' - /admin/lists/{list_address}/members: + $ref: '#/components/schemas/AdminListDeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /admin/lists/{local_address}/members: post: tags: - admin-lists summary: Admin-add a member to a MAIL list - operationId: admin_add_list_member_admin_lists__list_address__members_post + operationId: admin_add_list_member_admin_lists__local_address__members_post + parameters: + - name: local_address + in: path + required: true + schema: + type: string + description: 'List local address (name@swarm); the list: prefix and host + are implied.' + examples: + - announce@acme + title: Local Address + description: 'List local address (name@swarm); the list: prefix and host are + implied.' responses: '200': description: Successful Response @@ -650,12 +890,41 @@ paths: application/json: schema: $ref: '#/components/schemas/ListMemberPostResponse' - /admin/lists/{list_address}/members/{member_address}: + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /admin/lists/{local_address}/members/{member_address}: delete: tags: - admin-lists summary: Admin-remove a member from a MAIL list - operationId: admin_remove_list_member_admin_lists__list_address__members__member_address__delete + operationId: admin_remove_list_member_admin_lists__local_address__members__member_address__delete + parameters: + - name: local_address + in: path + required: true + schema: + type: string + description: 'List local address (name@swarm); the list: prefix and host + are implied.' + examples: + - announce@acme + title: Local Address + description: 'List local address (name@swarm); the list: prefix and host are + implied.' + - name: member_address + in: path + required: true + schema: + type: string + description: Full MAIL address of the member to remove (may be remote). + examples: + - user:bob@other-host + title: Member Address + description: Full MAIL address of the member to remove (may be remote). responses: '200': description: Successful Response @@ -663,6 +932,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ListMemberDeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /lists: get: tags: @@ -676,12 +951,25 @@ paths: application/json: schema: $ref: '#/components/schemas/ListsGetResponse' - /lists/{list_address}: + /lists/{local_address}: get: tags: - lists - summary: Get a specific list - operationId: get_list_lists__list_address__get + summary: Get a specific list by local address (name@swarm) + operationId: get_list_lists__local_address__get + parameters: + - name: local_address + in: path + required: true + schema: + type: string + description: 'List local address (name@swarm); the list: prefix and host + are implied.' + examples: + - announce@acme + title: Local Address + description: 'List local address (name@swarm); the list: prefix and host are + implied.' responses: '200': description: Successful Response @@ -689,12 +977,31 @@ paths: application/json: schema: $ref: '#/components/schemas/ListGetResponse' - /lists/{list_address}/subscribe: + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /lists/{local_address}/subscribe: post: tags: - lists summary: Subscribe to a MAIL list - operationId: subscribe_lists__list_address__subscribe_post + operationId: subscribe_lists__local_address__subscribe_post + parameters: + - name: local_address + in: path + required: true + schema: + type: string + description: 'List local address (name@swarm); the list: prefix and host + are implied.' + examples: + - announce@acme + title: Local Address + description: 'List local address (name@swarm); the list: prefix and host are + implied.' responses: '200': description: Successful Response @@ -702,12 +1009,31 @@ paths: application/json: schema: $ref: '#/components/schemas/ListMemberPostResponse' - /lists/{list_address}/unsubscribe: + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /lists/{local_address}/unsubscribe: post: tags: - lists summary: Unsubscribe from a MAIL list - operationId: unsubscribe_lists__list_address__unsubscribe_post + operationId: unsubscribe_lists__local_address__unsubscribe_post + parameters: + - name: local_address + in: path + required: true + schema: + type: string + description: 'List local address (name@swarm); the list: prefix and host + are implied.' + examples: + - announce@acme + title: Local Address + description: 'List local address (name@swarm); the list: prefix and host are + implied.' responses: '200': description: Successful Response @@ -715,6 +1041,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ListMemberDeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /: get: summary: Get basic server information and metadata @@ -752,7 +1084,7 @@ components: - agent - metadata title: AdminAgentDeleteResponse - description: 'Corresponds to `DELETE /admin/agent/{agent_address}`. + description: 'Corresponds to `DELETE /admin/agents/{local_address}`. Contains a newly-deleted MAIL agent registered on this server.' AdminAgentGetResponse: @@ -768,7 +1100,7 @@ components: - agent - metadata title: AdminAgentGetResponse - description: 'Corresponds to `GET /admin/agents/{agent_address}`. + description: 'Corresponds to `GET /admin/agents/{local_address}`. Contains a specific MAIL agent registered on this server.' AdminAgentPostResponse: @@ -784,7 +1116,7 @@ components: - agent - metadata title: AdminAgentPostResponse - description: 'Corresponds to `POST /admin/agent`. + description: 'Corresponds to `POST /admin/agents`. Contains the new MAIL agent registered on this server.' AdminAgentsGetResponse: @@ -887,7 +1219,7 @@ components: - mail_list - metadata title: AdminListDeleteResponse - description: Corresponds to `DELETE /admin/lists/{list_address}`. + description: Corresponds to `DELETE /admin/lists/{local_address}`. AdminListGetResponse: properties: mail_list: @@ -901,7 +1233,7 @@ components: - mail_list - metadata title: AdminListGetResponse - description: Corresponds to `GET /admin/lists/{list_address}`. + description: Corresponds to `GET /admin/lists/{local_address}`. AdminListPatchResponse: properties: mail_list: @@ -915,7 +1247,7 @@ components: - mail_list - metadata title: AdminListPatchResponse - description: Corresponds to `PATCH /admin/lists/{list_address}`. + description: Corresponds to `PATCH /admin/lists/{local_address}`. AdminListPostResponse: properties: mail_list: @@ -1460,7 +1792,7 @@ components: - mail_list - metadata title: ListGetResponse - description: Corresponds to `GET /lists/{list_address}`. + description: Corresponds to `GET /lists/{local_address}`. ListMemberDeleteResponse: properties: mail_list: @@ -1474,9 +1806,9 @@ components: - mail_list - metadata title: ListMemberDeleteResponse - description: 'Corresponds to `POST /lists/{list_address}/unsubscribe` + description: 'Corresponds to `POST /lists/{local_address}/unsubscribe` - and `DELETE /lists/{list_address}/members/{member_address}`. + and `DELETE /admin/lists/{local_address}/members/{member_address}`. The updated list with the member removed (idempotent — removing a @@ -1494,9 +1826,9 @@ components: - mail_list - metadata title: ListMemberPostResponse - description: 'Corresponds to `POST /lists/{list_address}/subscribe` and + description: 'Corresponds to `POST /lists/{local_address}/subscribe` and - `POST /admin/lists/{list_address}/members`. The updated list with + `POST /admin/lists/{local_address}/members`. The updated list with the member appended (idempotent — re-adding an existing member is diff --git a/src/mail/client/src/mail_client/admin_panel.py b/src/mail/client/src/mail_client/admin_panel.py index 0954b33..403e180 100644 --- a/src/mail/client/src/mail_client/admin_panel.py +++ b/src/mail/client/src/mail_client/admin_panel.py @@ -59,7 +59,7 @@ "Daemons", [ ("daemon-list (dl)", "List daemons."), - ("daemon-get (dg)", "Get a daemon by local address."), + ("daemon-get (dg)", "Get a daemon by worker name."), ("daemon-post (dp)", "Create daemon credentials."), ("daemon-delete (dd)", "Delete daemon credentials."), ], @@ -235,7 +235,7 @@ def build_parser() -> argparse.ArgumentParser: daemon_list_p.set_defaults(func=cmd_daemon_list, cmd="daemon-list") # command `daemon-get` - daemon_get_d = "get a specific daemon by local address on the MAIL server" + daemon_get_d = "get a specific daemon by worker name on the MAIL server" daemon_get_p = subparsers.add_parser( "daemon-get", aliases=["dg"], @@ -244,7 +244,7 @@ def build_parser() -> argparse.ArgumentParser: description=daemon_get_d, ) daemon_get_p.add_argument( - "local_address", help="the local address of the daemon to get (daemon@swarm)" + "worker_name", help="the worker name of the daemon to get" ) daemon_get_p.set_defaults(func=cmd_daemon_get, cmd="daemon-get") @@ -459,7 +459,8 @@ def build_parser() -> argparse.ArgumentParser: description=list_get_d, ) list_get_p.add_argument( - "list_address", help="the address of the mailing list to get" + "list_address", + help="the local address of the mailing list to get (name@swarm)", ) list_get_p.set_defaults(func=cmd_list_get_admin, cmd="list-get") @@ -507,7 +508,8 @@ def build_parser() -> argparse.ArgumentParser: description=list_delete_d, ) list_delete_p.add_argument( - "list_address", help="the address of the mailing list to delete" + "list_address", + help="the local address of the mailing list to delete (name@swarm)", ) list_delete_p.set_defaults(func=cmd_list_delete, cmd="list-delete") @@ -523,11 +525,12 @@ def build_parser() -> argparse.ArgumentParser: description=list_member_post_d, ) list_member_post_p.add_argument( - "list_address", help="the MAIL address of the mailing list to add a member to" + "list_address", + help="the local address of the mailing list to add a member to (name@swarm)", ) list_member_post_p.add_argument( "member_address", - help="the MAIL address of the member to add to this mailing list", + help="the full MAIL address of the member to add to this mailing list", ) list_member_post_p.set_defaults(func=cmd_list_member_post, cmd="list-member-post") @@ -544,11 +547,11 @@ def build_parser() -> argparse.ArgumentParser: ) list_member_delete_p.add_argument( "list_address", - help="the MAIL address of the mailing list to remove a member from", + help="the local address of the mailing list to remove a member from (name@swarm)", ) list_member_delete_p.add_argument( "member_address", - help="the MAIL address of the member to remove from this mailing list", + help="the full MAIL address of the member to remove from this mailing list", ) list_member_delete_p.set_defaults( func=cmd_list_member_delete, cmd="list-member-delete" diff --git a/src/mail/client/src/mail_client/cli.py b/src/mail/client/src/mail_client/cli.py index 8e961e5..f4c390c 100644 --- a/src/mail/client/src/mail_client/cli.py +++ b/src/mail/client/src/mail_client/cli.py @@ -488,7 +488,7 @@ def build_parser() -> argparse.ArgumentParser: description=list_get_d, ) list_get_p.add_argument( - "list_address", help="the address of the mailing list to get" + "list_address", help="the local address of the mailing list to get (name@swarm)" ) list_get_p.set_defaults(func=cmd_list_get, cmd="list-get") @@ -502,7 +502,8 @@ def build_parser() -> argparse.ArgumentParser: description=list_subscribe_d, ) list_subscribe_p.add_argument( - "list_address", help="the address of the mailing list to subscribe to" + "list_address", + help="the local address of the mailing list to subscribe to (name@swarm)", ) list_subscribe_p.set_defaults(func=cmd_list_subscribe, cmd="list-subscribe") @@ -516,7 +517,8 @@ def build_parser() -> argparse.ArgumentParser: description=list_unsubscribe_d, ) list_unsubscribe_p.add_argument( - "list_address", help="the address of the mailing list to unsubscribe from" + "list_address", + help="the local address of the mailing list to unsubscribe from (name@swarm)", ) list_unsubscribe_p.set_defaults(func=cmd_list_unsubscribe, cmd="list-unsubscribe") diff --git a/src/mail/client/src/mail_client/commands/daemon_get.py b/src/mail/client/src/mail_client/commands/daemon_get.py index c1933ad..00e5b91 100644 --- a/src/mail/client/src/mail_client/commands/daemon_get.py +++ b/src/mail/client/src/mail_client/commands/daemon_get.py @@ -13,7 +13,7 @@ def cmd_daemon_get(args: Namespace) -> None: """ - Get a specific daemon by local address on the MAIL server. + Get a specific daemon by worker name on the MAIL server. """ # 1. check that required env vars are provided @@ -24,9 +24,9 @@ def cmd_daemon_get(args: Namespace) -> None: if MAIL_TOKEN is None: raise ValueError("environment variable MAIL_TOKEN is required") - # 2. Attempt to get the specific daemon by local address on the MAIL server + # 2. Attempt to get the specific daemon by worker name on the MAIL server response = httpx.get( - url=f"{MAIL_SERVER}/admin/daemons/{args.local_address}", + url=f"{MAIL_SERVER}/admin/daemons/{args.worker_name}", headers={ "User-Agent": "Multi-Agent-Interface-Layer-CLI-Client/2.0.0 (github.com/charonlabs/mail)", "Authorization": f"Bearer {MAIL_TOKEN}", diff --git a/src/mail/protocol/src/mail_protocol/network/requests.py b/src/mail/protocol/src/mail_protocol/network/requests.py index f30e945..c679ea0 100644 --- a/src/mail/protocol/src/mail_protocol/network/requests.py +++ b/src/mail/protocol/src/mail_protocol/network/requests.py @@ -213,7 +213,7 @@ class AdminListPostRequest(BaseModel): class AdminListPatchRequest(BaseModel): """ - Corresponds to `PATCH /admin/lists/{list_address}`. + Corresponds to `PATCH /admin/lists/{local_address}`. All fields are optional; only the policy is mutable at v1. The canonical address (name, swarm, host) is immutable for the life of @@ -225,8 +225,8 @@ class AdminListPatchRequest(BaseModel): class ListMemberPostRequest(BaseModel): """ - Corresponds to ``POST /lists/{list_address}/members`` - and ``POST /admin/lists/{list_address}/members``. + Corresponds to ``POST /lists/{local_address}/subscribe`` + and ``POST /admin/lists/{local_address}/members``. ``member_address`` is the address being added. For the public subscribe path, this must match the authenticated bearer diff --git a/src/mail/protocol/src/mail_protocol/network/responses.py b/src/mail/protocol/src/mail_protocol/network/responses.py index 0489dd1..ae3e194 100644 --- a/src/mail/protocol/src/mail_protocol/network/responses.py +++ b/src/mail/protocol/src/mail_protocol/network/responses.py @@ -324,7 +324,7 @@ class AdminAgentsGetResponse(BaseModel): class AdminAgentGetResponse(BaseModel): """ - Corresponds to `GET /admin/agents/{agent_address}`. + Corresponds to `GET /admin/agents/{local_address}`. Contains a specific MAIL agent registered on this server. """ @@ -334,7 +334,7 @@ class AdminAgentGetResponse(BaseModel): class AdminAgentPostResponse(BaseModel): """ - Corresponds to `POST /admin/agent`. + Corresponds to `POST /admin/agents`. Contains the new MAIL agent registered on this server. """ @@ -344,7 +344,7 @@ class AdminAgentPostResponse(BaseModel): class AdminAgentDeleteResponse(BaseModel): """ - Corresponds to `DELETE /admin/agent/{agent_address}`. + Corresponds to `DELETE /admin/agents/{local_address}`. Contains a newly-deleted MAIL agent registered on this server. """ @@ -516,7 +516,7 @@ class AdminListsGetResponse(BaseModel): class AdminListGetResponse(BaseModel): """ - Corresponds to `GET /admin/lists/{list_address}`. + Corresponds to `GET /admin/lists/{local_address}`. """ mail_list: MAILListInBackend @@ -534,7 +534,7 @@ class AdminListPostResponse(BaseModel): class AdminListPatchResponse(BaseModel): """ - Corresponds to `PATCH /admin/lists/{list_address}`. + Corresponds to `PATCH /admin/lists/{local_address}`. """ mail_list: MAILListInBackend @@ -543,7 +543,7 @@ class AdminListPatchResponse(BaseModel): class AdminListDeleteResponse(BaseModel): """ - Corresponds to `DELETE /admin/lists/{list_address}`. + Corresponds to `DELETE /admin/lists/{local_address}`. """ mail_list: MAILListInBackend @@ -563,7 +563,7 @@ class ListsGetResponse(BaseModel): class ListGetResponse(BaseModel): """ - Corresponds to `GET /lists/{list_address}`. + Corresponds to `GET /lists/{local_address}`. """ mail_list: MAILListInBackend @@ -572,8 +572,8 @@ class ListGetResponse(BaseModel): class ListMemberPostResponse(BaseModel): """ - Corresponds to `POST /lists/{list_address}/subscribe` and - `POST /admin/lists/{list_address}/members`. The updated list with + Corresponds to `POST /lists/{local_address}/subscribe` and + `POST /admin/lists/{local_address}/members`. The updated list with the member appended (idempotent — re-adding an existing member is a no-op). """ @@ -584,8 +584,8 @@ class ListMemberPostResponse(BaseModel): class ListMemberDeleteResponse(BaseModel): """ - Corresponds to `POST /lists/{list_address}/unsubscribe` - and `DELETE /lists/{list_address}/members/{member_address}`. + Corresponds to `POST /lists/{local_address}/unsubscribe` + and `DELETE /admin/lists/{local_address}/members/{member_address}`. The updated list with the member removed (idempotent — removing a non-member is a no-op). """ diff --git a/src/mail/server/src/mail_server/backends/base.py b/src/mail/server/src/mail_server/backends/base.py index 371193a..2e6a090 100644 --- a/src/mail/server/src/mail_server/backends/base.py +++ b/src/mail/server/src/mail_server/backends/base.py @@ -60,6 +60,11 @@ class MAILServerBackend(Protocol): A generic base class for the MAIL server backend. """ + # The host this server answers for (e.g. ``mail.example.com``). Set + # during ``on_server_startup``. Routers read this to reconstruct a + # full canonical address from a local path-param identifier. + host: str + # # Lifecyle handlers # @@ -378,7 +383,7 @@ async def admin_get_agents( async def admin_get_agent( self, admin: MAILAdmin, - agent_address: str, + local_address: str, ) -> MAILAgent: """ Get a specific registered agent by local address (agent@swarm). @@ -402,7 +407,7 @@ async def admin_post_agent( async def admin_delete_agent( self, admin: MAILAdmin, - agent_address: str, + local_address: str, ) -> MAILAgent: """ Delete an existing MAIL agent by local address (agent@swarm). diff --git a/src/mail/server/src/mail_server/backends/memory/api.py b/src/mail/server/src/mail_server/backends/memory/api.py index 600378b..3c441cd 100644 --- a/src/mail/server/src/mail_server/backends/memory/api.py +++ b/src/mail/server/src/mail_server/backends/memory/api.py @@ -969,18 +969,18 @@ async def admin_get_agents( async def admin_get_agent( self, admin: MAILAdmin, - agent_address: str, + local_address: str, ) -> MAILAgent: """ Get a specific registered agent by local address (agent@swarm). """ - full_address = f"{agent_address}@{self.host}" + full_address = f"{local_address}@{self.host}" agent = self.user_agents.get(full_address) if agent is None: - raise ValueError(f"no agent found with address {agent_address}") + raise ValueError(f"no agent found with address {local_address}") if agent.user_agent.ua_type != "agent": - raise ValueError(f"invalid agent address: {agent_address}") + raise ValueError(f"invalid agent address: {local_address}") return agent.user_agent @@ -1022,25 +1022,25 @@ async def admin_post_agent( return agent async def admin_delete_agent( - self, admin: MAILAdmin, agent_address: str + self, admin: MAILAdmin, local_address: str ) -> MAILAgent: """ Delete an existing MAIL agent by local address (agent@swarm). """ - full_address = f"{agent_address}@{self.host}" + full_address = f"{local_address}@{self.host}" user_agent = self.user_agents.get(full_address) if user_agent is None: - raise ValueError(f"agent not found: {agent_address}") + raise ValueError(f"agent not found: {local_address}") if user_agent.user_agent.ua_type != "agent": - raise ValueError(f"invalid agent address: {agent_address}") + raise ValueError(f"invalid agent address: {local_address}") agent = self.user_agents.pop(full_address) if not isinstance(agent.user_agent, MAILAgent): self.user_agents.update( {full_address: agent} ) # re-add if invalid this far in - raise ValueError(f"invalid agent address: {agent_address}") + raise ValueError(f"invalid agent address: {local_address}") # remove inbox from self.inboxes self.inboxes.pop(full_address) diff --git a/src/mail/server/src/mail_server/backends/sqlite/api.py b/src/mail/server/src/mail_server/backends/sqlite/api.py index 7b76d3f..cefe3c3 100644 --- a/src/mail/server/src/mail_server/backends/sqlite/api.py +++ b/src/mail/server/src/mail_server/backends/sqlite/api.py @@ -776,16 +776,16 @@ async def admin_get_agents(self, admin: MAILAdmin) -> list[str]: return local_addrs async def admin_get_agent( - self, admin: MAILAdmin, agent_address: str + self, admin: MAILAdmin, local_address: str ) -> MAILAgent: - full_address = f"{agent_address}@{self.host}" + full_address = f"{local_address}@{self.host}" async with self._db.session() as session: agent = await MailStore(session).user_agents.get(full_address) if agent is None: - raise ValueError(f"no agent found with address {agent_address}") + raise ValueError(f"no agent found with address {local_address}") inner = agent.user_agent if not isinstance(inner, MAILAgent): - raise ValueError(f"invalid agent address: {agent_address}") + raise ValueError(f"invalid agent address: {local_address}") return inner async def admin_post_agent( @@ -811,17 +811,17 @@ async def admin_post_agent( return agent async def admin_delete_agent( - self, admin: MAILAdmin, agent_address: str + self, admin: MAILAdmin, local_address: str ) -> MAILAgent: - full_address = f"{agent_address}@{self.host}" + full_address = f"{local_address}@{self.host}" async with self._db.session() as session: store = MailStore(session) agent = await store.user_agents.get(full_address) if agent is None: - raise ValueError(f"agent not found: {agent_address}") + raise ValueError(f"agent not found: {local_address}") inner = agent.user_agent if not isinstance(inner, MAILAgent): - raise ValueError(f"invalid agent address: {agent_address}") + raise ValueError(f"invalid agent address: {local_address}") await store.user_agents.delete(full_address) return inner diff --git a/src/mail/server/src/mail_server/routers/admin.py b/src/mail/server/src/mail_server/routers/admin.py index fb24cd9..38146e8 100644 --- a/src/mail/server/src/mail_server/routers/admin.py +++ b/src/mail/server/src/mail_server/routers/admin.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 Addison Kline -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Path, Request from mail_protocol.network.responses import ( AdminAgentDeleteResponse, AdminAgentGetResponse, @@ -32,6 +32,11 @@ validate_admin_post_user_request, validate_admin_webhook_patch_request, validate_admin_webhook_post_request, + validate_local_address_param, + validate_swarm_name_param, + validate_user_id_param, + validate_webhook_id_param, + validate_worker_name_param, ) router = APIRouter(prefix="/admin", tags=["admin"]) @@ -59,18 +64,22 @@ async def get_agents( @router.get( - "/agents/{agent_address}", + "/agents/{local_address}", summary="Get a specific registered agent by local address (name@swarm)", response_model=AdminAgentGetResponse, ) async def get_agent( request: Request, + local_address: str = Path( + description="Agent local address (name@swarm); host is implied.", + examples=["researcher@acme"], + ), ) -> AdminAgentGetResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - agent_address = request.path_params.get("agent_address") + local_address = validate_local_address_param(local_address) try: - result = await backend.admin_get_agent(admin=admin, agent_address=agent_address) + result = await backend.admin_get_agent(admin=admin, local_address=local_address) except ValueError: raise HTTPException(status_code=404, detail="agent not found") @@ -103,19 +112,23 @@ async def post_agent( @router.delete( - "/agents/{agent_address}", + "/agents/{local_address}", summary="Delete an existing MAIL agent on this server", response_model=AdminAgentDeleteResponse, ) async def delete_agent( request: Request, + local_address: str = Path( + description="Agent local address (name@swarm); host is implied.", + examples=["researcher@acme"], + ), ) -> AdminAgentDeleteResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - agent_address = request.path_params.get("agent_address") + local_address = validate_local_address_param(local_address) try: result = await backend.admin_delete_agent( - admin=admin, agent_address=agent_address + admin=admin, local_address=local_address ) except ValueError: raise HTTPException(status_code=404, detail="agent not found") @@ -154,10 +167,14 @@ async def get_daemons( ) async def get_daemon( request: Request, + worker_name: str = Path( + description="Daemon worker name; prefix and host are implied.", + examples=["indexer"], + ), ) -> AdminDaemonGetResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - worker_name = request.path_params.get("worker_name") + worker_name = validate_worker_name_param(worker_name) try: result = await backend.admin_get_daemon(admin=admin, worker_name=worker_name) except ValueError: @@ -198,10 +215,14 @@ async def post_daemon( ) async def delete_daemon( request: Request, + worker_name: str = Path( + description="Daemon worker name; prefix and host are implied.", + examples=["indexer"], + ), ) -> AdminDaemonDeleteResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - worker_name = request.path_params.get("worker_name") + worker_name = validate_worker_name_param(worker_name) try: result = await backend.admin_delete_daemon(admin=admin, worker_name=worker_name) except ValueError: @@ -241,10 +262,14 @@ async def get_users( ) async def get_user( request: Request, + user_id: str = Path( + description="User id; prefix and host are implied.", + examples=["addison"], + ), ) -> AdminUserGetResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - user_id = request.path_params.get("user_id") + user_id = validate_user_id_param(user_id) try: result = await backend.admin_get_user(admin=admin, user_id=user_id) except ValueError: @@ -285,10 +310,14 @@ async def post_user( ) async def delete_user( request: Request, + user_id: str = Path( + description="User id; prefix and host are implied.", + examples=["addison"], + ), ) -> AdminUserDeleteResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - user_id = request.path_params.get("user_id") + user_id = validate_user_id_param(user_id) try: result = await backend.admin_delete_user(admin=admin, user_id=user_id) except ValueError: @@ -328,10 +357,16 @@ async def post_swarm(request: Request) -> AdminSwarmPostResponse: summary="Delete an existing MAIL swarm on this server by name", response_model=AdminSwarmDeleteResponse, ) -async def delete_swarm(request: Request) -> AdminSwarmDeleteResponse: +async def delete_swarm( + request: Request, + swarm_name: str = Path( + description="Swarm name.", + examples=["acme"], + ), +) -> AdminSwarmDeleteResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - swarm_name = request.path_params.get("swarm_name") + swarm_name = validate_swarm_name_param(swarm_name) try: result = await backend.admin_delete_swarm(admin=admin, swarm_name=swarm_name) except ValueError: @@ -367,10 +402,16 @@ async def get_webhooks(request: Request) -> AdminWebhooksGetResponse: summary="Get a specific existing server webhook by ID", response_model=AdminWebhookGetResponse, ) -async def get_webhook(request: Request) -> AdminWebhookGetResponse: +async def get_webhook( + request: Request, + webhook_id: str = Path( + description="Webhook id (wh_).", + examples=["wh_123e4567-e89b-12d3-a456-426614174000"], + ), +) -> AdminWebhookGetResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - webhook_id = request.path_params.get("webhook_id") + webhook_id = validate_webhook_id_param(webhook_id) try: result = await backend.admin_webhook_get(admin=admin, webhook_id=webhook_id) except ValueError: @@ -404,11 +445,17 @@ async def post_webhook(request: Request) -> AdminWebhooksPostResponse: summary="Update an existing webhook by ID on this server", response_model=AdminWebhooksPatchResponse, ) -async def patch_webhook(request: Request) -> AdminWebhooksPatchResponse: +async def patch_webhook( + request: Request, + webhook_id: str = Path( + description="Webhook id (wh_).", + examples=["wh_123e4567-e89b-12d3-a456-426614174000"], + ), +) -> AdminWebhooksPatchResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) payload = await validate_admin_webhook_patch_request(request=request) - webhook_id = request.path_params.get("webhook_id") + webhook_id = validate_webhook_id_param(webhook_id) try: result = await backend.admin_webhook_patch( admin=admin, webhook_id=webhook_id, payload=payload @@ -427,10 +474,16 @@ async def patch_webhook(request: Request) -> AdminWebhooksPatchResponse: summary="Delete an existing webhook by ID from this server", response_model=AdminWebhooksDeleteResponse, ) -async def delete_webhook(request: Request) -> AdminWebhooksDeleteResponse: +async def delete_webhook( + request: Request, + webhook_id: str = Path( + description="Webhook id (wh_).", + examples=["wh_123e4567-e89b-12d3-a456-426614174000"], + ), +) -> AdminWebhooksDeleteResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - webhook_id = request.path_params.get("webhook_id") + webhook_id = validate_webhook_id_param(webhook_id) try: result = await backend.admin_webhook_delete(admin=admin, webhook_id=webhook_id) except ValueError: diff --git a/src/mail/server/src/mail_server/routers/lists.py b/src/mail/server/src/mail_server/routers/lists.py index de963df..7eba721 100644 --- a/src/mail/server/src/mail_server/routers/lists.py +++ b/src/mail/server/src/mail_server/routers/lists.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 Charon Labs (contribution PR) -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Path, Request from mail_protocol.core.lists import MAILListPolicy from mail_protocol.network.responses import ( AdminListDeleteResponse, @@ -16,15 +16,34 @@ ) from mail_server.auth import validate_admin, validate_user_agent +from mail_server.backends.base import MAILServerBackend from mail_server.validators import ( validate_admin_patch_list_request, validate_admin_post_list_request, validate_list_member_post_request, + validate_local_address_param, + validate_member_address_param, ) admin_router = APIRouter(prefix="/admin/lists", tags=["admin-lists"]) public_router = APIRouter(prefix="/lists", tags=["lists"]) +_LOCAL_ADDRESS_PATH = Path( + description="List local address (name@swarm); the list: prefix and host are implied.", + examples=["announce@acme"], +) + + +def _full_list_address(backend: MAILServerBackend, local_address: str) -> str: + """ + Reconstruct a list's full canonical ``list:name@swarm@host`` address + from the local ``name@swarm`` path param. The path param carries only + the local identifier; the ``list:`` prefix is implied by the route and + the host by the server. + """ + + return f"list:{local_address}@{backend.host}" + # # Policy guardrails — v1 ships only the open / public variants. The @@ -82,14 +101,18 @@ async def admin_get_lists(request: Request) -> AdminListsGetResponse: @admin_router.get( - "/{list_address}", - summary="Get a specific MAIL list by address", + "/{local_address}", + summary="Get a specific MAIL list by local address (name@swarm)", response_model=AdminListGetResponse, ) -async def admin_get_list(request: Request) -> AdminListGetResponse: +async def admin_get_list( + request: Request, local_address: str = _LOCAL_ADDRESS_PATH +) -> AdminListGetResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - list_address = request.path_params.get("list_address") + list_address = _full_list_address( + backend, validate_local_address_param(local_address) + ) try: result = await backend.admin_get_list(admin=admin, list_address=list_address) except ValueError: @@ -115,14 +138,18 @@ async def admin_post_list(request: Request) -> AdminListPostResponse: @admin_router.patch( - "/{list_address}", + "/{local_address}", summary="Update a MAIL list's policy", response_model=AdminListPatchResponse, ) -async def admin_patch_list(request: Request) -> AdminListPatchResponse: +async def admin_patch_list( + request: Request, local_address: str = _LOCAL_ADDRESS_PATH +) -> AdminListPatchResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - list_address = request.path_params.get("list_address") + list_address = _full_list_address( + backend, validate_local_address_param(local_address) + ) payload = await validate_admin_patch_list_request(request=request) _reject_unsupported_policy(payload.policy) try: @@ -135,14 +162,18 @@ async def admin_patch_list(request: Request) -> AdminListPatchResponse: @admin_router.delete( - "/{list_address}", + "/{local_address}", summary="Delete a MAIL list", response_model=AdminListDeleteResponse, ) -async def admin_delete_list(request: Request) -> AdminListDeleteResponse: +async def admin_delete_list( + request: Request, local_address: str = _LOCAL_ADDRESS_PATH +) -> AdminListDeleteResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - list_address = request.path_params.get("list_address") + list_address = _full_list_address( + backend, validate_local_address_param(local_address) + ) try: result = await backend.admin_delete_list(admin=admin, list_address=list_address) except ValueError: @@ -151,15 +182,19 @@ async def admin_delete_list(request: Request) -> AdminListDeleteResponse: @admin_router.post( - "/{list_address}/members", + "/{local_address}/members", summary="Admin-add a member to a MAIL list", response_model=ListMemberPostResponse, ) -async def admin_add_list_member(request: Request) -> ListMemberPostResponse: +async def admin_add_list_member( + request: Request, local_address: str = _LOCAL_ADDRESS_PATH +) -> ListMemberPostResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) _ = admin # auth-only; the backend method does not gate by admin - list_address = request.path_params.get("list_address") + list_address = _full_list_address( + backend, validate_local_address_param(local_address) + ) payload = await validate_list_member_post_request(request=request) try: result = await backend.add_list_member( @@ -172,16 +207,25 @@ async def admin_add_list_member(request: Request) -> ListMemberPostResponse: @admin_router.delete( - "/{list_address}/members/{member_address}", + "/{local_address}/members/{member_address}", summary="Admin-remove a member from a MAIL list", response_model=ListMemberDeleteResponse, ) -async def admin_remove_list_member(request: Request) -> ListMemberDeleteResponse: +async def admin_remove_list_member( + request: Request, + local_address: str = _LOCAL_ADDRESS_PATH, + member_address: str = Path( + description="Full MAIL address of the member to remove (may be remote).", + examples=["user:bob@other-host"], + ), +) -> ListMemberDeleteResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) _ = admin - list_address = request.path_params.get("list_address") - member_address = request.path_params.get("member_address") + list_address = _full_list_address( + backend, validate_local_address_param(local_address) + ) + member_address = validate_member_address_param(member_address) try: result = await backend.remove_list_member( list_address=list_address, @@ -212,14 +256,18 @@ async def get_lists(request: Request) -> ListsGetResponse: @public_router.get( - "/{list_address}", - summary="Get a specific list", + "/{local_address}", + summary="Get a specific list by local address (name@swarm)", response_model=ListGetResponse, ) -async def get_list(request: Request) -> ListGetResponse: +async def get_list( + request: Request, local_address: str = _LOCAL_ADDRESS_PATH +) -> ListGetResponse: backend = request.app.state.backend await validate_user_agent(backend=backend, request=request) - list_address = request.path_params.get("list_address") + list_address = _full_list_address( + backend, validate_local_address_param(local_address) + ) try: result = await backend.get_list(list_address=list_address) except ValueError: @@ -231,14 +279,18 @@ async def get_list(request: Request) -> ListGetResponse: @public_router.post( - "/{list_address}/subscribe", + "/{local_address}/subscribe", summary="Subscribe to a MAIL list", response_model=ListMemberPostResponse, ) -async def subscribe(request: Request) -> ListMemberPostResponse: +async def subscribe( + request: Request, local_address: str = _LOCAL_ADDRESS_PATH +) -> ListMemberPostResponse: backend = request.app.state.backend user_agent = await validate_user_agent(backend=backend, request=request) - list_address = request.path_params.get("list_address") + list_address = _full_list_address( + backend, validate_local_address_param(local_address) + ) try: existing = await backend.get_list(list_address=list_address) @@ -262,14 +314,18 @@ async def subscribe(request: Request) -> ListMemberPostResponse: @public_router.post( - "/{list_address}/unsubscribe", + "/{local_address}/unsubscribe", summary="Unsubscribe from a MAIL list", response_model=ListMemberDeleteResponse, ) -async def unsubscribe(request: Request) -> ListMemberDeleteResponse: +async def unsubscribe( + request: Request, local_address: str = _LOCAL_ADDRESS_PATH +) -> ListMemberDeleteResponse: backend = request.app.state.backend user_agent = await validate_user_agent(backend=backend, request=request) - list_address = request.path_params.get("list_address") + list_address = _full_list_address( + backend, validate_local_address_param(local_address) + ) member_address = user_agent.get_address() try: diff --git a/src/mail/server/src/mail_server/validators.py b/src/mail/server/src/mail_server/validators.py index 9b182bb..bbaafea 100644 --- a/src/mail/server/src/mail_server/validators.py +++ b/src/mail/server/src/mail_server/validators.py @@ -2,6 +2,14 @@ # Copyright (c) 2026 Addison Kline from fastapi import HTTPException, Request +from mail_protocol.core.validators import ( + validate_daemon_worker_name, + validate_local_address, + validate_mail_address, + validate_swarm_name, + validate_user_name, + validate_webhook_id, +) from mail_protocol.network.requests import ( AdminAgentPostRequest, AdminDaemonPostRequest, @@ -47,6 +55,96 @@ async def validate_box_filter_params(request: Request) -> BoxFilterParams: ) +# +# Path parameter validators +# +# Admin (and list) endpoints address resources by their *local* +# identifier — the host is implied by the server and the user-agent +# prefix (``daemon:``/``user:``/``list:``) is implied by the route. +# These helpers validate the shape of a path segment and 422 on +# malformed input, mirroring the body/query validators above. A +# well-formed-but-unknown id still 404s downstream. +# +def validate_local_address_param(value: str) -> str: + """ + Validate an agent or list local address path param (``name@swarm``). + """ + + try: + return validate_local_address(value) + except ValueError as e: + raise HTTPException( + status_code=422, detail=f"invalid local address path parameter: {e}" + ) + + +def validate_worker_name_param(value: str) -> str: + """ + Validate a daemon ``worker_name`` path param. + """ + + try: + return validate_daemon_worker_name(value) + except ValueError as e: + raise HTTPException( + status_code=422, detail=f"invalid worker name path parameter: {e}" + ) + + +def validate_user_id_param(value: str) -> str: + """ + Validate a ``user_id`` path param. + """ + + try: + return validate_user_name(value) + except ValueError as e: + raise HTTPException( + status_code=422, detail=f"invalid user id path parameter: {e}" + ) + + +def validate_swarm_name_param(value: str) -> str: + """ + Validate a ``swarm_name`` path param. + """ + + try: + return validate_swarm_name(value) + except ValueError as e: + raise HTTPException( + status_code=422, detail=f"invalid swarm name path parameter: {e}" + ) + + +def validate_webhook_id_param(value: str) -> str: + """ + Validate a ``webhook_id`` path param (``wh_``). + """ + + try: + return validate_webhook_id(value) + except ValueError as e: + raise HTTPException( + status_code=422, detail=f"invalid webhook id path parameter: {e}" + ) + + +def validate_member_address_param(value: str) -> str: + """ + Validate a list ``member_address`` path param. Unlike the resource + identifiers above, a member can be any user-agent — possibly on + another host — so this is a full MAIL address, not a local one. + """ + + try: + return validate_mail_address(value) + except ValueError as e: + raise HTTPException( + status_code=422, detail=f"invalid member address path parameter: {e}" + ) + + # # Draft endpoint validators # @@ -246,7 +344,7 @@ async def validate_admin_patch_list_request( request: Request, ) -> AdminListPatchRequest: """ - Ensure that the request payload is valid for `PATCH /admin/lists/{list_address}`. + Ensure that the request payload is valid for `PATCH /admin/lists/{local_address}`. """ try: @@ -263,7 +361,7 @@ async def validate_list_member_post_request( ) -> ListMemberPostRequest: """ Ensure that the request payload is valid for the member-add endpoints - (`POST /lists/{list_address}/members` and the admin variant). + (`POST /admin/lists/{local_address}/members` and the subscribe variant). """ try: diff --git a/tests/e2e/test_journeys.py b/tests/e2e/test_journeys.py index cfbe22b..9404ac5 100644 --- a/tests/e2e/test_journeys.py +++ b/tests/e2e/test_journeys.py @@ -17,7 +17,10 @@ USER = f"user:alice@{HOST}" OTHER_USER = f"user:bob@{HOST}" AGENT = f"sage@{SWARM}@{HOST}" +# Full address: used as a message recipient (delivery resolves the full +# ``list:`` form). The list HTTP/CLI surface addresses it by local form. LIST_ADDRESS = f"list:town-square@{SWARM}@{HOST}" +LIST_LOCAL_ADDRESS = f"town-square@{SWARM}" def test_send_deliver_read_journey(e2e_stack) -> None: @@ -67,7 +70,7 @@ def test_list_fan_out_journey(e2e_stack) -> None: assert response.status_code == 200, response.text # Bob subscribes himself through the CLI. - subscribed = e2e_stack.cli_json("list-subscribe", LIST_ADDRESS, token=bob) + subscribed = e2e_stack.cli_json("list-subscribe", LIST_LOCAL_ADDRESS, token=bob) assert OTHER_USER in subscribed["mail_list"]["members"] draft = e2e_stack.cli_json("compose", "To the square", "Hear ye.", token=alice) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 707c210..37baa57 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -211,7 +211,11 @@ async def mutate(store: MailStore) -> None: @pytest.fixture def seed_list(backend: MAILServerBackend) -> Callable[[MAILListInBackend], str]: - """Backend-agnostic: persist a prebuilt list. Returns its address.""" + """ + Backend-agnostic: persist a prebuilt list. Returns its *local* + address (``name@swarm``) — the form the HTTP API addresses lists by. + The backend still keys lists by the full ``list:`` address. + """ def _seed(record: MAILListInBackend) -> str: if isinstance(backend, MemoryBackend): @@ -223,7 +227,7 @@ async def mutate(store: MailStore) -> None: await store.lists.add(record) _run_sqlite_write(backend._db.url, mutate) - return record.get_address() + return f"{record.name}@{record.swarm}" return _seed diff --git a/tests/integration/test_admin.py b/tests/integration/test_admin.py index b1f0d7f..cc63f70 100644 --- a/tests/integration/test_admin.py +++ b/tests/integration/test_admin.py @@ -29,6 +29,24 @@ def test_get_agent_unknown_returns_404(app_client: TestClient, headers_for) -> N assert response.status_code == 404 +def test_get_agent_malformed_local_address_returns_422( + app_client: TestClient, headers_for +) -> None: + # A full address (name@swarm@host) is no longer accepted here: the + # path param is the *local* address (name@swarm). + response = app_client.get( + f"/admin/agents/sage@{SWARM}@localhost", headers=headers_for(ADMIN) + ) + assert response.status_code == 422 + + +def test_get_agent_non_slug_returns_422(app_client: TestClient, headers_for) -> None: + response = app_client.get( + f"/admin/agents/Sage@{SWARM}", headers=headers_for(ADMIN) + ) + assert response.status_code == 422 + + def test_post_agent_creates_and_can_login( app_client: TestClient, headers_for, token_for ) -> None: @@ -130,6 +148,13 @@ def test_get_daemon_unknown_returns_404(app_client: TestClient, headers_for) -> assert response.status_code == 404 +def test_get_daemon_malformed_worker_name_returns_422( + app_client: TestClient, headers_for +) -> None: + response = app_client.get("/admin/daemons/Bad-Name", headers=headers_for(ADMIN)) + assert response.status_code == 422 + + def test_post_daemon_creates_and_can_login(app_client: TestClient, headers_for) -> None: response = app_client.post( "/admin/daemons", diff --git a/tests/integration/test_admin_webhooks.py b/tests/integration/test_admin_webhooks.py index 78e5597..3955155 100644 --- a/tests/integration/test_admin_webhooks.py +++ b/tests/integration/test_admin_webhooks.py @@ -59,12 +59,23 @@ def test_get_webhook_by_id(app_client: TestClient, headers_for) -> None: def test_get_webhook_unknown_returns_404( app_client: TestClient, headers_for ) -> None: + # Well-formed (wh_) but unknown id → 404. response = app_client.get( - "/admin/webhooks/wh_nonexistent", headers=headers_for(ADMIN) + "/admin/webhooks/wh_123e4567-e89b-12d3-a456-426614174000", + headers=headers_for(ADMIN), ) assert response.status_code == 404 +def test_get_webhook_malformed_id_returns_422( + app_client: TestClient, headers_for +) -> None: + response = app_client.get( + "/admin/webhooks/wh_nonexistent", headers=headers_for(ADMIN) + ) + assert response.status_code == 422 + + def test_delete_webhook_removes(app_client: TestClient, headers_for) -> None: headers = headers_for(ADMIN) webhook_id = _post_webhook(app_client, headers) @@ -80,8 +91,10 @@ def test_delete_webhook_removes(app_client: TestClient, headers_for) -> None: def test_delete_webhook_unknown_returns_404( app_client: TestClient, headers_for ) -> None: + # Well-formed (wh_) but unknown id → 404. response = app_client.delete( - "/admin/webhooks/wh_nonexistent", headers=headers_for(ADMIN) + "/admin/webhooks/wh_123e4567-e89b-12d3-a456-426614174000", + headers=headers_for(ADMIN), ) assert response.status_code == 404 diff --git a/tests/integration/test_flows.py b/tests/integration/test_flows.py index ee46392..399dd64 100644 --- a/tests/integration/test_flows.py +++ b/tests/integration/test_flows.py @@ -12,7 +12,11 @@ USER = "user:alice@localhost" OTHER_USER = "user:bob@localhost" AGENT = "sage@chorus@localhost" +# Full address: used as a message recipient (delivery resolves the full +# ``list:`` form). The HTTP list endpoints address the same list by its +# local form (``name@swarm``). LIST_ADDRESS = "list:welfare-discourse@chorus@localhost" +LIST_LOCAL_ADDRESS = "welfare-discourse@chorus" def test_send_and_read_journey( @@ -64,7 +68,7 @@ def test_list_fan_out_journey( # Bob subscribes himself through the public endpoint. response = app_client.post( - f"/lists/{LIST_ADDRESS}/subscribe", headers=headers_for(OTHER_USER) + f"/lists/{LIST_LOCAL_ADDRESS}/subscribe", headers=headers_for(OTHER_USER) ) assert response.status_code == 200 assert OTHER_USER in response.json()["mail_list"]["members"] diff --git a/tests/integration/test_lists.py b/tests/integration/test_lists.py index 2a8d13d..407c89a 100644 --- a/tests/integration/test_lists.py +++ b/tests/integration/test_lists.py @@ -145,12 +145,24 @@ def test_admin_get_list_missing_returns_404( app_client: TestClient, headers_for ) -> None: response = app_client.get( - "/admin/lists/list:nonexistent@chorus@localhost", + "/admin/lists/nonexistent@chorus", headers=headers_for(ADMIN_ADDRESS), ) assert response.status_code == 404 +def test_admin_get_list_malformed_local_address_returns_422( + app_client: TestClient, headers_for +) -> None: + # The full ``list:`` address is no longer accepted here — the path + # param is the list's local address (name@swarm). + response = app_client.get( + "/admin/lists/list:welfare-discourse@chorus@localhost", + headers=headers_for(ADMIN_ADDRESS), + ) + assert response.status_code == 422 + + def test_admin_patch_list_updates_policy_no_op_for_open( app_client: TestClient, headers_for, @@ -202,7 +214,7 @@ def test_admin_delete_list_missing_returns_404( app_client: TestClient, headers_for ) -> None: response = app_client.delete( - "/admin/lists/list:nonexistent@chorus@localhost", + "/admin/lists/nonexistent@chorus", headers=headers_for(ADMIN_ADDRESS), ) assert response.status_code == 404 @@ -286,7 +298,7 @@ def test_get_list_missing_returns_404( app_client: TestClient, headers_for ) -> None: response = app_client.get( - "/lists/list:nonexistent@chorus@localhost", + "/lists/nonexistent@chorus", headers=headers_for(USER_ADDRESS), ) assert response.status_code == 404 @@ -334,7 +346,7 @@ def test_subscribe_missing_list_returns_404( app_client: TestClient, headers_for ) -> None: response = app_client.post( - "/lists/list:nonexistent@chorus@localhost/subscribe", + "/lists/nonexistent@chorus/subscribe", headers=headers_for(USER_ADDRESS), ) assert response.status_code == 404