nene2-python 1.3.0__tar.gz → 1.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nene2_python-1.3.0 → nene2_python-1.4.0}/PKG-INFO +1 -1
- nene2_python-1.4.0/docs/field-trials/2026-05-field-trial-11.md +116 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/how-to/configure-auth.md +49 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/pyproject.toml +1 -1
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/auth/api_key.py +22 -3
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/auth/bearer_token.py +21 -2
- nene2_python-1.4.0/src/nene2/auth/local_verifier.py +36 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/auth/test_api_key.py +21 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/auth/test_bearer_token.py +55 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/uv.lock +1 -1
- nene2_python-1.3.0/src/nene2/auth/local_verifier.py +0 -17
- {nene2_python-1.3.0 → nene2_python-1.4.0}/.env.example +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/.github/workflows/ci.yml +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/.github/workflows/docs.yml +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/.github/workflows/publish.yml +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/.gitignore +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/.vitepress/config.mts +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/.vitepress/theme/custom.css +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/.vitepress/theme/index.ts +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/AGENTS.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/CHANGELOG.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/CLAUDE.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/Dockerfile +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/LICENSE +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/README.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/alembic/README +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/alembic/env.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/alembic/script.py.mako +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/alembic/versions/001_create_notes_and_tags_tables.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/alembic.ini +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/compose.yaml +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/adr/0001-toolchain.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/adr/0002-clean-architecture.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/adr/0003-security-first.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/adr/0004-ai-first-design.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/adr/0005-logging.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/adr/0006-rate-limiting.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/adr/0009-mcp-design.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/adr/0010-async-use-case.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/de/index.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/de/tutorials/getting-started.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/explanation/architecture.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/explanation/design-philosophy.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-1.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-10.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-2.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-3.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-4.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-5.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-6.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-7.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-8.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-9.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/fr/index.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/fr/tutorials/getting-started.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/how-to/add-new-domain.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/how-to/new-project.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/how-to/run-tests.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/how-to/sqlalchemy-repository.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/howto/mcp-setup.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/index.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/explanation/architecture.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/explanation/design-philosophy.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/how-to/add-new-domain.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/how-to/configure-auth.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/how-to/new-project.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/how-to/run-tests.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/how-to/sqlalchemy-repository.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/howto/mcp-setup.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/index.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/reference/api.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/reference/configuration.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/reference/framework-modules.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/tutorials/first-domain.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/ja/tutorials/getting-started.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/pt-br/index.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/pt-br/tutorials/getting-started.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/reference/api.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/reference/configuration.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/reference/framework-modules.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/roadmap.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/todo/current.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/tutorials/first-domain.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/tutorials/getting-started.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/zh/index.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/docs/zh/tutorials/getting-started.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/package-lock.json +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/package.json +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/__main__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/app.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/comment/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/comment/entity.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/comment/exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/comment/handler.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/comment/repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/comment/sqlalchemy_repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/comment/use_case.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/mcp.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/note/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/note/async_use_case.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/note/entity.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/note/exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/note/handler.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/note/repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/note/sqlalchemy_repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/note/use_case.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/schema.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/tag/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/tag/entity.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/tag/exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/tag/handler.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/tag/repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/tag/sqlalchemy_repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/example/tag/use_case.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/auth/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/auth/exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/auth/interfaces.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/config/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/config/settings.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/database/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/database/exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/database/health.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/database/interfaces.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/database/sqlalchemy_executor.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/database/utils.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/http/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/http/health.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/http/pagination.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/http/problem_details.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/log/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/log/setup.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/mcp/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/mcp/http_client.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/mcp/server.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/middleware/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/middleware/domain_exception.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/middleware/error_handler.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/middleware/request_id.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/middleware/request_logging.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/middleware/request_size_limit.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/middleware/security_headers.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/middleware/throttle.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/py.typed +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/use_case/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/use_case/protocols.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/validation/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/nene2/validation/exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/scripts/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/src/scripts/export_openapi.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/comment/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/comment/test_comment_http.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/comment/test_comment_repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/comment/test_comment_use_case.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/conftest.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/note/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/note/test_async_note_use_case.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/note/test_list_notes.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/note/test_note_repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/tag/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/tag/test_tag_repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/tag/test_tags.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/test_cors.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/example/test_mcp.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/auth/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/auth/test_token_issuer.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/database/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/database/test_transaction.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/database/test_utils.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/http/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/http/test_pagination.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/mcp/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/mcp/test_http_client.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/middleware/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/middleware/test_error_handler.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/middleware/test_request_id.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/middleware/test_request_logging.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/middleware/test_request_size_limit.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/middleware/test_security_headers.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/middleware/test_throttle.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/use_case/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/use_case/test_protocols.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/validation/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/nene2/validation/test_exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/scripts/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.4.0}/tests/scripts/test_export_openapi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nene2-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: NENE2 Python — minimal API framework following NENE2's design philosophy
|
|
5
5
|
Project-URL: Homepage, https://github.com/hideyukiMORI/nene2-python
|
|
6
6
|
Project-URL: Repository, https://github.com/hideyukiMORI/nene2-python
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Field Trial 11 — journal: BearerTokenMiddleware + HttpxMcpClient DX 検証
|
|
2
|
+
|
|
3
|
+
## Date
|
|
4
|
+
|
|
5
|
+
2026-05-20
|
|
6
|
+
|
|
7
|
+
## Baseline
|
|
8
|
+
|
|
9
|
+
- nene2-python v1.3.0(PyPI 経由)
|
|
10
|
+
- Python 3.14(uv managed)
|
|
11
|
+
- プロジェクト: **journal** — 日記管理 API
|
|
12
|
+
- エンティティ: `Entry(id, title, body, created_at)`
|
|
13
|
+
- HTTP API: ポート 8120(BearerTokenMiddleware 付き、InMemory)
|
|
14
|
+
- MCP サーバー: ポート 8121(streamable-http)
|
|
15
|
+
|
|
16
|
+
## Goal
|
|
17
|
+
|
|
18
|
+
FT9 で `HttpxMcpClient` の基本(認証なし)は確認済み。
|
|
19
|
+
今回は `BearerTokenMiddleware` で保護した HTTP API に対して MCP ツールが認証付きリクエストを送るパターンを検証する。
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Steps Taken
|
|
24
|
+
|
|
25
|
+
### 1. BearerTokenMiddleware で HTTP API を保護
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
tokens = [t.strip() for t in os.getenv("BEARER_TOKENS", "").split(",") if t.strip()]
|
|
29
|
+
if tokens:
|
|
30
|
+
app.add_middleware(BearerTokenMiddleware, verifier=LocalTokenVerifier(tokens))
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
環境変数 `BEARER_TOKENS=secret-token-1,secret-token-2` でカンマ区切りの複数トークンを設定。
|
|
34
|
+
|
|
35
|
+
### 2. MCP サーバーから認証付きで呼び出す
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
token = os.getenv("MCP_BEARER_TOKEN", "")
|
|
39
|
+
client = HttpxMcpClient(token if token else None)
|
|
40
|
+
|
|
41
|
+
@server.tool("List all journal entries.")
|
|
42
|
+
def list_entries(limit: int = 20, offset: int = 0) -> str:
|
|
43
|
+
response = client.get(API_BASE, f"/entries?limit={limit}&offset={offset}")
|
|
44
|
+
response.raise_for_error()
|
|
45
|
+
return response.body
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`MCP_BEARER_TOKEN=secret-token-1` を MCP サーバーの環境変数に設定。
|
|
49
|
+
`HttpxMcpClient(token)` が `Authorization: Bearer <token>` ヘッダーを自動付与。
|
|
50
|
+
|
|
51
|
+
### 3. 動作確認
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
# MCP → 認証付き API → 成功
|
|
55
|
+
create_entry("First Entry", "Hello from MCP!") → {"id":1,...}
|
|
56
|
+
list_entries() → {"items":[{"id":1,...}],...}
|
|
57
|
+
|
|
58
|
+
# 誤トークン → 401 → raise_for_error() → isError: true
|
|
59
|
+
HTTP 401: {"type":"...unauthorized","detail":"The provided token is invalid or expired."}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Friction Points
|
|
65
|
+
|
|
66
|
+
### FT11-1: `BearerTokenMiddleware` が `/docs`・`/openapi.json` まで保護する
|
|
67
|
+
|
|
68
|
+
- **摩擦**: ミドルウェアがすべてのパスに適用されるため、
|
|
69
|
+
FastAPI の Swagger UI (`/docs`) や OpenAPI スキーマ (`/openapi.json`) にアクセスできなくなる
|
|
70
|
+
```
|
|
71
|
+
GET /docs → 401 Unauthorized
|
|
72
|
+
GET /openapi.json → 401 Unauthorized
|
|
73
|
+
```
|
|
74
|
+
- ロードバランサーの `/health` チェックも同様にブロックされる
|
|
75
|
+
- **現状の回避策**: 開発時は `BEARER_TOKENS=` を空にして認証を無効化
|
|
76
|
+
- **深刻度**: HIGH(開発・運用の両方で問題になる。特にヘルスチェックのブロックは本番障害につながる)
|
|
77
|
+
- **解決策**: `BearerTokenMiddleware(app, verifier=..., exclude_paths=["/docs", "/openapi.json", "/health"])` で除外パスを指定できるようにする
|
|
78
|
+
|
|
79
|
+
### FT11-2: `MCP_BEARER_TOKEN` 未設定時に分かりにくい 401 エラー
|
|
80
|
+
|
|
81
|
+
- **摩擦**: MCP サーバー起動時に `MCP_BEARER_TOKEN` が未設定の場合、
|
|
82
|
+
`HttpxMcpClient(None)` になって認証ヘッダーなしで API を呼ぶ
|
|
83
|
+
- MCP ツール呼び出しが `HTTP 401` で失敗するが、エラーメッセージからトークン未設定が原因と気づきにくい
|
|
84
|
+
```
|
|
85
|
+
McpHttpError: HTTP 401: {"detail":"A valid Bearer token is required."}
|
|
86
|
+
```
|
|
87
|
+
- **深刻度**: LOW(エラーメッセージを読めば原因はわかる)
|
|
88
|
+
- **解決策**: `mcp_server.py` でトークン未設定時に起動時警告を出す(フレームワーク対応不要、パターン文書化)
|
|
89
|
+
|
|
90
|
+
### FT11-3: `LocalTokenVerifier` がカンマ区切り文字列の分割を要求する
|
|
91
|
+
|
|
92
|
+
- **摩擦**: 複数トークンを環境変数で渡す標準パターンがなく、アプリ側でカンマ分割処理を書く必要がある
|
|
93
|
+
```python
|
|
94
|
+
tokens = [t.strip() for t in os.getenv("BEARER_TOKENS", "").split(",") if t.strip()]
|
|
95
|
+
```
|
|
96
|
+
- FT3 でも同様のコードを書いていた(重複パターン)
|
|
97
|
+
- **深刻度**: LOW(数行のコードだが、毎回書く必要がある)
|
|
98
|
+
- **解決策**: `LocalTokenVerifier.from_env("BEARER_TOKENS")` クラスメソッドを追加
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Summary
|
|
103
|
+
|
|
104
|
+
| ID | 摩擦 | 深刻度 | 解決策 |
|
|
105
|
+
|--------|----------------------------------------------------------|--------|-------------------------------------------------|
|
|
106
|
+
| FT11-1 | `BearerTokenMiddleware` が `/docs`・`/health` もブロック | HIGH | `exclude_paths` 引数を追加 |
|
|
107
|
+
| FT11-2 | `MCP_BEARER_TOKEN` 未設定時の 401 が原因不明に見える | LOW | 起動時警告パターンを文書化 |
|
|
108
|
+
| FT11-3 | `LocalTokenVerifier` の複数トークン設定にボイラープレート | LOW | `LocalTokenVerifier.from_env()` クラスメソッド |
|
|
109
|
+
|
|
110
|
+
**`HttpxMcpClient(bearer_token)` + `raise_for_error()` の組み合わせは期待通りに動作した。**
|
|
111
|
+
認証エラー(401)は `McpHttpError` として raise され、MCP の `isError: true` に正しく変換される。
|
|
112
|
+
|
|
113
|
+
FT12 候補:
|
|
114
|
+
- **FT11-1 の修正**: `BearerTokenMiddleware` に `exclude_paths` を追加
|
|
115
|
+
- **PostgreSQL アダプター**: `RETURNING` 句が使えるかを検証
|
|
116
|
+
- **SSE トランスポート**: `streamable-http` との差異を確認
|
|
@@ -81,6 +81,55 @@ class JwtTokenVerifier:
|
|
|
81
81
|
|
|
82
82
|
Pass your verifier directly to `BearerTokenMiddleware`.
|
|
83
83
|
|
|
84
|
+
## Loading tokens from environment variables
|
|
85
|
+
|
|
86
|
+
Use `LocalTokenVerifier.from_env()` to avoid writing the split-and-strip boilerplate every time:
|
|
87
|
+
|
|
88
|
+
```dotenv
|
|
89
|
+
# .env
|
|
90
|
+
BEARER_TOKENS=token-a,token-b,token-c
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from nene2.auth import BearerTokenMiddleware, LocalTokenVerifier
|
|
95
|
+
|
|
96
|
+
app.add_middleware(
|
|
97
|
+
BearerTokenMiddleware,
|
|
98
|
+
verifier=LocalTokenVerifier.from_env("BEARER_TOKENS"),
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
An unset or empty variable produces an empty allowlist — all requests are denied.
|
|
103
|
+
|
|
104
|
+
## Excluding paths from authentication
|
|
105
|
+
|
|
106
|
+
Use `exclude_paths` to bypass auth for health checks and API docs:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
app.add_middleware(
|
|
110
|
+
BearerTokenMiddleware,
|
|
111
|
+
verifier=LocalTokenVerifier.from_env("BEARER_TOKENS"),
|
|
112
|
+
exclude_paths=["/docs", "/openapi.json", "/redoc", "/health"],
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`ApiKeyAuthMiddleware` supports the same parameter.
|
|
117
|
+
|
|
118
|
+
## MCP server — fail fast on missing token
|
|
119
|
+
|
|
120
|
+
When an MCP server calls a Bearer-protected API via `HttpxMcpClient`, validate the token
|
|
121
|
+
at startup rather than discovering a missing token at call time:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import os
|
|
125
|
+
from nene2.mcp.http_client import HttpxMcpClient
|
|
126
|
+
|
|
127
|
+
token = os.getenv("MCP_BEARER_TOKEN")
|
|
128
|
+
if not token:
|
|
129
|
+
raise RuntimeError("MCP_BEARER_TOKEN is not set — cannot call the authenticated API.")
|
|
130
|
+
client = HttpxMcpClient(token)
|
|
131
|
+
```
|
|
132
|
+
|
|
84
133
|
## Custom TokenIssuer (e.g. JWT)
|
|
85
134
|
|
|
86
135
|
Implement `TokenIssuerProtocol` to issue tokens (e.g. for a login endpoint).
|
|
@@ -17,13 +17,32 @@ _API_KEY_HEADER = "X-Api-Key"
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class ApiKeyAuthMiddleware(BaseHTTPMiddleware):
|
|
20
|
-
"""Require a valid X-Api-Key header on every request.
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
"""Require a valid X-Api-Key header on every request.
|
|
21
|
+
|
|
22
|
+
Use ``exclude_paths`` to skip authentication for specific paths such as
|
|
23
|
+
health-check endpoints or API documentation::
|
|
24
|
+
|
|
25
|
+
app.add_middleware(
|
|
26
|
+
ApiKeyAuthMiddleware,
|
|
27
|
+
verifier=LocalTokenVerifier(api_keys),
|
|
28
|
+
exclude_paths=["/docs", "/openapi.json", "/redoc", "/health"],
|
|
29
|
+
)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
app: object,
|
|
35
|
+
*,
|
|
36
|
+
verifier: TokenVerifierProtocol,
|
|
37
|
+
exclude_paths: list[str] | None = None,
|
|
38
|
+
) -> None:
|
|
23
39
|
super().__init__(app) # type: ignore[arg-type]
|
|
24
40
|
self._verifier = verifier
|
|
41
|
+
self._exclude_paths = set(exclude_paths or [])
|
|
25
42
|
|
|
26
43
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
44
|
+
if request.url.path in self._exclude_paths:
|
|
45
|
+
return await call_next(request)
|
|
27
46
|
api_key = request.headers.get(_API_KEY_HEADER, "")
|
|
28
47
|
try:
|
|
29
48
|
verified = bool(api_key) and self._verifier.verify(api_key)
|
|
@@ -17,13 +17,32 @@ _WWW_AUTH = 'Bearer realm="api"'
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class BearerTokenMiddleware(BaseHTTPMiddleware):
|
|
20
|
-
"""Require a valid Bearer token on every request.
|
|
20
|
+
"""Require a valid Bearer token on every request.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Use ``exclude_paths`` to skip authentication for specific paths such as
|
|
23
|
+
health-check endpoints or API documentation::
|
|
24
|
+
|
|
25
|
+
app.add_middleware(
|
|
26
|
+
BearerTokenMiddleware,
|
|
27
|
+
verifier=LocalTokenVerifier(tokens),
|
|
28
|
+
exclude_paths=["/docs", "/openapi.json", "/redoc", "/health"],
|
|
29
|
+
)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
app: object,
|
|
35
|
+
*,
|
|
36
|
+
verifier: TokenVerifierProtocol,
|
|
37
|
+
exclude_paths: list[str] | None = None,
|
|
38
|
+
) -> None:
|
|
23
39
|
super().__init__(app) # type: ignore[arg-type]
|
|
24
40
|
self._verifier = verifier
|
|
41
|
+
self._exclude_paths = set(exclude_paths or [])
|
|
25
42
|
|
|
26
43
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
44
|
+
if request.url.path in self._exclude_paths:
|
|
45
|
+
return await call_next(request)
|
|
27
46
|
auth = request.headers.get("Authorization", "")
|
|
28
47
|
if not auth.startswith("Bearer "):
|
|
29
48
|
response = problem_details_response(
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Local token verifier — compares against a fixed set of allowed tokens.
|
|
2
|
+
|
|
3
|
+
For development and testing only. In production, implement TokenVerifierProtocol
|
|
4
|
+
against your actual auth backend (database, external IdP, JWT, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LocalTokenVerifier:
|
|
12
|
+
"""Verify tokens against a fixed allowlist using constant-time comparison."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, allowed_tokens: list[str]) -> None:
|
|
15
|
+
self._allowed = allowed_tokens
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_env(cls, env_var: str, *, separator: str = ",") -> "LocalTokenVerifier":
|
|
19
|
+
"""Create a verifier from a separator-delimited environment variable.
|
|
20
|
+
|
|
21
|
+
Example .env::
|
|
22
|
+
|
|
23
|
+
BEARER_TOKENS = token - a, token - b, token - c
|
|
24
|
+
|
|
25
|
+
Usage::
|
|
26
|
+
|
|
27
|
+
verifier = LocalTokenVerifier.from_env("BEARER_TOKENS")
|
|
28
|
+
|
|
29
|
+
An unset or empty variable results in an empty allowlist (all requests denied).
|
|
30
|
+
"""
|
|
31
|
+
raw = os.getenv(env_var, "")
|
|
32
|
+
tokens = [t.strip() for t in raw.split(separator) if t.strip()]
|
|
33
|
+
return cls(tokens)
|
|
34
|
+
|
|
35
|
+
def verify(self, token: str) -> bool:
|
|
36
|
+
return any(secrets.compare_digest(token, allowed) for allowed in self._allowed)
|
|
@@ -46,6 +46,27 @@ def test_multiple_allowed_keys() -> None:
|
|
|
46
46
|
assert client.get("/secret", headers={"X-Api-Key": "key-c"}).status_code == 401
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def test_exclude_paths_bypasses_auth() -> None:
|
|
50
|
+
app = FastAPI()
|
|
51
|
+
app.add_middleware(
|
|
52
|
+
ApiKeyAuthMiddleware,
|
|
53
|
+
verifier=LocalTokenVerifier(["key"]),
|
|
54
|
+
exclude_paths=["/health"],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@app.get("/health")
|
|
58
|
+
async def health() -> JSONResponse:
|
|
59
|
+
return JSONResponse({"status": "ok"})
|
|
60
|
+
|
|
61
|
+
@app.get("/secret")
|
|
62
|
+
async def secret() -> JSONResponse:
|
|
63
|
+
return JSONResponse({"ok": True})
|
|
64
|
+
|
|
65
|
+
client = TestClient(app)
|
|
66
|
+
assert client.get("/health").status_code == 200
|
|
67
|
+
assert client.get("/secret").status_code == 401
|
|
68
|
+
|
|
69
|
+
|
|
49
70
|
def test_verifier_raises_token_verification_exception_returns_401() -> None:
|
|
50
71
|
"""TokenVerificationException from verifier must return 401, not 500."""
|
|
51
72
|
|
|
@@ -58,3 +58,58 @@ def test_local_verifier_constant_time() -> None:
|
|
|
58
58
|
assert verifier.verify("secret-token") is True
|
|
59
59
|
assert verifier.verify("wrong") is False
|
|
60
60
|
assert verifier.verify("") is False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_local_verifier_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
64
|
+
monkeypatch.setenv("TEST_TOKENS", "tok-a,tok-b, tok-c ")
|
|
65
|
+
verifier = LocalTokenVerifier.from_env("TEST_TOKENS")
|
|
66
|
+
assert verifier.verify("tok-a") is True
|
|
67
|
+
assert verifier.verify("tok-c") is True
|
|
68
|
+
assert verifier.verify("tok-d") is False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_local_verifier_from_env_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
72
|
+
monkeypatch.delenv("TEST_TOKENS", raising=False)
|
|
73
|
+
verifier = LocalTokenVerifier.from_env("TEST_TOKENS")
|
|
74
|
+
assert verifier.verify("anything") is False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_local_verifier_from_env_custom_separator(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
78
|
+
monkeypatch.setenv("TEST_TOKENS", "tok-a|tok-b")
|
|
79
|
+
verifier = LocalTokenVerifier.from_env("TEST_TOKENS", separator="|")
|
|
80
|
+
assert verifier.verify("tok-a") is True
|
|
81
|
+
assert verifier.verify("tok-b") is True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_exclude_paths_bypasses_auth() -> None:
|
|
85
|
+
app = FastAPI()
|
|
86
|
+
app.add_middleware(
|
|
87
|
+
BearerTokenMiddleware,
|
|
88
|
+
verifier=LocalTokenVerifier(["tok"]),
|
|
89
|
+
exclude_paths=["/health", "/docs"],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@app.get("/health")
|
|
93
|
+
async def health() -> JSONResponse:
|
|
94
|
+
return JSONResponse({"status": "ok"})
|
|
95
|
+
|
|
96
|
+
@app.get("/secret")
|
|
97
|
+
async def secret() -> JSONResponse:
|
|
98
|
+
return JSONResponse({"ok": True})
|
|
99
|
+
|
|
100
|
+
client = TestClient(app)
|
|
101
|
+
assert client.get("/health").status_code == 200
|
|
102
|
+
assert client.get("/secret").status_code == 401
|
|
103
|
+
assert client.get("/secret", headers={"Authorization": "Bearer tok"}).status_code == 200
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_exclude_paths_default_is_empty() -> None:
|
|
107
|
+
app = FastAPI()
|
|
108
|
+
app.add_middleware(BearerTokenMiddleware, verifier=LocalTokenVerifier(["tok"]))
|
|
109
|
+
|
|
110
|
+
@app.get("/health")
|
|
111
|
+
async def health() -> JSONResponse:
|
|
112
|
+
return JSONResponse({"status": "ok"})
|
|
113
|
+
|
|
114
|
+
client = TestClient(app)
|
|
115
|
+
assert client.get("/health").status_code == 401
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
"""Local token verifier — compares against a fixed set of allowed tokens.
|
|
2
|
-
|
|
3
|
-
For development and testing only. In production, implement TokenVerifierProtocol
|
|
4
|
-
against your actual auth backend (database, external IdP, JWT, etc.).
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import secrets
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class LocalTokenVerifier:
|
|
11
|
-
"""Verify tokens against a fixed allowlist using constant-time comparison."""
|
|
12
|
-
|
|
13
|
-
def __init__(self, allowed_tokens: list[str]) -> None:
|
|
14
|
-
self._allowed = allowed_tokens
|
|
15
|
-
|
|
16
|
-
def verify(self, token: str) -> bool:
|
|
17
|
-
return any(secrets.compare_digest(token, allowed) for allowed in self._allowed)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nene2_python-1.3.0 → nene2_python-1.4.0}/alembic/versions/001_create_notes_and_tags_tables.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|