@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,167 @@
1
+ # Template Terraform module for a Biffo plugin (ADR-0003 chunk 12 / issues #25, #201).
2
+ #
3
+ # Copy this directory to modules/plugins/<name>/ inside a plugin repo (as
4
+ # terraform/, per ADR-0003 section 2's plugin repo layout) and adjust as
5
+ # needed. `biffo plugin install <name>@<minor>` copies that terraform/
6
+ # directory into the user's monorepo at modules/plugins/<name>/ and then
7
+ # generates the instantiating `module "plugin_<name>"` block — gated on
8
+ # `enabled_plugins` — into infra/environments/<env>/plugins.generated.tf. That
9
+ # file is CLI-owned and regenerated on every install/uninstall; main.tf is
10
+ # never edited. See infra/environments/dev/README.md's "Adding a plugin".
11
+ #
12
+ # This module deliberately wraps two existing modules rather than
13
+ # reimplementing Lambda/IAM/EventBridge from scratch:
14
+ # - modules/cloud/aws/compute — the plugin's Lambda function, with the
15
+ # same DLQ/logging/tracing/least-privilege IAM baseline every Biffo
16
+ # function gets.
17
+ # - modules/cloud/aws/events (via the shared event_bus_name passed in) —
18
+ # this module adds only the subscription rule/target/permission a
19
+ # plugin needs to react to events on the bus the root config already
20
+ # owns. No new bus is created.
21
+ #
22
+ # What this module does NOT do, per ADR-0002 ("no DB clients outside
23
+ # services/api/", "microservices call the API via HTTP and react to
24
+ # EventBridge events"):
25
+ # - It never creates a database, and never receives
26
+ # db_credentials_secret_arn — that variable is wired to the Core API's
27
+ # Lambda only (infra/environments/dev/main.tf's module "core_api" block).
28
+ # A plugin that needs platform data calls the Core API over HTTPS
29
+ # (BIFFO_CORE_API_URL, see variables.tf) using the plugin SDK's
30
+ # BiffoAPIClient, exactly like any other API consumer.
31
+ # - It never attaches to the VPC unless var.enable_vpc_access is set to
32
+ # true — see variables.tf's enable_vpc_access description for why.
33
+ #
34
+ # Loose coupling: this module must not reference other plugin modules or
35
+ # their resources. Each plugin is instantiated independently by the root
36
+ # config; nothing here should assume any other plugin is installed.
37
+
38
+ terraform {
39
+ required_providers {
40
+ aws = { source = "hashicorp/aws", version = "~> 5.0" }
41
+ }
42
+ }
43
+
44
+ locals {
45
+ name_prefix = "${var.project_name}-${var.environment}"
46
+ function_name = "${local.name_prefix}-plugin-${var.plugin_name}"
47
+ # A rule is created when the plugin subscribes to specific events, or when it
48
+ # is a generic forwarder that reacts to every event (subscribe_all).
49
+ has_subscriptions = var.subscribe_all || length(var.event_subscriptions) > 0
50
+ # Grant Core API access only when the root config told us which API to scope
51
+ # it to. Empty (the default) => the plugin never calls Core, so no grant.
52
+ grants_core_api_access = var.core_api_execution_arn != ""
53
+ }
54
+
55
+ # Compute — the plugin's Lambda function.
56
+ module "function" {
57
+ source = "../../cloud/aws/compute"
58
+
59
+ project_name = var.project_name
60
+ environment = var.environment
61
+ function_name = "plugin-${var.plugin_name}"
62
+ handler = var.handler
63
+ runtime = var.runtime
64
+ memory_size = var.memory_size
65
+ timeout = var.timeout
66
+ enable_vpc_access = var.enable_vpc_access
67
+ vpc_id = var.vpc_id
68
+ private_subnet_ids = var.private_subnet_ids
69
+ event_bus_name = var.event_bus_name
70
+
71
+ # No db_credentials_secret_arn — see the ADR-0002 note above.
72
+ environment_variables = merge(
73
+ {
74
+ BIFFO_CORE_API_URL = var.core_api_url
75
+ BIFFO_PLUGIN_NAME = var.plugin_name
76
+ },
77
+ var.environment_variables,
78
+ )
79
+
80
+ sqs_kms_key_id = var.sqs_kms_key_id
81
+ cloudwatch_kms_key_id = var.cloudwatch_kms_key_id
82
+ tags = var.tags
83
+ }
84
+
85
+ # Events — subscribe the plugin's Lambda to its declared event_subscriptions
86
+ # on the shared bus. Each subscription is matched as its own source +
87
+ # detail-type pair via `$or`, rather than independent `source`/`detail-type`
88
+ # arrays, to avoid EventBridge matching the cross product of unrelated
89
+ # source/detail-type combinations when a plugin subscribes to more than one
90
+ # event.
91
+ resource "aws_cloudwatch_event_rule" "subscription" {
92
+ count = local.has_subscriptions ? 1 : 0
93
+ name = "${local.function_name}-events"
94
+ description = "Routes subscribed events to the ${var.plugin_name} plugin"
95
+ event_bus_name = var.event_bus_name
96
+
97
+ # subscribe_all → match every event on the bus (a generic forwarder; the plugin
98
+ # decides what to do, so new triggers need no Terraform change — ADR-0010). Else
99
+ # a single subscription uses a flat pattern and two or more are OR-ed: EventBridge
100
+ # rejects a `$or` with fewer than 2 elements ("There must have at least 2 Objects
101
+ # in $or relationship"), so the single-subscription case must not use it.
102
+ # jsonencode is applied inside each branch so the conditional's arms are all
103
+ # strings — a `cond ? {a} : {b}` on differently-shaped objects is an "Inconsistent
104
+ # conditional result types" error at apply (validate misses it).
105
+ event_pattern = var.subscribe_all ? jsonencode({
106
+ source = [{ prefix = "" }]
107
+ }) : length(var.event_subscriptions) == 1 ? jsonencode({
108
+ source = [var.event_subscriptions[0].source]
109
+ "detail-type" = [var.event_subscriptions[0].detail_type]
110
+ }) : jsonencode({
111
+ "$or" = [
112
+ for s in var.event_subscriptions : {
113
+ source = [s.source]
114
+ "detail-type" = [s.detail_type]
115
+ }
116
+ ]
117
+ })
118
+
119
+ tags = var.tags
120
+ }
121
+
122
+ resource "aws_cloudwatch_event_target" "subscription" {
123
+ count = local.has_subscriptions ? 1 : 0
124
+ rule = aws_cloudwatch_event_rule.subscription[0].name
125
+ event_bus_name = var.event_bus_name
126
+ target_id = "${var.plugin_name}-lambda"
127
+ arn = module.function.function_arn
128
+ }
129
+
130
+ resource "aws_lambda_permission" "subscription" {
131
+ count = local.has_subscriptions ? 1 : 0
132
+ statement_id = "AllowEventBridgeInvoke"
133
+ action = "lambda:InvokeFunction"
134
+ function_name = module.function.function_name
135
+ principal = "events.amazonaws.com"
136
+ source_arn = aws_cloudwatch_event_rule.subscription[0].arn
137
+ }
138
+
139
+ # Core API access (ADR-0009) — the plugin->Core auth path.
140
+ #
141
+ # ADR-0002 forbids this Lambda from touching the database, so anything it needs
142
+ # from the platform it gets over HTTPS from the Core API. The Core API's
143
+ # internal routes (/api/v1/internal/*) are IAM-authorized, not Cognito-JWT, so
144
+ # the plugin authenticates by SigV4-signing with this Lambda role — see
145
+ # biffo_plugin_sdk.SignedCoreClient, which BiffoPluginBase uses by default. No
146
+ # bearer token, no shared secret, nothing to rotate.
147
+ #
148
+ # Scoped to the /api/v1/internal/* prefix on one API, never the whole API.
149
+ data "aws_iam_policy_document" "core_api" {
150
+ count = local.grants_core_api_access ? 1 : 0
151
+
152
+ statement {
153
+ sid = "InvokeCoreInternalApi"
154
+ effect = "Allow"
155
+ actions = ["execute-api:Invoke"]
156
+ resources = ["${var.core_api_execution_arn}/*/*/api/v1/internal/*"]
157
+ }
158
+ }
159
+
160
+ resource "aws_iam_role_policy" "core_api" {
161
+ count = local.grants_core_api_access ? 1 : 0
162
+ name = "${local.function_name}-core-api"
163
+ # compute exposes the role via its ARN; derive the role name (last ARN
164
+ # segment) since aws_iam_role_policy wants the name, not the ARN.
165
+ role = element(split("/", module.function.role_arn), length(split("/", module.function.role_arn)) - 1)
166
+ policy = data.aws_iam_policy_document.core_api[0].json
167
+ }
@@ -0,0 +1,33 @@
1
+ output "function_arn" {
2
+ description = "Lambda function ARN — aggregate this in the root config's plugin outputs."
3
+ value = module.function.function_arn
4
+ }
5
+
6
+ output "function_name" {
7
+ value = module.function.function_name
8
+ }
9
+
10
+ # The plugin Lambda's execution role ARN. Useful for auditing, but note this is
11
+ # deliberately NOT how the role reaches the Core API's
12
+ # BIFFO_SERVICE_PRINCIPAL_ARN_ALLOWLIST — wiring this output into the core_api
13
+ # module would create the dependency cycle core_api -> api_gateway -> plugin ->
14
+ # core_api (issue #201). Allowlist the predictable assumed-role glob instead;
15
+ # see README.md.
16
+ output "role_arn" {
17
+ value = module.function.role_arn
18
+ }
19
+
20
+ output "role_name" {
21
+ description = "The plugin Lambda's execution role NAME — the value to interpolate into the Core API's BIFFO_SERVICE_PRINCIPAL_ARN_ALLOWLIST glob (arn:aws:sts::<acct>:assumed-role/<role-name>/*)."
22
+ value = element(split("/", module.function.role_arn), length(split("/", module.function.role_arn)) - 1)
23
+ }
24
+
25
+ output "dlq_arn" {
26
+ description = "Dead letter queue ARN for failed invocations (both direct and EventBridge-triggered)."
27
+ value = module.function.dlq_arn
28
+ }
29
+
30
+ output "event_rule_arn" {
31
+ description = "EventBridge rule ARN, or null when the plugin declares no event_subscriptions."
32
+ value = local.has_subscriptions ? aws_cloudwatch_event_rule.subscription[0].arn : null
33
+ }
@@ -0,0 +1,133 @@
1
+ variable "project_name" {
2
+ description = "Biffo project name — passed through unchanged from the root config."
3
+ type = string
4
+ }
5
+
6
+ variable "environment" {
7
+ description = "Deployment environment (dev/staging/prod) — passed through unchanged from the root config."
8
+ type = string
9
+ }
10
+
11
+ variable "plugin_name" {
12
+ description = "Plugin slug, matching biffo.plugin.json's `name` field and the services/<name>/ directory this plugin was installed into (ADR-0003 section 2). Used to namespace every resource this module creates."
13
+ type = string
14
+ }
15
+
16
+ variable "handler" {
17
+ description = "Lambda handler entrypoint, e.g. `src.lambda.main.handler` per the plugin repo layout in ADR-0003 section 2."
18
+ type = string
19
+ }
20
+
21
+ variable "runtime" {
22
+ type = string
23
+ default = "python3.13"
24
+ }
25
+
26
+ variable "memory_size" {
27
+ type = number
28
+ default = 512
29
+ }
30
+
31
+ variable "timeout" {
32
+ type = number
33
+ default = 30
34
+ }
35
+
36
+ variable "enable_vpc_access" {
37
+ description = <<-EOT
38
+ Attach this plugin's Lambda to a VPC. Leave false (the default) — per
39
+ ADR-0002, plugins never access the database directly, only the Core API
40
+ over HTTPS and EventBridge, so VPC attachment is normally unnecessary.
41
+ In NAT-less networking configs (e.g. dev's enable_nat_gateway = false),
42
+ attaching to the VPC would also cut off the outbound internet access
43
+ this Lambda needs to reach the Core API's public endpoint. Only enable
44
+ this if the plugin has a genuine, ADR-0002-compliant reason to reach a
45
+ VPC-only resource (e.g. ElastiCache) — never to reach the database.
46
+
47
+ Must be an explicit boolean, not inferred from whether vpc_id is set —
48
+ vpc_id can be only known after apply (e.g. if it were ever passed from
49
+ a VPC created in the same plan), and Terraform's count/for_each in the
50
+ underlying compute module cannot depend on a not-yet-known value.
51
+ EOT
52
+ type = bool
53
+ default = false
54
+ }
55
+
56
+ variable "vpc_id" {
57
+ description = "VPC to attach this plugin's Lambda to. Only used when enable_vpc_access is true."
58
+ type = string
59
+ default = ""
60
+ }
61
+
62
+ variable "private_subnet_ids" {
63
+ description = "Private subnets to place ENIs in. Only used when enable_vpc_access is true."
64
+ type = list(string)
65
+ default = []
66
+ }
67
+
68
+ variable "core_api_url" {
69
+ description = "Core API base URL (module.api_gateway.api_endpoint from the root config). Injected as BIFFO_CORE_API_URL — the plugin SDK's BiffoAPIClient reads this env var by default (packages/python-sdk/src/biffo_plugin_sdk/client.py). This is how plugins read/write platform data per ADR-0002 — never a direct DB connection."
70
+ type = string
71
+ default = ""
72
+ }
73
+
74
+ variable "core_api_execution_arn" {
75
+ description = <<-EOT
76
+ The Core API Gateway's execution ARN (module.api_gateway.execution_arn from
77
+ the root config, i.e. arn:aws:execute-api:<region>:<acct>:<api-id>). When
78
+ set, this plugin's Lambda role is granted execute-api:Invoke scoped to that
79
+ API's /api/v1/internal/* routes only — the plugin->Core auth mechanism from
80
+ ADR-0009 (IAM SigV4), which the plugin SDK's SignedCoreClient speaks.
81
+
82
+ Leave empty (the default) only if the plugin never calls the Core API. It
83
+ is not enough on its own: the Core API independently re-checks the caller
84
+ against BIFFO_SERVICE_PRINCIPAL_ARN_ALLOWLIST, so the plugin's role must
85
+ also be allowlisted there — see this module's README for the exact glob and
86
+ why it is a static string rather than this module's role_arn output.
87
+ EOT
88
+ type = string
89
+ default = ""
90
+ }
91
+
92
+ variable "event_bus_name" {
93
+ description = "Name of the shared EventBridge bus (module.events.event_bus_name from the root config). This module subscribes to it — it never creates its own bus, keeping every plugin's events on one platform-wide bus per ADR-0002."
94
+ type = string
95
+ }
96
+
97
+ variable "event_subscriptions" {
98
+ description = "Events this plugin reacts to, mirroring biffo.plugin.json's `event_subscriptions` array (ADR-0003 section 2), e.g. [{ source = \"biffo.core\", detail_type = \"UserCreated\" }]. Leave empty if the plugin only calls the Core API and never reacts to events — no EventBridge rule is created in that case. Ignored when `subscribe_all` is true."
99
+ type = list(object({
100
+ source = string
101
+ detail_type = string
102
+ }))
103
+ default = []
104
+ }
105
+
106
+ variable "subscribe_all" {
107
+ description = "Subscribe to every event on the bus (a generic forwarder). When true, `event_subscriptions` is ignored and the rule matches all events, so the plugin — not Terraform — decides what to act on and a new trigger needs no infra change (ADR-0010). Use for engines like the orchestrator that forward everything to the Core API for matching."
108
+ type = bool
109
+ default = false
110
+ }
111
+
112
+ variable "environment_variables" {
113
+ description = "Additional environment variables for the plugin's Lambda, merged over BIFFO_CORE_API_URL / BIFFO_PLUGIN_NAME. Do not put database connection details here — plugins never receive them (ADR-0002)."
114
+ type = map(string)
115
+ default = {}
116
+ }
117
+
118
+ variable "sqs_kms_key_id" {
119
+ description = "KMS key ID for the DLQ's SQS queue encryption (CKV_AWS_27). Leave empty for AWS-owned key."
120
+ type = string
121
+ default = ""
122
+ }
123
+
124
+ variable "cloudwatch_kms_key_id" {
125
+ description = "KMS key ID for CloudWatch log group encryption (CKV_AWS_158). Leave empty for AWS-owned key."
126
+ type = string
127
+ default = ""
128
+ }
129
+
130
+ variable "tags" {
131
+ type = map(string)
132
+ default = {}
133
+ }
@@ -0,0 +1,17 @@
1
+ """Make `example_plugin` and the test-local `fakes` module importable.
2
+
3
+ This template is a standalone repo (not part of the biffo-template
4
+ monorepo's uv workspace — see README's "Standalone repo" note), so
5
+ `example_plugin` is installed normally via the project's own
6
+ [build-system]/hatchling config when `uv sync` runs. This conftest only
7
+ needs to put `tests/` itself on sys.path so `tests/test_example_plugin.py`
8
+ can `import fakes` as a plain sibling module (pytest's rootdir insertion
9
+ already covers this in most configurations, but the explicit path insert
10
+ keeps this working from any cwd, matching the RBAC reference plugin's
11
+ `services/rbac/tests/conftest.py` pattern in the biffo-template monorepo).
12
+ """
13
+
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).parent))
@@ -0,0 +1,74 @@
1
+ """A tiny in-memory fake of the Core API's generic CRUD routes
2
+ (`/api/v1/plugins/example-plugin/*`), used to test this plugin without a
3
+ real Core API or network call.
4
+
5
+ Mirrors the shape `build_plugin_router` in
6
+ `services/api/src/api/routing/plugin_router.py` (biffo-template monorepo)
7
+ actually produces (id auto-assigned, tenant_id auto-assigned, list/create/
8
+ read/delete by path), so these tests exercise realistic request/response
9
+ semantics rather than hand-picked canned responses. Copied from the RBAC
10
+ reference plugin's `services/rbac/tests/fakes.py` (PR #76) and trimmed to
11
+ this plugin's single `widgets` resource.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from itertools import count
18
+ from typing import Any
19
+
20
+ import httpx
21
+ from biffo_plugin_sdk import BiffoAPIClient
22
+
23
+ _BASE_PATH = "/api/v1/plugins/example-plugin"
24
+
25
+
26
+ class FakeCoreApi:
27
+ """Fake backing store for the plugin's own generic CRUD routes."""
28
+
29
+ def __init__(self) -> None:
30
+ self.tables: dict[str, list[dict[str, Any]]] = {"widgets": []}
31
+ self.request_log: list[tuple[str, str]] = []
32
+ self._ids = count(1)
33
+
34
+ def client(self) -> BiffoAPIClient:
35
+ transport = httpx.MockTransport(self._handle)
36
+ async_client = httpx.AsyncClient(transport=transport)
37
+ return BiffoAPIClient(
38
+ base_url="https://core.example.com", token="test-jwt", client=async_client
39
+ )
40
+
41
+ def _handle(self, request: httpx.Request) -> httpx.Response:
42
+ path = request.url.path
43
+ assert path.startswith(_BASE_PATH), f"unexpected path: {path}"
44
+ self.request_log.append((request.method, path))
45
+
46
+ rest = path[len(_BASE_PATH) :].strip("/")
47
+ parts = rest.split("/") if rest else []
48
+ table = parts[0]
49
+ row_id = parts[1] if len(parts) > 1 else None
50
+ rows = self.tables.setdefault(table, [])
51
+
52
+ if request.method == "GET" and row_id is None:
53
+ return httpx.Response(200, json=rows)
54
+
55
+ if request.method == "GET" and row_id is not None:
56
+ row = next((r for r in rows if r["id"] == row_id), None)
57
+ if row is None:
58
+ return httpx.Response(404, json={"detail": "Not found"})
59
+ return httpx.Response(200, json=row)
60
+
61
+ if request.method == "POST":
62
+ payload = json.loads(request.content or b"{}")
63
+ row = {"id": f"id-{next(self._ids)}", "tenant_id": "default", **payload}
64
+ rows.append(row)
65
+ return httpx.Response(201, json=row)
66
+
67
+ if request.method == "DELETE" and row_id is not None:
68
+ before = len(rows)
69
+ self.tables[table] = [r for r in rows if r["id"] != row_id]
70
+ if len(self.tables[table]) == before:
71
+ return httpx.Response(404, json={"detail": "Not found"})
72
+ return httpx.Response(200, json={"deleted": True, "id": row_id})
73
+
74
+ return httpx.Response(404, json={"detail": "Not found"})
@@ -0,0 +1,108 @@
1
+ """Tests for ExamplePlugin: manifest loading, lifecycle hooks, and the
2
+ UserCreated event subscription.
3
+
4
+ Modelled on the RBAC reference plugin's
5
+ `services/rbac/tests/test_rbac_plugin.py` (PR #76) in the biffo-template
6
+ monorepo.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from biffo_plugin_sdk import BiffoEvent
12
+ from fakes import FakeCoreApi
13
+
14
+ from example_plugin.plugin import _DEFAULT_WIDGET, ExamplePlugin
15
+
16
+
17
+ def _make_plugin() -> tuple[ExamplePlugin, FakeCoreApi]:
18
+ fake = FakeCoreApi()
19
+ plugin = ExamplePlugin(api=fake.client())
20
+ return plugin, fake
21
+
22
+
23
+ class TestManifestLoading:
24
+ def test_manifest_name_and_version(self) -> None:
25
+ plugin, _ = _make_plugin()
26
+ assert plugin.manifest.name == "example-plugin"
27
+ assert plugin.manifest.version == "0.1.0"
28
+
29
+ def test_manifest_declares_the_widgets_table(self) -> None:
30
+ plugin, _ = _make_plugin()
31
+ table_names = {t.name for t in plugin.manifest.tables}
32
+ assert table_names == {"example_widgets"}
33
+
34
+ def test_register_returns_manifest_registration(self) -> None:
35
+ plugin, _ = _make_plugin()
36
+ registration = plugin.register()
37
+ assert registration["name"] == "example-plugin"
38
+ assert {t["name"] for t in registration["tables"]} == {"example_widgets"}
39
+ assert {r["path"] for r in registration["api_routes"]} == {
40
+ "/widgets",
41
+ "/widgets/{id}",
42
+ }
43
+
44
+
45
+ class TestSubscription:
46
+ def test_subscribes_to_user_created(self) -> None:
47
+ plugin, _ = _make_plugin()
48
+ assert plugin.events.has_subscription("UserCreated")
49
+
50
+
51
+ class TestOnInstall:
52
+ async def test_seeds_default_widget(self) -> None:
53
+ plugin, fake = _make_plugin()
54
+
55
+ await plugin.seed_default_widget()
56
+
57
+ names = {r["name"] for r in fake.tables["widgets"]}
58
+ assert names == {_DEFAULT_WIDGET["name"]}
59
+
60
+ async def test_is_idempotent(self) -> None:
61
+ plugin, fake = _make_plugin()
62
+
63
+ await plugin.seed_default_widget()
64
+ await plugin.seed_default_widget()
65
+
66
+ assert len(fake.tables["widgets"]) == 1
67
+
68
+ async def test_default_widget_marked_active(self) -> None:
69
+ plugin, fake = _make_plugin()
70
+
71
+ await plugin.seed_default_widget()
72
+
73
+ widget = fake.tables["widgets"][0]
74
+ assert widget["is_active"] is True
75
+
76
+ def test_on_uninstall_is_a_noop(self) -> None:
77
+ plugin, _ = _make_plugin()
78
+ assert plugin.on_uninstall() is None
79
+
80
+ def test_on_install_sync_bridge_seeds_widget(self) -> None:
81
+ """The public, ABC-compliant on_install() entrypoint (a plain sync
82
+ function calling asyncio.run(seed_default_widget()) internally) —
83
+ exercised from a genuinely synchronous test function, the same kind
84
+ of context the CLI would call it from, since asyncio.run() cannot be
85
+ nested inside pytest-asyncio's already-running loop."""
86
+ plugin, fake = _make_plugin()
87
+
88
+ plugin.on_install()
89
+
90
+ names = {r["name"] for r in fake.tables["widgets"]}
91
+ assert names == {_DEFAULT_WIDGET["name"]}
92
+
93
+
94
+ class TestUserCreatedLogsEvent:
95
+ async def test_dispatch_does_not_raise(self) -> None:
96
+ """The example handler only logs — this proves the subscription is
97
+ wired end to end (event -> EventSubscriber -> handler) without
98
+ raising, the same shape a real handler with side effects would be
99
+ tested in (see RBAC's test_rbac_plugin.py for a handler that
100
+ actually mutates state via self.api)."""
101
+ plugin, _ = _make_plugin()
102
+
103
+ event = BiffoEvent(
104
+ detail_type="UserCreated",
105
+ tenant_id="default",
106
+ payload={"cognito_sub": "user-123", "email": "new@example.com"},
107
+ )
108
+ await plugin.events.dispatch(event)
@@ -0,0 +1,27 @@
1
+ # Biffo Plugin Manifest Schema Validator
2
+
3
+ Validates `biffo.plugin.json` against the registry schema.
4
+ Used by: CLI install flow, registry CI, SDK register_plugin().
5
+
6
+ ## Usage
7
+
8
+ ```bash
9
+ python -m jsonschema -i biffo.plugin.json registry-schema.json
10
+ ```
11
+
12
+ Or programmatically:
13
+
14
+ ```python
15
+ from jsonschema import validate, ValidationError
16
+
17
+ with open("registry-schema.json") as f:
18
+ schema = json.load(f)
19
+
20
+ with open("biffo.plugin.json") as f:
21
+ manifest = json.load(f)
22
+
23
+ try:
24
+ validate(instance=manifest, schema=schema)
25
+ except ValidationError as e:
26
+ print(f"Invalid manifest: {e.message}")
27
+ ```
@@ -0,0 +1,5 @@
1
+ {
2
+ "schema_version": "1.0",
3
+ "last_updated": "2026-06-30T00:00:00Z",
4
+ "plugins": []
5
+ }