diff --git a/README.md b/README.md index 6426f95..a8d7964 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Official Python SDK for [Norbix](https://norbix.ai). Use split clients with flat module access: -- `NorbixApi` for API scope (`client.database`, `client.membership`, ...) +- `NorbixApi` for API scope (`client.database`, `client.membership`, `client.public`, ...) - `NorbixHub` for Hub scope (`client.database`, `client.account`, ...) ## Install @@ -143,6 +143,32 @@ and `norbix.hub.ai` (`get_embedding_integrations`, `save_embedding_integration`, [docs/api/ai.md](./docs/api/ai.md), [docs/hub/ai.md](./docs/hub/ai.md), [docs/hub/account.md](./docs/hub/account.md). +## Project settings, public pages, MCP and AI service users + +Project owners manage the Admin Portal and legal pages on the Hub, +`norbix.hub.account`: `update_project_admin_url`, `update_project_legal_documents`, +`update_project_expose_legal`, `get_admin_portal_structure`, +`assign_admin_portal_service_user`. Two switches choose what the public Admin +Portal config shows: `update_project_expose_brand` (brand, on by default) and +`update_project_expose_auth` (sign-in methods and password policy, off by default). + +Once the legal pages are exposed, anyone can read them — and the project's +public config — from the API host with no sign-in, `norbix.api.public`: + +```python +norbix.hub.account.update_project_legal_documents("proj_123", termsMarkdown="# Terms", privacyMarkdown="# Privacy") +norbix.hub.account.update_project_expose_legal("proj_123", exposed=True) +norbix.api.public.get_public_project_legal("proj_123", "terms") # {"kind", "title", "body", "available"} +norbix.api.public.get_public_project_config("proj_123") +``` + +AI agents reach Norbix through the developer MCP endpoint with an AI service +user key: `create_ai_service_user`, `list_ai_service_users`, +`rotate_ai_service_user_key`, `revoke_ai_service_user_key`, +`delete_ai_service_user`, then `mcp` / `mcp_stream` / `mcp_end_session`. +Full tables and an MCP example: [docs/hub/account.md](./docs/hub/account.md), +[docs/api/public.md](./docs/api/public.md). + ## Errors ```python diff --git a/docs/api/_index.md b/docs/api/_index.md index 5e0e9ac..7a90248 100644 --- a/docs/api/_index.md +++ b/docs/api/_index.md @@ -7,3 +7,4 @@ | [`echo`](./echo.md) | 1 | | [`files`](./files.md) | 10 | | [`membership`](./membership.md) | 18 | +| [`public`](./public.md) | 2 | diff --git a/docs/api/public.md b/docs/api/public.md new file mode 100644 index 0000000..7b991a5 --- /dev/null +++ b/docs/api/public.md @@ -0,0 +1,16 @@ +# API · Public + +Access with `norbix.api.public` (or `client.public` on `NorbixApi`). + +These routes need **no sign-in**: the gateway does not authenticate them, and +the SDK sends no `Authorization` header even when the client has a token. + +| Method | Verb | Path | Scope | +| --- | --- | --- | --- | +| `get_public_project_config` | `GET` | `/{version}/public/projects/{ProjectId}/config` | `unauthenticated` | +| `get_public_project_legal` | `GET` | `/{version}/public/projects/{ProjectId}/legal/{Kind}` | `unauthenticated` | + +`get_public_project_legal` takes `kind` = `"terms"` or `"privacy"` and gives +back `{kind, title, body, available}` (Markdown in `body`). `available` is +`False` until the project turns the legal pages on with +`norbix.hub.account.update_project_expose_legal(project_id, exposed=True)`. diff --git a/docs/hub/_index.md b/docs/hub/_index.md index cb5f09e..f2425de 100644 --- a/docs/hub/_index.md +++ b/docs/hub/_index.md @@ -2,7 +2,7 @@ | Module | Endpoints | | --- | ---: | -| [`account`](./account.md) | 36 | +| [`account`](./account.md) | 58 | | [`ai`](./ai.md) | 14 | | [`database`](./database.md) | 41 | | [`echo`](./echo.md) | 1 | diff --git a/docs/hub/account.md b/docs/hub/account.md index 89630e0..513b20b 100644 --- a/docs/hub/account.md +++ b/docs/hub/account.md @@ -47,3 +47,42 @@ Access with `norbix.hub.account`. | `delete_project_ai_assistant` | `DELETE` | `/{version}/account/projects/{projectId}/ai/assistants/{assistantId}` | `project` | | `get_project_ai_usage` | `GET` | `/{version}/account/projects/{projectId}/ai/usage` | `project` | | `set_admin_portal_enabled` | `PUT` | `/{version}/account/projects/{projectId}/admin-portal/enabled` | `project` | +| `update_project_admin_url` | `PATCH` | `/{version}/account/projects/{projectId}/settings/admin-url` | `project` | +| `update_project_legal_documents` | `PATCH` | `/{version}/account/projects/{projectId}/settings/legal` | `project` | +| `update_project_expose_legal` | `PATCH` | `/{version}/account/projects/{projectId}/settings/legal/expose` | `project` | +| `update_project_expose_brand` | `PATCH` | `/{version}/account/projects/{projectId}/settings/brand/expose` | `project` | +| `update_project_expose_auth` | `PATCH` | `/{version}/account/projects/{projectId}/settings/auth/expose` | `project` | +| `get_admin_portal_structure` | `GET` | `/{version}/account/projects/{projectId}/admin-portal/structure` | `project` | +| `assign_admin_portal_service_user` | `PUT` | `/{version}/account/projects/{projectId}/settings/admin-portal/service-user` | `project` | +| `create_ai_service_user` | `POST` | `/{version}/account/ai/service-users` | `project` | +| `list_ai_service_users` | `GET` | `/{version}/account/ai/service-users` | `project` | +| `rotate_ai_service_user_key` | `POST` | `/{version}/account/ai/service-users/{Id}/keys` | `project` | +| `revoke_ai_service_user_key` | `DELETE` | `/{version}/account/ai/service-users/{Id}/keys/{KeyId}` | `project` | +| `delete_ai_service_user` | `DELETE` | `/{version}/account/ai/service-users/{Id}` | `project` | +| `mcp` | `POST` | `/{version}/account/mcp` | `project` | +| `mcp_stream` | `GET` | `/{version}/account/mcp` | `project` | +| `mcp_end_session` | `DELETE` | `/{version}/account/mcp` | `project` | + +## The developer MCP endpoint + +`mcp`, `mcp_stream` and `mcp_end_session` speak MCP Streamable HTTP +(revision 2025-11-25). They give back `{"status", "sessionId", "body", "events"}`: +`sessionId` is read from the `Mcp-Session-Id` answer header of `initialize` +and must be passed as `session_id` on every later call; `body` is the JSON-RPC +answer; `events` holds the messages of an SSE answer (a streamed `tools/call`, +or the `mcp_stream` server stream). `mcp_stream` does not stream: it returns +when the server closes the stream or `timeout` runs out. + +```python +init = norbix.hub.account.mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-11-25", "capabilities": {}, + "clientInfo": {"name": "my-agent", "version": "1.0"}}}) +sid = init["sessionId"] +norbix.hub.account.mcp({"jsonrpc": "2.0", "method": "notifications/initialized"}, session_id=sid) +tools = norbix.hub.account.mcp({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, session_id=sid) +norbix.hub.account.mcp_end_session(sid) +``` + +An AI service user key (`nbsu_…`, from `create_ai_service_user` or +`rotate_ai_service_user_key`) works as the client's `api_key` / `bearer_token` +and narrows the tools the agent sees to the service user's scope. diff --git a/docs/tasks/project-audit-python.md b/docs/tasks/project-audit-python.md new file mode 100644 index 0000000..e5e5dce --- /dev/null +++ b/docs/tasks/project-audit-python.md @@ -0,0 +1,120 @@ +# Project audit — Python SDK: Project module completeness +This file: /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/docs/tasks/project-audit-python.md (branch audit/project) + +## Goal +Give the Python SDK every Project-module route the TypeScript SDK already has: admin portal and legal settings, the public project config and legal pages, the developer MCP endpoint, and AI service users — sync and async, each with a test and a docs row. +Not in scope: AI plans, knowledge search, AI credits (decided internal); changes to the gateway or the TypeScript SDK. + +## Plan +1. [done] docs(sdk-python:project): write this task file before any code +2. [done] feat(sdk-python:account:settings): admin URL, legal documents, expose legal, admin portal structure, admin portal service user +3. [done] feat(sdk-python:account:ai-service-users): create, list, delete AI service users, rotate and revoke their keys +4. [done] feat(sdk-python:account:mcp): the developer MCP endpoint — send a message (POST), read the server stream (GET), end the session (DELETE) +5. [done] feat(sdk-python:api:public): new `api.public` module — public project config and public legal document, no sign-in +6. [done] docs(sdk-python:project): docs pages, docs index and README section for all of the above +7. [done] release(sdk-python:project): ruff, mypy, pytest green; push branch; open one pull request to main — https://github.com/norbix-code/sdk-python/pull/25 +8. [done] feat(sdk-python:account:settings): expose brand and expose auth switches (item B3b; gateway routes from item B1) — `update_project_expose_brand`, `update_project_expose_auth`, sync + async, tests, docs; pushed to pull request #25 + +## Checks +- `uv run ruff check .` — All checks passed +- `uv run mypy src` — Success: no issues found in 37 source files +- `uv run pytest` — 799 passed (31 new: 20 settings/service-user shapes, 7 MCP, 4 public) +- venv lives outside the repo: `UV_PROJECT_ENVIRONMENT=~/scratch/project-python/venv` +- Step 8 re-run (venv `~/scratch/project-b3b/venv`): ruff All checks passed; mypy no issues in 37 source files; pytest 803 passed (4 new: brand + auth, sync + async) + +## Changes +| file (absolute, branch audit/project) | what changed | step | +|------|--------------|------| +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/docs/tasks/project-audit-python.md | this task file | 1–7 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/src/norbix_python/hub/account.py | 5 settings methods, 5 AI service user methods, 3 MCP methods + MCP helpers (sync + async) | 2, 3, 4 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/tests/hub/test_account_project.py | new: one table, 10 routes, each sent through the sync and async client (verb, path, body, headers) | 2, 3 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/src/norbix_python/transport.py | additive send options: `extra_headers`, `query`, `response_type="envelope"` (sync + async) | 4 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/tests/hub/test_account_mcp.py | new: initialize session header, SSE answer parsing, 202 notification, stream resume, end session, 404, async twins | 4 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/src/norbix_python/api/public.py | new module `api.public`: config + legal, no sign-in (sync + async) | 5 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/src/norbix_python/api/__init__.py | `public` added to both API namespaces | 5 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/src/norbix_python/client.py | `NorbixApi.public` / `.Public` | 5 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/tests/api/test_public.py | new: API host, path, no Authorization even when signed in, NorbixApi flat access, async twin | 5 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/docs/hub/account.md | 13 rows + "The developer MCP endpoint" section | 6 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/docs/api/public.md | new page | 6 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/docs/api/_index.md, /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/docs/hub/_index.md | `public` row; account count 36 → 56 | 6 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/README.md | module list mentions `client.public`; new section "Project settings, public pages, MCP and AI service users" | 6 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/src/norbix_python/hub/account.py | `update_project_expose_brand`, `update_project_expose_auth` (sync + async) | 8 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/tests/hub/test_account_project.py | 2 table rows (brand sends `exposed: false`, so a false body is checked too) | 8 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/docs/hub/account.md, /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/docs/hub/_index.md, /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/README.md | 2 rows; account count 56 → 58; README sentence on the two switches | 8 | + +## Findings +fix(sdk-python:client:api): the flat `NorbixApi` client has no `ai`, although `api.ai` exists since the AI pull request #23 — open, not fixed here + where: /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/src/norbix_python/client.py:243 (branch audit/project) +```python +# src/norbix_python/client.py:242-248 (audit/project) + api = ApiNamespace(self._transport) + self.database = api.database + self.membership = api.membership + self.echo = api.echo + self.files = api.files + self.public = api.public # added in step 5 + # <-- missing: self.ai = api.ai (and self.Ai) +``` + +decision(sdk-python:account:settings): new methods use scope `project`; the older project-settings methods use `account`, which needlessly demands `account_id` on the client — open + where: /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/src/norbix_python/hub/account.py:325 (branch audit/project) +```python +# src/norbix_python/hub/account.py:325-333 (audit/project) + def update_project_accent_color(self, project_id: str, *, ...) -> Any: + """PATCH /{version}/account/projects/{projectId}/settings/accent-color""" + return self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/accent-color", + method="PATCH", + path_params={"projectId": project_id}, + request=request, + scope="account", # <-- here: gateway reads the account from the session; TS uses 'project' +``` + +fix(sdk-ts:api:public): the TypeScript `getPublicProjectConfig` / `getPublicProjectLegal` demand a token, but the gateway routes are anonymous — open, other repo + where: /Users/djovaisas/Projects/norbix/sdks/norbix-js src/api/public.ts:28 and :47 (branch origin/main b7230dc, not edited) +```ts +// norbix-js src/api/public.ts:24-29 (origin/main) + path: '/{version}/public/projects/{ProjectId}/config', + method: 'GET', + request, + pathParams: ['ProjectId'], + scope: 'project', // <-- here: needs a token; gateway has no [Authenticate] on this route +``` + +fix(sdk-ts:account:mcp): the TypeScript `mcp` only sends POST and cannot read the `Mcp-Session-Id` answer header, so no session can follow `initialize` — open, other repo + where: /Users/djovaisas/Projects/norbix/sdks/norbix-js src/hub/account.ts:2055-2068 (branch origin/main b7230dc, not edited) +```ts +// norbix-js src/hub/account.ts:2059-2066 (origin/main) + return this.transport.send({ + target: 'hub', + path: '/{version}/account/mcp', + method: 'POST', // <-- here: GET (stream) and DELETE (end session) aliases unreachable + request, + pathParams: [], + scope: 'project', +``` + +fix(sdk-python:transport): the body drops every `None` value, so a caller cannot send an explicit null (admin URL reset needs `url=""` instead) — open, documented in the docstring + where: /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/src/norbix_python/transport.py:393 (branch audit/project) +```python +# src/norbix_python/transport.py:393 (audit/project) + remaining = {k: v for k, v in request.items() if v is not None} # <-- here: explicit null never reaches the gateway +``` + +docs(sdk-python:docs:index): endpoint counts in the docs index pages are stale (hub ai says 14, page has 20; hub email 1 vs 2; api database 18 vs 20; api files 10 vs 9; push and sms pages missing from the index) — open; only account and public fixed here + where: /Users/djovaisas/Projects/norbix/worktrees/sdks/norbix-python/audit/project/docs/hub/_index.md:6 (branch audit/project) + +docs(sdk-python:hub:account): the two new switches have no matching request DTO class in the SDK — none needed, the Python SDK sends `**request` keyword bodies with no generated types — no action + +## Rejected / moved out +- decision(sdk-python:account:ai): AI plans, knowledge search and AI credits not added — rejected — decided internal by the campaign brief — none +- decision(sdk-python:account:mcp): one TS-style `mcp` for all three verbs not copied; three methods instead (`mcp` POST, `mcp_stream` GET, `mcp_end_session` DELETE), because each verb needs different headers and arguments — rejected (by design) — none +- decision(sdk-python:account:mcp): no real streaming for `mcp_stream`; it returns when the server closes the stream or the timeout ends, because the SDK transport is request/response only — rejected (by design) — none + +## Needs you +- [ ] release(sdk-python:project): review and squash-merge the pull request to main (expected next version: minor, no breaking change) — needs you · action: merge the PR listed in the final message +- [ ] decision(sdk-python:findings): route the findings above (NorbixApi `ai`, settings scope, TS public scope, TS mcp, null bodies, docs counts) — needs you · action: pick fix-here / own branch / hand out + +## Open questions +- none diff --git a/src/norbix_python/api/__init__.py b/src/norbix_python/api/__init__.py index d5a3056..f64b205 100644 --- a/src/norbix_python/api/__init__.py +++ b/src/norbix_python/api/__init__.py @@ -6,6 +6,7 @@ from .echo import AsyncEchoModule, EchoModule from .files import AsyncFilesModule, FilesModule from .membership import AsyncMembershipModule, MembershipModule +from .public import AsyncPublicModule, PublicModule class ApiNamespace: @@ -15,6 +16,7 @@ def __init__(self, transport: Transport) -> None: self.echo = EchoModule(transport) self.files = FilesModule(transport) self.membership = MembershipModule(transport) + self.public = PublicModule(transport) class AsyncApiNamespace: @@ -24,3 +26,4 @@ def __init__(self, transport: AsyncTransport) -> None: self.echo = AsyncEchoModule(transport) self.files = AsyncFilesModule(transport) self.membership = AsyncMembershipModule(transport) + self.public = AsyncPublicModule(transport) diff --git a/src/norbix_python/api/public.py b/src/norbix_python/api/public.py new file mode 100644 index 0000000..003994c --- /dev/null +++ b/src/norbix_python/api/public.py @@ -0,0 +1,113 @@ +"""Public project pages served by the API host — no sign-in. + +The API-host twin of the TypeScript SDK's ``src/api/public.ts``. +""" +from __future__ import annotations + +from typing import Any + +from ..transport import AsyncTransport, Transport + + +class PublicModule: + def __init__(self, transport: Transport) -> None: + self._transport = transport + + def get_public_project_config(self, project_id: str, *, timeout: float | None = None, **request: Any) -> Any: + """GET /{version}/public/projects/{ProjectId}/config + + The project's public, non-sensitive config — what an Admin Portal or a + sign-in page needs before anyone signs in: ``displayName``, the + Admin Portal switch, the brand block (when the project exposes its + brand), social providers, passkey, and sign-in methods with the + password policy (only when the project exposes them). + + **No sign-in.** The gateway does not authenticate this route, and the + SDK sends no ``Authorization`` header even when the client has one. + An unknown project gives back an empty config, not an error. + Request DTO: GetPublicProjectConfig. + """ + return self._transport.send( + target="api", + path="/{version}/public/projects/{ProjectId}/config", + method="GET", + path_params={"ProjectId": project_id}, + request=request, + scope="unauthenticated", + timeout=timeout, + ) + + def get_public_project_legal(self, project_id: str, kind: str, *, timeout: float | None = None, **request: Any) -> Any: + """GET /{version}/public/projects/{ProjectId}/legal/{Kind} + + The project's public legal document: ``kind`` is ``"terms"`` or + ``"privacy"``. Gives back ``{kind, title, body, available}`` with the + Markdown in ``body``. ``available`` is ``False`` when the project has + not turned the legal pages on (``hub.account.update_project_expose_legal``), + has not written that document, or does not exist — the answer does + not say which. + + **No sign-in**, same as ``get_public_project_config``. + Request DTO: GetPublicProjectLegal. + """ + return self._transport.send( + target="api", + path="/{version}/public/projects/{ProjectId}/legal/{Kind}", + method="GET", + path_params={"ProjectId": project_id, "Kind": kind}, + request=request, + scope="unauthenticated", + timeout=timeout, + ) + + +class AsyncPublicModule: + def __init__(self, transport: AsyncTransport) -> None: + self._transport = transport + + async def get_public_project_config(self, project_id: str, *, timeout: float | None = None, **request: Any) -> Any: + """GET /{version}/public/projects/{ProjectId}/config + + The project's public, non-sensitive config — what an Admin Portal or a + sign-in page needs before anyone signs in: ``displayName``, the + Admin Portal switch, the brand block (when the project exposes its + brand), social providers, passkey, and sign-in methods with the + password policy (only when the project exposes them). + + **No sign-in.** The gateway does not authenticate this route, and the + SDK sends no ``Authorization`` header even when the client has one. + An unknown project gives back an empty config, not an error. + Request DTO: GetPublicProjectConfig. + """ + return await self._transport.send( + target="api", + path="/{version}/public/projects/{ProjectId}/config", + method="GET", + path_params={"ProjectId": project_id}, + request=request, + scope="unauthenticated", + timeout=timeout, + ) + + async def get_public_project_legal(self, project_id: str, kind: str, *, timeout: float | None = None, **request: Any) -> Any: + """GET /{version}/public/projects/{ProjectId}/legal/{Kind} + + The project's public legal document: ``kind`` is ``"terms"`` or + ``"privacy"``. Gives back ``{kind, title, body, available}`` with the + Markdown in ``body``. ``available`` is ``False`` when the project has + not turned the legal pages on (``hub.account.update_project_expose_legal``), + has not written that document, or does not exist — the answer does + not say which. + + **No sign-in**, same as ``get_public_project_config``. + Request DTO: GetPublicProjectLegal. + """ + return await self._transport.send( + target="api", + path="/{version}/public/projects/{ProjectId}/legal/{Kind}", + method="GET", + path_params={"ProjectId": project_id, "Kind": kind}, + request=request, + scope="unauthenticated", + timeout=timeout, + ) diff --git a/src/norbix_python/client.py b/src/norbix_python/client.py index 59433f3..2e2fdf2 100644 --- a/src/norbix_python/client.py +++ b/src/norbix_python/client.py @@ -244,10 +244,12 @@ def __init__( self.membership = api.membership self.echo = api.echo self.files = api.files + self.public = api.public self.Database = self.database self.Membership = self.membership self.Echo = self.echo self.Files = self.files + self.Public = self.public def close(self) -> None: self._transport.close() diff --git a/src/norbix_python/hub/account.py b/src/norbix_python/hub/account.py index efde193..ee05467 100644 --- a/src/norbix_python/hub/account.py +++ b/src/norbix_python/hub/account.py @@ -1,9 +1,67 @@ from __future__ import annotations +import json from typing import Any from ..transport import AsyncTransport, Transport +# ---------------------------------------------------------------------- +# The developer MCP endpoint (Streamable HTTP, MCP revision 2025-11-25). +# One route, three verbs. The session id travels in the Mcp-Session-Id +# response header of ``initialize``, so these calls give back an envelope: +# {"status": int, "sessionId": str | None, "body": the JSON-RPC answer +# (dict) or None, "events": [JSON-RPC messages from an SSE answer]} +# ---------------------------------------------------------------------- + +_MCP_PATH = "/{version}/account/mcp" + + +def _mcp_headers( + accept: str, + session_id: str | None, + protocol_version: str | None, + last_event_id: str | None = None, +) -> dict[str, str]: + headers = {"Accept": accept} + if session_id: + headers["Mcp-Session-Id"] = session_id + if protocol_version: + headers["MCP-Protocol-Version"] = protocol_version + if last_event_id: + headers["Last-Event-ID"] = last_event_id + return headers + + +def _sse_messages(text: str) -> list[Any]: + """The JSON messages of an SSE body; events with no JSON data (priming, ping) are skipped.""" + messages: list[Any] = [] + for block in text.replace("\r\n", "\n").split("\n\n"): + data = "\n".join(line[5:].lstrip(" ") for line in block.split("\n") if line.startswith("data:")) + if not data: + continue + try: + messages.append(json.loads(data)) + except ValueError: + continue + return messages + + +def _mcp_result(envelope: dict[str, Any]) -> dict[str, Any]: + headers = envelope["headers"] + raw = envelope["body"] + events: list[Any] = [] + body: Any = raw + if "text/event-stream" in headers.get("content-type", "") and isinstance(raw, str): + events = _sse_messages(raw) + answers = [m for m in events if isinstance(m, dict) and "id" in m and ("result" in m or "error" in m)] + body = answers[-1] if answers else None + return { + "status": envelope["status"], + "sessionId": headers.get("mcp-session-id"), + "body": body, + "events": events, + } + class AccountModule: def __init__(self, transport: Transport) -> None: @@ -576,6 +634,330 @@ def set_admin_portal_enabled(self, project_id: str, *, timeout: float | None = N bearer_token=bearer_token, ) + def update_project_admin_url(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """PATCH /{version}/account/projects/{projectId}/settings/admin-url + + Overrides the project's Admin Portal URL. Body: ``url``. Pass ``url=""`` to go back + to the standard ``pr_{id}.admin.{host}`` address (a ``None`` value is not sent). + Request DTO: UpdateProjectAdminUrl. + """ + return self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/admin-url", + method="PATCH", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def update_project_legal_documents(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """PATCH /{version}/account/projects/{projectId}/settings/legal + + Saves the project's Terms and Privacy Policy as Markdown. + Body: ``termsMarkdown``, ``privacyMarkdown``. Both are replaced on every call: + a field left out or empty clears that document. + Request DTO: UpdateProjectLegalDocuments. + """ + return self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/legal", + method="PATCH", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def update_project_expose_legal(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """PATCH /{version}/account/projects/{projectId}/settings/legal/expose + + Turns the public legal pages on or off. Body: ``exposed`` (bool). + When on, ``api.public.get_public_project_legal`` serves the documents. + Request DTO: UpdateProjectExposeLegal. + """ + return self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/legal/expose", + method="PATCH", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def update_project_expose_brand(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """PATCH /{version}/account/projects/{projectId}/settings/brand/expose + + Sets whether the brand (name, colors, logo, icon) is returned by the public + Admin Portal config, ``api.public.get_public_project_config``. On by default. + Body: ``exposed`` (bool). + Request DTO: UpdateProjectExposeBrand. + """ + return self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/brand/expose", + method="PATCH", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def update_project_expose_auth(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """PATCH /{version}/account/projects/{projectId}/settings/auth/expose + + Sets whether the sign-in methods (email / phone / username) and the password + policy are returned by the public Admin Portal config, + ``api.public.get_public_project_config``. Off by default. Body: ``exposed`` (bool). + Request DTO: UpdateProjectExposeAuth. + """ + return self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/auth/expose", + method="PATCH", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def get_admin_portal_structure(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/account/projects/{projectId}/admin-portal/structure + + Reads what the project's Admin Portal shows: ``projectId``, ``adminPortalEnabled``, + ``displayName`` and the ``modules`` in its navigation. + Request DTO: GetAdminPortalStructure. + """ + return self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/admin-portal/structure", + method="GET", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def assign_admin_portal_service_user(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """PUT /{version}/account/projects/{projectId}/settings/admin-portal/service-user + + Assigns an existing service user as the project's Admin Portal service user. + Body: ``serviceUserId`` (required). + Request DTO: AssignAdminPortalServiceUserRequest. + """ + return self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/admin-portal/service-user", + method="PUT", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def create_ai_service_user(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/account/ai/service-users + + Creates an AI service user — a scoped credential for an AI agent (for example an MCP client). + Body: ``name`` (required) and ``scope`` (required) — a dict with ``reach``, + ``projectId``, ``rights`` and ``envs`` that says what the agent may touch. + The answer carries the new key once; store it, it is never shown again. + Request DTO: CreateAiServiceUserRequest. + """ + return self._transport.send( + target="hub", + path="/{version}/account/ai/service-users", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def list_ai_service_users(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/account/ai/service-users + + Lists the account's AI service users and their keys (key values are never returned). + Request DTO: ListAiServiceUsersRequest. + """ + return self._transport.send( + target="hub", + path="/{version}/account/ai/service-users", + method="GET", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def rotate_ai_service_user_key(self, service_user_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/account/ai/service-users/{Id}/keys + + Issues a new key for an AI service user (``aisu_…``). Optional body ``revokeKeyId`` + revokes an old key (``aisk_…``) in the same call. The new key is shown once. + Request DTO: RotateAiServiceUserKeyRequest. + """ + return self._transport.send( + target="hub", + path="/{version}/account/ai/service-users/{Id}/keys", + method="POST", + path_params={"Id": service_user_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def revoke_ai_service_user_key(self, service_user_id: str, key_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """DELETE /{version}/account/ai/service-users/{Id}/keys/{KeyId} + + Revokes one key (``aisk_…``) of an AI service user (``aisu_…``). + Request DTO: RevokeAiServiceUserKeyRequest. + """ + return self._transport.send( + target="hub", + path="/{version}/account/ai/service-users/{Id}/keys/{KeyId}", + method="DELETE", + path_params={"Id": service_user_id, "KeyId": key_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def delete_ai_service_user(self, service_user_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """DELETE /{version}/account/ai/service-users/{Id} + + Deletes an AI service user (``aisu_…``) and every key it has. + Request DTO: DeleteAiServiceUserRequest. + """ + return self._transport.send( + target="hub", + path="/{version}/account/ai/service-users/{Id}", + method="DELETE", + path_params={"Id": service_user_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def mcp( + self, + message: dict[str, Any], + *, + session_id: str | None = None, + protocol_version: str | None = None, + toolsets: str | None = None, + timeout: float | None = None, + bearer_token: str | None = None, + ) -> dict[str, Any]: + """POST /{version}/account/mcp + + Sends one JSON-RPC 2.0 message to the Norbix MCP server + (``initialize``, ``tools/list``, ``tools/call``, ``prompts/*``, + ``resources/*``, ``ping``, or a notification). + + Start with ``initialize``: the answer's ``sessionId`` must be passed as + ``session_id`` on every later call. ``toolsets`` narrows ``tools/list`` + (for example ``"ai:campaigns,ai:project-context"``). + + Gives back ``{"status", "sessionId", "body", "events"}``. ``body`` is + the JSON-RPC answer (``None`` for a notification, answered 202). A + ``tools/call`` may be answered as an SSE stream: then ``events`` holds + every message of the stream and ``body`` is the final answer. + + Signs in with the client's token — a dashboard session, a JWT, or an + AI service user key (``nbsu_…``, see ``create_ai_service_user``). + A missing or expired session raises ``NorbixError`` (400 / 404). + """ + envelope = self._transport.send( + target="hub", + path=_MCP_PATH, + method="POST", + path_params={}, + request=dict(message), + scope="project", + timeout=timeout, + bearer_token=bearer_token, + response_type="envelope", + extra_headers=_mcp_headers("application/json, text/event-stream", session_id, protocol_version), + query={"toolsets": toolsets}, + ) + return _mcp_result(envelope) + + def mcp_stream( + self, + session_id: str, + *, + last_event_id: str | None = None, + protocol_version: str | None = None, + toolsets: str | None = None, + timeout: float | None = None, + bearer_token: str | None = None, + ) -> dict[str, Any]: + """GET /{version}/account/mcp + + Reads the server-to-client SSE stream of a session (server + notifications such as ``notifications/tools/list_changed``). + ``last_event_id`` resumes a dropped stream. + + This SDK does not stream: the call returns when the server closes the + stream (after its maximum stream time) or when ``timeout`` runs out — + set ``timeout`` above the server's stream time. ``events`` holds the + messages received. + """ + envelope = self._transport.send( + target="hub", + path=_MCP_PATH, + method="GET", + path_params={}, + request={}, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + response_type="envelope", + extra_headers=_mcp_headers("text/event-stream", session_id, protocol_version, last_event_id), + query={"toolsets": toolsets}, + ) + return _mcp_result(envelope) + + def mcp_end_session( + self, + session_id: str, + *, + protocol_version: str | None = None, + timeout: float | None = None, + bearer_token: str | None = None, + ) -> dict[str, Any]: + """DELETE /{version}/account/mcp + + Ends the MCP session named by ``session_id``. + """ + envelope = self._transport.send( + target="hub", + path=_MCP_PATH, + method="DELETE", + path_params={}, + request={}, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + response_type="envelope", + extra_headers=_mcp_headers("application/json", session_id, protocol_version), + ) + return _mcp_result(envelope) + class AsyncAccountModule: def __init__(self, transport: AsyncTransport) -> None: @@ -1147,3 +1529,327 @@ async def set_admin_portal_enabled(self, project_id: str, *, timeout: float | No timeout=timeout, bearer_token=bearer_token, ) + + async def update_project_admin_url(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """PATCH /{version}/account/projects/{projectId}/settings/admin-url + + Overrides the project's Admin Portal URL. Body: ``url``. Pass ``url=""`` to go back + to the standard ``pr_{id}.admin.{host}`` address (a ``None`` value is not sent). + Request DTO: UpdateProjectAdminUrl. + """ + return await self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/admin-url", + method="PATCH", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def update_project_legal_documents(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """PATCH /{version}/account/projects/{projectId}/settings/legal + + Saves the project's Terms and Privacy Policy as Markdown. + Body: ``termsMarkdown``, ``privacyMarkdown``. Both are replaced on every call: + a field left out or empty clears that document. + Request DTO: UpdateProjectLegalDocuments. + """ + return await self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/legal", + method="PATCH", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def update_project_expose_legal(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """PATCH /{version}/account/projects/{projectId}/settings/legal/expose + + Turns the public legal pages on or off. Body: ``exposed`` (bool). + When on, ``api.public.get_public_project_legal`` serves the documents. + Request DTO: UpdateProjectExposeLegal. + """ + return await self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/legal/expose", + method="PATCH", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def update_project_expose_brand(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """PATCH /{version}/account/projects/{projectId}/settings/brand/expose + + Sets whether the brand (name, colors, logo, icon) is returned by the public + Admin Portal config, ``api.public.get_public_project_config``. On by default. + Body: ``exposed`` (bool). + Request DTO: UpdateProjectExposeBrand. + """ + return await self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/brand/expose", + method="PATCH", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def update_project_expose_auth(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """PATCH /{version}/account/projects/{projectId}/settings/auth/expose + + Sets whether the sign-in methods (email / phone / username) and the password + policy are returned by the public Admin Portal config, + ``api.public.get_public_project_config``. Off by default. Body: ``exposed`` (bool). + Request DTO: UpdateProjectExposeAuth. + """ + return await self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/auth/expose", + method="PATCH", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def get_admin_portal_structure(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/account/projects/{projectId}/admin-portal/structure + + Reads what the project's Admin Portal shows: ``projectId``, ``adminPortalEnabled``, + ``displayName`` and the ``modules`` in its navigation. + Request DTO: GetAdminPortalStructure. + """ + return await self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/admin-portal/structure", + method="GET", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def assign_admin_portal_service_user(self, project_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """PUT /{version}/account/projects/{projectId}/settings/admin-portal/service-user + + Assigns an existing service user as the project's Admin Portal service user. + Body: ``serviceUserId`` (required). + Request DTO: AssignAdminPortalServiceUserRequest. + """ + return await self._transport.send( + target="hub", + path="/{version}/account/projects/{projectId}/settings/admin-portal/service-user", + method="PUT", + path_params={"projectId": project_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def create_ai_service_user(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/account/ai/service-users + + Creates an AI service user — a scoped credential for an AI agent (for example an MCP client). + Body: ``name`` (required) and ``scope`` (required) — a dict with ``reach``, + ``projectId``, ``rights`` and ``envs`` that says what the agent may touch. + The answer carries the new key once; store it, it is never shown again. + Request DTO: CreateAiServiceUserRequest. + """ + return await self._transport.send( + target="hub", + path="/{version}/account/ai/service-users", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def list_ai_service_users(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/account/ai/service-users + + Lists the account's AI service users and their keys (key values are never returned). + Request DTO: ListAiServiceUsersRequest. + """ + return await self._transport.send( + target="hub", + path="/{version}/account/ai/service-users", + method="GET", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def rotate_ai_service_user_key(self, service_user_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/account/ai/service-users/{Id}/keys + + Issues a new key for an AI service user (``aisu_…``). Optional body ``revokeKeyId`` + revokes an old key (``aisk_…``) in the same call. The new key is shown once. + Request DTO: RotateAiServiceUserKeyRequest. + """ + return await self._transport.send( + target="hub", + path="/{version}/account/ai/service-users/{Id}/keys", + method="POST", + path_params={"Id": service_user_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def revoke_ai_service_user_key(self, service_user_id: str, key_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """DELETE /{version}/account/ai/service-users/{Id}/keys/{KeyId} + + Revokes one key (``aisk_…``) of an AI service user (``aisu_…``). + Request DTO: RevokeAiServiceUserKeyRequest. + """ + return await self._transport.send( + target="hub", + path="/{version}/account/ai/service-users/{Id}/keys/{KeyId}", + method="DELETE", + path_params={"Id": service_user_id, "KeyId": key_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def delete_ai_service_user(self, service_user_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """DELETE /{version}/account/ai/service-users/{Id} + + Deletes an AI service user (``aisu_…``) and every key it has. + Request DTO: DeleteAiServiceUserRequest. + """ + return await self._transport.send( + target="hub", + path="/{version}/account/ai/service-users/{Id}", + method="DELETE", + path_params={"Id": service_user_id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def mcp( + self, + message: dict[str, Any], + *, + session_id: str | None = None, + protocol_version: str | None = None, + toolsets: str | None = None, + timeout: float | None = None, + bearer_token: str | None = None, + ) -> dict[str, Any]: + """POST /{version}/account/mcp + + Sends one JSON-RPC 2.0 message to the Norbix MCP server + (``initialize``, ``tools/list``, ``tools/call``, ``prompts/*``, + ``resources/*``, ``ping``, or a notification). + + Start with ``initialize``: the answer's ``sessionId`` must be passed as + ``session_id`` on every later call. ``toolsets`` narrows ``tools/list`` + (for example ``"ai:campaigns,ai:project-context"``). + + Gives back ``{"status", "sessionId", "body", "events"}``. ``body`` is + the JSON-RPC answer (``None`` for a notification, answered 202). A + ``tools/call`` may be answered as an SSE stream: then ``events`` holds + every message of the stream and ``body`` is the final answer. + + Signs in with the client's token — a dashboard session, a JWT, or an + AI service user key (``nbsu_…``, see ``create_ai_service_user``). + A missing or expired session raises ``NorbixError`` (400 / 404). + """ + envelope = await self._transport.send( + target="hub", + path=_MCP_PATH, + method="POST", + path_params={}, + request=dict(message), + scope="project", + timeout=timeout, + bearer_token=bearer_token, + response_type="envelope", + extra_headers=_mcp_headers("application/json, text/event-stream", session_id, protocol_version), + query={"toolsets": toolsets}, + ) + return _mcp_result(envelope) + + async def mcp_stream( + self, + session_id: str, + *, + last_event_id: str | None = None, + protocol_version: str | None = None, + toolsets: str | None = None, + timeout: float | None = None, + bearer_token: str | None = None, + ) -> dict[str, Any]: + """GET /{version}/account/mcp + + Reads the server-to-client SSE stream of a session (server + notifications such as ``notifications/tools/list_changed``). + ``last_event_id`` resumes a dropped stream. + + This SDK does not stream: the call returns when the server closes the + stream (after its maximum stream time) or when ``timeout`` runs out — + set ``timeout`` above the server's stream time. ``events`` holds the + messages received. + """ + envelope = await self._transport.send( + target="hub", + path=_MCP_PATH, + method="GET", + path_params={}, + request={}, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + response_type="envelope", + extra_headers=_mcp_headers("text/event-stream", session_id, protocol_version, last_event_id), + query={"toolsets": toolsets}, + ) + return _mcp_result(envelope) + + async def mcp_end_session( + self, + session_id: str, + *, + protocol_version: str | None = None, + timeout: float | None = None, + bearer_token: str | None = None, + ) -> dict[str, Any]: + """DELETE /{version}/account/mcp + + Ends the MCP session named by ``session_id``. + """ + envelope = await self._transport.send( + target="hub", + path=_MCP_PATH, + method="DELETE", + path_params={}, + request={}, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + response_type="envelope", + extra_headers=_mcp_headers("application/json", session_id, protocol_version), + ) + return _mcp_result(envelope) diff --git a/src/norbix_python/transport.py b/src/norbix_python/transport.py index 331c5fa..35a94b0 100644 --- a/src/norbix_python/transport.py +++ b/src/norbix_python/transport.py @@ -19,7 +19,10 @@ # "binary" gives back the raw bytes — for an endpoint that answers with a # file rather than a document, such as a public file link. Parsing a PDF as # JSON quietly hands back its text instead (10b-files slice SDK-2). -ResponseType = Literal["json", "binary"] +# "envelope" gives back {"status", "headers", "body"} without the business +# refusal check — for a protocol endpoint whose answer headers matter, such +# as the MCP endpoint (its session id travels in a response header). +ResponseType = Literal["json", "binary", "envelope"] Target = Literal["api", "hub"] _IDEMPOTENT_VERBS: frozenset[str] = frozenset({"GET", "DELETE"}) @@ -67,6 +70,8 @@ def send( region: str | None = None, response_type: ResponseType = "json", follow_redirects: bool = False, + extra_headers: dict[str, str] | None = None, + query: dict[str, Any] | None = None, ) -> Any: if scope == "account" and not self._cfg.account_id: raise NorbixError( @@ -87,6 +92,7 @@ def send( path_params=params, request=req, ) + url = _with_query(url, query) headers = {"Accept": "application/json", **self._cfg.default_headers} if scope != "unauthenticated": @@ -114,6 +120,10 @@ def send( headers["nb-region"] = resolved_region if body is not None: headers["Content-Type"] = "application/json" + # Per-call headers win over everything above (e.g. the MCP endpoint + # needs its own Accept and its session headers). + if extra_headers: + headers.update(extra_headers) try: response = self._request_with_retries( @@ -142,6 +152,8 @@ def send( # are not JSON, so the check below does not apply to them. if response_type == "binary": return response.content + if response_type == "envelope": + return _envelope(response) if response.status_code == 204 or not response.content: return None try: @@ -216,6 +228,8 @@ async def send( region: str | None = None, response_type: ResponseType = "json", follow_redirects: bool = False, + extra_headers: dict[str, str] | None = None, + query: dict[str, Any] | None = None, ) -> Any: if scope == "account" and not self._cfg.account_id: raise NorbixError( @@ -236,6 +250,7 @@ async def send( path_params=params, request=req, ) + url = _with_query(url, query) headers = {"Accept": "application/json", **self._cfg.default_headers} if scope != "unauthenticated": @@ -263,6 +278,10 @@ async def send( headers["nb-region"] = resolved_region if body is not None: headers["Content-Type"] = "application/json" + # Per-call headers win over everything above (e.g. the MCP endpoint + # needs its own Accept and its session headers). + if extra_headers: + headers.update(extra_headers) try: response = await self._request_with_retries( @@ -291,6 +310,8 @@ async def send( # are not JSON, so the check below does not apply to them. if response_type == "binary": return response.content + if response_type == "envelope": + return _envelope(response) if response.status_code == 204 or not response.content: return None try: @@ -380,6 +401,31 @@ def _build_url_and_body( return (url, remaining if remaining else None) +def _with_query(url: str, query: dict[str, Any] | None) -> str: + """Append query parameters that always go in the URL, whatever the verb.""" + if not query: + return url + pairs = [(k, _stringify(v)) for k, v in query.items() if v is not None] + if not pairs: + return url + return f"{url}{'&' if '?' in url else '?'}{urlencode(pairs)}" + + +def _envelope(response: httpx.Response) -> dict[str, Any]: + """Status, headers (lower-case names) and body (parsed JSON, else text, else None).""" + body: Any = None + if response.content: + try: + body = response.json() + except ValueError: + body = response.text + return { + "status": response.status_code, + "headers": {k.lower(): v for k, v in response.headers.items()}, + "body": body, + } + + def _to_iterable(value: Any) -> list[Any]: if isinstance(value, list): return value diff --git a/tests/api/test_public.py b/tests/api/test_public.py new file mode 100644 index 0000000..d2ebf27 --- /dev/null +++ b/tests/api/test_public.py @@ -0,0 +1,86 @@ +"""api.public — the public project config and legal pages, served by the API host with no sign-in.""" +from __future__ import annotations + +import asyncio +from typing import Any + +import httpx +import pytest + +from norbix_python import AsyncNorbix, Norbix, NorbixApi + +LEGAL = {"kind": "terms", "title": "Terms", "body": "# Terms", "available": True} + + +@pytest.fixture +def _no_env_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("NORBIX_API_KEY", raising=False) + monkeypatch.delenv("NORBIX_BEARER_TOKEN", raising=False) + + +def _capture(seen: list[httpx.Request], answer: Any) -> Any: + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json=answer) + + return handler + + +@pytest.mark.usefixtures("_no_env_credentials") +@pytest.mark.parametrize("bearer_token", [None, "signed-in"], ids=["no_credentials", "signed_in"]) +def test_public_project_config_goes_to_the_api_host_without_auth(bearer_token: str | None) -> None: + seen: list[httpx.Request] = [] + client = Norbix( + project_id="test-project", + bearer_token=bearer_token, + http_client=httpx.Client(transport=httpx.MockTransport(_capture(seen, {"displayName": "Shop"}))), + ) + + result = client.api.public.get_public_project_config("pr_1") + + assert result == {"displayName": "Shop"} + assert seen[0].method == "GET" + assert seen[0].url.host == "api.norbix.ai" + assert seen[0].url.path == "/v2/public/projects/pr_1/config" + assert "authorization" not in seen[0].headers + + +@pytest.mark.usefixtures("_no_env_credentials") +def test_public_project_legal_puts_project_and_kind_in_the_path() -> None: + seen: list[httpx.Request] = [] + client = NorbixApi( + project_id="test-project", + http_client=httpx.Client(transport=httpx.MockTransport(_capture(seen, LEGAL))), + ) + + result = client.public.get_public_project_legal("pr_1", "terms") + + assert result == LEGAL + assert seen[0].method == "GET" + assert seen[0].url.path == "/v2/public/projects/pr_1/legal/terms" + assert "authorization" not in seen[0].headers + assert client.Public is client.public + + +@pytest.mark.usefixtures("_no_env_credentials") +def test_async_public_module_sends_the_same_requests() -> None: + seen: list[httpx.Request] = [] + + async def run() -> None: + client = AsyncNorbix( + project_id="test-project", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(_capture(seen, LEGAL))), + ) + try: + await client.api.public.get_public_project_config("pr_1") + await client.api.public.get_public_project_legal("pr_1", "privacy") + finally: + await client.aclose() + + asyncio.run(run()) + + assert [r.url.path for r in seen] == [ + "/v2/public/projects/pr_1/config", + "/v2/public/projects/pr_1/legal/privacy", + ] + assert all("authorization" not in r.headers for r in seen) diff --git a/tests/hub/test_account_mcp.py b/tests/hub/test_account_mcp.py new file mode 100644 index 0000000..20f2063 --- /dev/null +++ b/tests/hub/test_account_mcp.py @@ -0,0 +1,172 @@ +"""The developer MCP endpoint: POST / GET / DELETE /{version}/account/mcp. + +The session id travels in the ``Mcp-Session-Id`` header, and a ``tools/call`` +may be answered as an SSE stream, so these tests check headers both ways and +the parsing of a stream answer. +""" +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import httpx +import pytest + +from norbix_python import AsyncNorbix, Norbix, NorbixError + +INIT = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-11-25"}} + +SSE_ANSWER = ( + "id: p1:0\nretry: 3000\ndata: \n\n" + 'id: p1:1\ndata: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":1}}\n\n' + ": ping\n\n" + 'id: p1:2\ndata: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"ok"}]}}\n\n' +) + + +def _client(handler: Any) -> Norbix: + return Norbix( + project_id="test-project", + bearer_token="nbsu_test", + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + +def test_initialize_returns_the_session_id_from_the_response_header() -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response( + 200, + headers={"Mcp-Session-Id": "mcps_1"}, + json={"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-11-25"}}, + ) + + result = _client(handler).hub.account.mcp(INIT, toolsets="ai:campaigns,ai:project-context") + + assert result == { + "status": 200, + "sessionId": "mcps_1", + "body": {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-11-25"}}, + "events": [], + } + request = seen[0] + assert request.method == "POST" + assert request.url.path == "/v2/account/mcp" + assert request.url.params["toolsets"] == "ai:campaigns,ai:project-context" + assert json.loads(request.content) == INIT + assert request.headers["accept"] == "application/json, text/event-stream" + assert request.headers["authorization"] == "Bearer nbsu_test" + assert "mcp-session-id" not in request.headers + + +def test_tools_call_answered_as_a_stream_gives_the_final_answer_and_all_events() -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, text=SSE_ANSWER) + + message = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "list_campaigns"}} + result = _client(handler).hub.account.mcp(message, session_id="mcps_1", protocol_version="2025-11-25") + + assert result["body"] == {"jsonrpc": "2.0", "id": 2, "result": {"content": [{"type": "text", "text": "ok"}]}} + assert [e.get("method") for e in result["events"]] == ["notifications/progress", None] + assert seen[0].headers["mcp-session-id"] == "mcps_1" + assert seen[0].headers["mcp-protocol-version"] == "2025-11-25" + assert "toolsets" not in seen[0].url.params + + +def test_a_notification_is_answered_202_with_no_body() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(202) + + result = _client(handler).hub.account.mcp( + {"jsonrpc": "2.0", "method": "notifications/initialized"}, session_id="mcps_1" + ) + + assert result == {"status": 202, "sessionId": None, "body": None, "events": []} + + +def test_stream_asks_for_event_stream_and_resumes_from_the_last_event() -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + body = 'id: g1:4\ndata: {"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":{}}\n\n' + return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, text=body) + + result = _client(handler).hub.account.mcp_stream("mcps_1", last_event_id="g1:3") + + assert result["events"] == [{"jsonrpc": "2.0", "method": "notifications/tools/list_changed", "params": {}}] + assert result["body"] is None + request = seen[0] + assert request.method == "GET" + assert request.url.path == "/v2/account/mcp" + assert request.url.query == b"" + assert request.headers["accept"] == "text/event-stream" + assert request.headers["mcp-session-id"] == "mcps_1" + assert request.headers["last-event-id"] == "g1:3" + + +def test_end_session_sends_delete_with_the_session_header() -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200) + + result = _client(handler).hub.account.mcp_end_session("mcps_1") + + assert result == {"status": 200, "sessionId": None, "body": None, "events": []} + assert seen[0].method == "DELETE" + assert seen[0].url.path == "/v2/account/mcp" + assert seen[0].headers["mcp-session-id"] == "mcps_1" + + +def test_an_expired_session_raises_with_the_404_status() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 404, + json={"jsonrpc": "2.0", "id": None, "error": {"code": -32001, "message": "Session not found"}}, + ) + + with pytest.raises(NorbixError) as raised: + _client(handler).hub.account.mcp({"jsonrpc": "2.0", "id": 3, "method": "ping"}, session_id="gone") + + assert raised.value.status == 404 + + +def test_async_twins_send_the_same_requests() -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, headers={"Mcp-Session-Id": "mcps_9"}, json={"jsonrpc": "2.0", "id": 1, "result": {}}) + + async def run() -> list[dict[str, Any]]: + client = AsyncNorbix( + project_id="test-project", + bearer_token="nbsu_test", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + try: + return [ + await client.hub.account.mcp(INIT), + await client.hub.account.mcp_stream("mcps_9"), + await client.hub.account.mcp_end_session("mcps_9"), + ] + finally: + await client.aclose() + + results = asyncio.run(run()) + + assert [r["sessionId"] for r in results] == ["mcps_9", "mcps_9", "mcps_9"] + assert [(r.method, r.headers["accept"]) for r in seen] == [ + ("POST", "application/json, text/event-stream"), + ("GET", "text/event-stream"), + ("DELETE", "application/json"), + ] + assert json.loads(seen[0].content) == INIT diff --git a/tests/hub/test_account_project.py b/tests/hub/test_account_project.py new file mode 100644 index 0000000..d523435 --- /dev/null +++ b/tests/hub/test_account_project.py @@ -0,0 +1,99 @@ +"""Request-shape tests for the Project-module routes of ``hub.account``. + +One table drives both clients: every case is sent through the sync +``Norbix`` and the ``AsyncNorbix`` client, and the test checks the verb, the +resolved path, the JSON body and the auth / project headers. +""" +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import httpx +import pytest + +from norbix_python import AsyncNorbix, Norbix + +# (method, positional args, keyword body, verb, path after /v2, expected JSON body or None) +CASES: list[tuple[str, tuple[str, ...], dict[str, Any], str, str, dict[str, Any] | None]] = [ + ("update_project_admin_url", ("pr_1",), {"url": "https://admin.example.com"}, "PATCH", + "/account/projects/pr_1/settings/admin-url", {"url": "https://admin.example.com"}), + ("update_project_legal_documents", ("pr_1",), {"termsMarkdown": "# Terms", "privacyMarkdown": "# Privacy"}, "PATCH", + "/account/projects/pr_1/settings/legal", {"termsMarkdown": "# Terms", "privacyMarkdown": "# Privacy"}), + ("update_project_expose_legal", ("pr_1",), {"exposed": True}, "PATCH", + "/account/projects/pr_1/settings/legal/expose", {"exposed": True}), + ("update_project_expose_brand", ("pr_1",), {"exposed": False}, "PATCH", + "/account/projects/pr_1/settings/brand/expose", {"exposed": False}), + ("update_project_expose_auth", ("pr_1",), {"exposed": True}, "PATCH", + "/account/projects/pr_1/settings/auth/expose", {"exposed": True}), + ("get_admin_portal_structure", ("pr_1",), {}, "GET", + "/account/projects/pr_1/admin-portal/structure", None), + ("assign_admin_portal_service_user", ("pr_1",), {"serviceUserId": "su_1"}, "PUT", + "/account/projects/pr_1/settings/admin-portal/service-user", {"serviceUserId": "su_1"}), + ("create_ai_service_user", (), {"name": "Claude Code on my laptop", "scope": {"reach": "project", "projectId": "pr_1", "rights": "read", "envs": ["TEST"]}}, "POST", + "/account/ai/service-users", {"name": "Claude Code on my laptop", "scope": {"reach": "project", "projectId": "pr_1", "rights": "read", "envs": ["TEST"]}}), + ("list_ai_service_users", (), {}, "GET", "/account/ai/service-users", None), + ("rotate_ai_service_user_key", ("aisu_1",), {"revokeKeyId": "aisk_old"}, "POST", + "/account/ai/service-users/aisu_1/keys", {"revokeKeyId": "aisk_old"}), + ("revoke_ai_service_user_key", ("aisu_1", "aisk_1"), {}, "DELETE", + "/account/ai/service-users/aisu_1/keys/aisk_1", None), + ("delete_ai_service_user", ("aisu_1",), {}, "DELETE", "/account/ai/service-users/aisu_1", None), +] + + +def _capture(seen: list[httpx.Request]) -> Any: + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json={}) + + return handler + + +def _check(request: httpx.Request, verb: str, path: str, body: dict[str, Any] | None) -> None: + assert request.method == verb + assert request.url.path == "/v2" + path + assert request.headers["authorization"] == "Bearer test-token" + assert request.headers["x-cm-projectid"] == "test-project" + if body is None: + assert request.content == b"" + else: + assert json.loads(request.content) == body + + +@pytest.mark.parametrize(("name", "args", "kwargs", "verb", "path", "body"), CASES, ids=[c[0] for c in CASES]) +def test_sync_request_shape( + name: str, args: tuple[str, ...], kwargs: dict[str, Any], verb: str, path: str, body: dict[str, Any] | None +) -> None: + seen: list[httpx.Request] = [] + # No account_id: these routes take the account from the signed-in session. + client = Norbix( + project_id="test-project", + bearer_token="test-token", + http_client=httpx.Client(transport=httpx.MockTransport(_capture(seen))), + ) + getattr(client.hub.account, name)(*args, **kwargs) + assert len(seen) == 1 + _check(seen[0], verb, path, body) + + +@pytest.mark.parametrize(("name", "args", "kwargs", "verb", "path", "body"), CASES, ids=[c[0] for c in CASES]) +def test_async_request_shape( + name: str, args: tuple[str, ...], kwargs: dict[str, Any], verb: str, path: str, body: dict[str, Any] | None +) -> None: + seen: list[httpx.Request] = [] + + async def run() -> None: + client = AsyncNorbix( + project_id="test-project", + bearer_token="test-token", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(_capture(seen))), + ) + try: + await getattr(client.hub.account, name)(*args, **kwargs) + finally: + await client.aclose() + + asyncio.run(run()) + assert len(seen) == 1 + _check(seen[0], verb, path, body)