@biffo/cli 0.38.0 → 0.41.0

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.
Files changed (73) hide show
  1. package/_skeletons/plugin-template/.github/workflows/ci.yml +124 -0
  2. package/_skeletons/plugin-template/.github/workflows/release.yml +114 -0
  3. package/_skeletons/plugin-template/README.md +232 -0
  4. package/_skeletons/plugin-template/biffo.plugin.json +89 -0
  5. package/_skeletons/plugin-template/pyproject.toml +100 -0
  6. package/_skeletons/plugin-template/registry-schema.json +294 -0
  7. package/_skeletons/plugin-template/src/__init__.py +0 -0
  8. package/_skeletons/plugin-template/src/example_plugin/__init__.py +10 -0
  9. package/_skeletons/plugin-template/src/example_plugin/main.py +63 -0
  10. package/_skeletons/plugin-template/src/example_plugin/manifest.py +30 -0
  11. package/_skeletons/plugin-template/src/example_plugin/plugin.py +92 -0
  12. package/_skeletons/plugin-template/terraform/README.md +146 -0
  13. package/_skeletons/plugin-template/terraform/main.tf +167 -0
  14. package/_skeletons/plugin-template/terraform/outputs.tf +33 -0
  15. package/_skeletons/plugin-template/terraform/variables.tf +133 -0
  16. package/_skeletons/plugin-template/tests/conftest.py +17 -0
  17. package/_skeletons/plugin-template/tests/fakes.py +74 -0
  18. package/_skeletons/plugin-template/tests/test_example_plugin.py +108 -0
  19. package/_skeletons/registry/README.md +27 -0
  20. package/_skeletons/registry/plugins.json +5 -0
  21. package/_skeletons/registry/registry-schema.json +294 -0
  22. package/_skeletons/sibling-template/.github/renovate.json +38 -0
  23. package/_skeletons/sibling-template/.github/workflows/ci.yml +185 -0
  24. package/_skeletons/sibling-template/.github/workflows/codeql.yml +56 -0
  25. package/_skeletons/sibling-template/.github/workflows/deploy.yml +219 -0
  26. package/_skeletons/sibling-template/.github/workflows/destroy-infra.yml +73 -0
  27. package/_skeletons/sibling-template/README.md +164 -0
  28. package/_skeletons/sibling-template/_gitignore +71 -0
  29. package/_skeletons/sibling-template/apps/frontend/.env.example +14 -0
  30. package/_skeletons/sibling-template/apps/frontend/eslint.config.mjs +10 -0
  31. package/_skeletons/sibling-template/apps/frontend/next.config.ts +20 -0
  32. package/_skeletons/sibling-template/apps/frontend/package.json +42 -0
  33. package/_skeletons/sibling-template/apps/frontend/pnpm-lock.yaml +5268 -0
  34. package/_skeletons/sibling-template/apps/frontend/pnpm-workspace.yaml +19 -0
  35. package/_skeletons/sibling-template/apps/frontend/src/app/globals.css +34 -0
  36. package/_skeletons/sibling-template/apps/frontend/src/app/layout.tsx +15 -0
  37. package/_skeletons/sibling-template/apps/frontend/src/app/page.test.tsx +75 -0
  38. package/_skeletons/sibling-template/apps/frontend/src/app/page.tsx +85 -0
  39. package/_skeletons/sibling-template/apps/frontend/src/lib/api-client.ts +58 -0
  40. package/_skeletons/sibling-template/apps/frontend/src/lib/auth.test.ts +135 -0
  41. package/_skeletons/sibling-template/apps/frontend/src/lib/auth.ts +87 -0
  42. package/_skeletons/sibling-template/apps/frontend/src/test-setup.ts +1 -0
  43. package/_skeletons/sibling-template/apps/frontend/tsconfig.json +29 -0
  44. package/_skeletons/sibling-template/apps/frontend/vitest.config.ts +18 -0
  45. package/_skeletons/sibling-template/biffo.sibling.json +7 -0
  46. package/_skeletons/sibling-template/infra/backend.tf +23 -0
  47. package/_skeletons/sibling-template/infra/main.tf +79 -0
  48. package/_skeletons/sibling-template/infra/outputs.tf +17 -0
  49. package/_skeletons/sibling-template/infra/variables.tf +63 -0
  50. package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/main.tf +114 -0
  51. package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/outputs.tf +12 -0
  52. package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/variables.tf +41 -0
  53. package/_skeletons/sibling-template/modules/cloud/aws/compute/main.tf +157 -0
  54. package/_skeletons/sibling-template/modules/cloud/aws/compute/outputs.tf +4 -0
  55. package/_skeletons/sibling-template/modules/cloud/aws/compute/variables.tf +70 -0
  56. package/_skeletons/sibling-template/modules/cloud/aws/storage/main.tf +78 -0
  57. package/_skeletons/sibling-template/modules/cloud/aws/storage/outputs.tf +4 -0
  58. package/_skeletons/sibling-template/modules/cloud/aws/storage/variables.tf +12 -0
  59. package/_skeletons/sibling-template/services/api/pyproject.toml +69 -0
  60. package/_skeletons/sibling-template/services/api/src/api/__init__.py +0 -0
  61. package/_skeletons/sibling-template/services/api/src/api/config.py +31 -0
  62. package/_skeletons/sibling-template/services/api/src/api/core_client.py +64 -0
  63. package/_skeletons/sibling-template/services/api/src/api/main.py +43 -0
  64. package/_skeletons/sibling-template/services/api/src/api/middleware/__init__.py +0 -0
  65. package/_skeletons/sibling-template/services/api/src/api/middleware/auth.py +111 -0
  66. package/_skeletons/sibling-template/services/api/src/api/routers/__init__.py +0 -0
  67. package/_skeletons/sibling-template/services/api/src/api/routers/whoami.py +21 -0
  68. package/_skeletons/sibling-template/services/api/tests/conftest.py +24 -0
  69. package/_skeletons/sibling-template/services/api/tests/test_whoami.py +21 -0
  70. package/_skeletons/sibling-template/services/api/uv.lock +1160 -0
  71. package/core.version +1 -1
  72. package/dist/index.js +113 -87
  73. package/package.json +5 -4
@@ -0,0 +1,64 @@
1
+ import httpx
2
+ from fastapi import HTTPException, Security, status
3
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
4
+
5
+ from .config import settings
6
+
7
+ _security = HTTPBearer()
8
+
9
+
10
+ class CoreApiError(Exception):
11
+ def __init__(self, status_code: int, detail: str) -> None:
12
+ self.status_code = status_code
13
+ self.detail = detail
14
+ super().__init__(detail)
15
+
16
+
17
+ class CoreApiClient:
18
+ """
19
+ Thin per-request client for calling the core project's API (ADR-0002/
20
+ ADR-0007) — this is the ONLY way this service may read or write data
21
+ that belongs to the core. It is deliberately NOT
22
+ packages/python-sdk's BiffoAPIClient: that client is built around a
23
+ single static BIFFO_JWT_TOKEN env var for background/event-driven
24
+ plugin code, not a live per-request user token. Here we forward the
25
+ caller's own bearer token, so the core API applies the exact same
26
+ tenant/permission scoping it would for a request made directly against
27
+ it — this service never gets elevated privileges the calling user
28
+ didn't already have.
29
+ """
30
+
31
+ def __init__(self, bearer_token: str) -> None:
32
+ self._bearer_token = bearer_token
33
+
34
+ async def get(self, path: str) -> dict:
35
+ async with httpx.AsyncClient(base_url=settings.core_api_url, timeout=10) as client:
36
+ response = await client.get(
37
+ path,
38
+ headers={"Authorization": f"Bearer {self._bearer_token}"},
39
+ )
40
+ if response.is_error:
41
+ raise CoreApiError(response.status_code, response.text)
42
+ return response.json() # type: ignore[no-any-return]
43
+
44
+ async def post(self, path: str, body: dict) -> dict:
45
+ async with httpx.AsyncClient(base_url=settings.core_api_url, timeout=10) as client:
46
+ response = await client.post(
47
+ path,
48
+ json=body,
49
+ headers={"Authorization": f"Bearer {self._bearer_token}"},
50
+ )
51
+ if response.is_error:
52
+ raise CoreApiError(response.status_code, response.text)
53
+ return response.json() # type: ignore[no-any-return]
54
+
55
+
56
+ def get_core_client(
57
+ credentials: HTTPAuthorizationCredentials = Security(_security),
58
+ ) -> CoreApiClient:
59
+ if not settings.core_api_url:
60
+ raise HTTPException(
61
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
62
+ detail="core_api_url is not configured",
63
+ )
64
+ return CoreApiClient(credentials.credentials)
@@ -0,0 +1,43 @@
1
+ import asyncio
2
+
3
+ from aws_lambda_powertools import Logger, Tracer
4
+ from aws_lambda_powertools.utilities.typing import LambdaContext
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from mangum import Mangum
8
+
9
+ from .config import settings
10
+ from .routers import whoami
11
+
12
+ logger = Logger()
13
+ tracer = Tracer()
14
+
15
+ app = FastAPI(
16
+ title="Sibling API",
17
+ version="0.0.0",
18
+ docs_url="/api/docs" if settings.environment != "prod" else None,
19
+ redoc_url=None,
20
+ )
21
+
22
+ app.add_middleware(
23
+ CORSMiddleware,
24
+ allow_origins=settings.cors_origins,
25
+ allow_credentials=True,
26
+ allow_methods=["*"],
27
+ allow_headers=["*"],
28
+ )
29
+
30
+ app.include_router(whoami.router, prefix="/api/v1")
31
+
32
+ handler = Mangum(app, lifespan="off")
33
+
34
+
35
+ @logger.inject_lambda_context
36
+ @tracer.capture_lambda_handler
37
+ def lambda_handler(event: dict, context: LambdaContext) -> dict:
38
+ # asyncio.run() (used internally by httpx's async client teardown, among
39
+ # other things) sets the current event loop to None when it exits,
40
+ # causing asyncio.get_event_loop() (used by Mangum) to raise RuntimeError
41
+ # in Python 3.12+. Recreate the loop before each invocation.
42
+ asyncio.set_event_loop(asyncio.new_event_loop())
43
+ return handler(event, context) # type: ignore[reportArgumentType]
@@ -0,0 +1,111 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ from functools import lru_cache
4
+ from typing import cast
5
+
6
+ import httpx
7
+ import jwt
8
+ from aws_lambda_powertools import Logger
9
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
10
+ from fastapi import HTTPException, Security, status
11
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
12
+ from jwt import PyJWTError
13
+ from jwt.algorithms import RSAAlgorithm
14
+
15
+ from ..config import settings
16
+
17
+ logger = Logger()
18
+ _security = HTTPBearer()
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class AuthenticatedUser:
23
+ """Verified identity extracted from the core project's Cognito JWT."""
24
+
25
+ sub: str
26
+ email: str
27
+ username: str
28
+
29
+
30
+ # Ported near-verbatim from the core project's services/api/src/api/middleware/auth.py
31
+ # (ADR-0007) — this sibling's API Gateway already has its own Cognito JWT
32
+ # authorizer (see modules/cloud/aws/api-gateway), so a request only reaches
33
+ # this Lambda at all if a valid token was already presented. This second,
34
+ # independent verification is deliberate defense in depth, not redundant:
35
+ # it means the Lambda's own authorization logic never trusts API Gateway's
36
+ # authorizer having run correctly, matching how the core API itself behaves.
37
+ @lru_cache(maxsize=1)
38
+ def _get_jwks(user_pool_id: str, region: str) -> dict:
39
+ """Fetch and cache JWKS. Cached per Lambda instance lifetime."""
40
+ if settings.cognito_jwks_json:
41
+ return json.loads(settings.cognito_jwks_json) # type: ignore[no-any-return]
42
+ url = f"https://cognito-idp.{region}.amazonaws.com/{user_pool_id}/.well-known/jwks.json"
43
+ response = httpx.get(url, timeout=10)
44
+ response.raise_for_status()
45
+ return response.json() # type: ignore[no-any-return]
46
+
47
+
48
+ def _verify_token(token: str) -> dict:
49
+ try:
50
+ unverified_headers = jwt.get_unverified_header(token)
51
+ except PyJWTError as exc:
52
+ raise HTTPException(
53
+ status_code=status.HTTP_401_UNAUTHORIZED,
54
+ detail="Malformed token",
55
+ headers={"WWW-Authenticate": "Bearer"},
56
+ ) from exc
57
+
58
+ kid = unverified_headers.get("kid")
59
+ jwks = _get_jwks(settings.cognito_user_pool_id, settings.cognito_region)
60
+ signing_key = next((k for k in jwks["keys"] if k["kid"] == kid), None)
61
+
62
+ if signing_key is None and not settings.cognito_jwks_json:
63
+ # Unknown kid and we can fetch remotely — JWKS may have rotated;
64
+ # bust the cache and retry once.
65
+ _get_jwks.cache_clear()
66
+ jwks = _get_jwks(settings.cognito_user_pool_id, settings.cognito_region)
67
+ signing_key = next((k for k in jwks["keys"] if k["kid"] == kid), None)
68
+
69
+ if signing_key is None:
70
+ raise HTTPException(
71
+ status_code=status.HTTP_401_UNAUTHORIZED,
72
+ detail="Unknown signing key",
73
+ headers={"WWW-Authenticate": "Bearer"},
74
+ )
75
+
76
+ try:
77
+ # PyJWT needs a key object, not a raw JWK dict — convert the matched JWK.
78
+ # A Cognito JWKS only publishes public keys, so this is always public.
79
+ public_key = cast(RSAPublicKey, RSAAlgorithm.from_jwk(json.dumps(signing_key)))
80
+ claims: dict = jwt.decode(
81
+ token,
82
+ public_key,
83
+ algorithms=["RS256"],
84
+ audience=settings.cognito_client_id,
85
+ )
86
+ except PyJWTError as exc:
87
+ raise HTTPException(
88
+ status_code=status.HTTP_401_UNAUTHORIZED,
89
+ detail=f"Token invalid: {exc}",
90
+ headers={"WWW-Authenticate": "Bearer"},
91
+ ) from exc
92
+
93
+ return claims
94
+
95
+
96
+ async def require_auth(
97
+ credentials: HTTPAuthorizationCredentials = Security(_security),
98
+ ) -> AuthenticatedUser:
99
+ """
100
+ FastAPI dependency that verifies the core project's Cognito JWT and
101
+ returns the caller's identity.
102
+
103
+ Raises HTTP 401 if the token is missing, expired, or invalid.
104
+ """
105
+ claims = _verify_token(credentials.credentials)
106
+
107
+ return AuthenticatedUser(
108
+ sub=claims["sub"],
109
+ email=claims.get("email", ""),
110
+ username=claims.get("cognito:username", claims.get("username", "")),
111
+ )
@@ -0,0 +1,21 @@
1
+ from fastapi import APIRouter, Depends
2
+
3
+ from ..middleware.auth import AuthenticatedUser, require_auth
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.get("/whoami")
9
+ async def whoami(caller: AuthenticatedUser = Depends(require_auth)) -> dict:
10
+ """
11
+ Proves the shared-Cognito-session SSO (ADR-0007) actually works: the
12
+ frontend calls this immediately after finding a session, and only
13
+ renders "Hello <username>" once this independently-verified response
14
+ comes back — never trusting a client-side JWT decode for display.
15
+ """
16
+ return {"username": caller.username or caller.email or caller.sub}
17
+
18
+
19
+ @router.get("/health")
20
+ async def health() -> dict:
21
+ return {"status": "ok"}
@@ -0,0 +1,24 @@
1
+ from collections.abc import Generator
2
+
3
+ import pytest
4
+ from fastapi.testclient import TestClient
5
+
6
+ from api.main import app
7
+ from api.middleware.auth import AuthenticatedUser, require_auth
8
+
9
+
10
+ @pytest.fixture
11
+ def authenticated_client() -> Generator[TestClient]:
12
+ app.dependency_overrides[require_auth] = lambda: AuthenticatedUser(
13
+ sub="abc-123",
14
+ email="a@example.com",
15
+ username="testuser",
16
+ )
17
+ client = TestClient(app)
18
+ yield client
19
+ app.dependency_overrides.pop(require_auth, None)
20
+
21
+
22
+ @pytest.fixture
23
+ def client() -> TestClient:
24
+ return TestClient(app)
@@ -0,0 +1,21 @@
1
+ from fastapi.testclient import TestClient
2
+
3
+
4
+ def test_whoami_returns_username_for_authenticated_caller(authenticated_client: TestClient) -> None:
5
+ response = authenticated_client.get("/api/v1/whoami")
6
+
7
+ assert response.status_code == 200
8
+ assert response.json() == {"username": "testuser"}
9
+
10
+
11
+ def test_whoami_rejects_unauthenticated_requests(client: TestClient) -> None:
12
+ response = client.get("/api/v1/whoami")
13
+
14
+ assert response.status_code == 401 # No Authorization header at all — HTTPBearer's own 401
15
+
16
+
17
+ def test_health_requires_no_auth(client: TestClient) -> None:
18
+ response = client.get("/api/v1/health")
19
+
20
+ assert response.status_code == 200
21
+ assert response.json() == {"status": "ok"}