@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,294 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Biffo Plugin Manifest",
4
+ "description": "Schema for biffo.plugin.json — declares what a plugin provides and needs.",
5
+ "type": "object",
6
+ "required": ["name", "version", "tables", "api_routes", "ui_components"],
7
+ "properties": {
8
+ "name": {
9
+ "type": "string",
10
+ "pattern": "^[a-z][a-z0-9-]*$",
11
+ "description": "Unique plugin slug (lowercase, kebab-case)."
12
+ },
13
+ "version": {
14
+ "type": "string",
15
+ "pattern": "^\\d+\\.\\d+\\.\\d+$",
16
+ "description": "Semver version of this plugin release."
17
+ },
18
+ "description": {
19
+ "type": "string",
20
+ "maxLength": 500,
21
+ "description": "Human-readable summary shown in the marketplace."
22
+ },
23
+ "author": {
24
+ "type": "string",
25
+ "default": "Biffo Team",
26
+ "description": "Publisher name."
27
+ },
28
+ "tags": {
29
+ "type": "array",
30
+ "items": { "type": "string" },
31
+ "description": "Categorisation tags for filtering/search."
32
+ },
33
+ "required_core_version": {
34
+ "type": "string",
35
+ "default": ">=0.0.0",
36
+ "description": "Minimum Biffo core version (npm-style range)."
37
+ },
38
+ "tables": {
39
+ "type": "array",
40
+ "items": {
41
+ "type": "object",
42
+ "required": ["name"],
43
+ "properties": {
44
+ "name": {
45
+ "type": "string",
46
+ "pattern": "^[a-z][a-z0-9_]*$",
47
+ "description": "PostgreSQL table name (snake_case)."
48
+ },
49
+ "columns": {
50
+ "type": "array",
51
+ "description": "Additional columns beyond id/tenant_id/created_at/updated_at, which are auto-injected on every table per ADR-0001 and must NOT be declared here — doing so fails validation (ColumnDefinition/PluginTableDefinition in services/api/src/api/models/plugin_table.py).",
52
+ "items": {
53
+ "type": "object",
54
+ "required": ["name", "type"],
55
+ "properties": {
56
+ "name": {
57
+ "type": "string",
58
+ "not": { "enum": ["id", "tenant_id", "created_at", "updated_at"] },
59
+ "description": "Column name. The names id, tenant_id, created_at, and updated_at are reserved (auto-injected) and rejected here."
60
+ },
61
+ "type": {
62
+ "type": "string",
63
+ "pattern": "^(String|Integer|Text|Boolean|Float|DateTime)(\\(.*\\))?$",
64
+ "description": "SQLAlchemy column type constructor string, e.g. 'String(255)', 'Integer', or 'DateTime(timezone=True)'. Must be one of the base types plugin_table.py's _TYPE_MAP resolves (String, Integer, Text, Boolean, Float, DateTime) — anything else silently falls back to String, so this schema rejects it up front rather than allowing a PostgreSQL-style type name (e.g. UUID, TIMESTAMP) that looks valid but isn't."
65
+ },
66
+ "primary_key": {
67
+ "type": "boolean",
68
+ "default": false,
69
+ "description": "Whether this is the primary key."
70
+ },
71
+ "nullable": {
72
+ "type": "boolean",
73
+ "default": false,
74
+ "description": "Whether NULL values are allowed. Defaults to false (NOT NULL)."
75
+ },
76
+ "index": {
77
+ "type": "boolean",
78
+ "default": false,
79
+ "description": "Create a database index on this column."
80
+ },
81
+ "default": { "type": "string", "description": "SQL default value expression." },
82
+ "description": {
83
+ "type": "string",
84
+ "default": "",
85
+ "description": "Human-readable column description."
86
+ }
87
+ },
88
+ "additionalProperties": false
89
+ }
90
+ },
91
+ "indexes": {
92
+ "type": "array",
93
+ "description": "Explicit (optionally multi-column or unique) indexes on the table, beyond any single-column indexes already requested via a column's index: true.",
94
+ "items": {
95
+ "type": "object",
96
+ "required": ["name", "columns"],
97
+ "properties": {
98
+ "name": { "type": "string", "description": "Index name in the database." },
99
+ "columns": {
100
+ "type": "array",
101
+ "items": { "type": "string" },
102
+ "minItems": 1,
103
+ "description": "Column names included in the index."
104
+ },
105
+ "unique": {
106
+ "type": "boolean",
107
+ "default": false,
108
+ "description": "Whether the index enforces uniqueness."
109
+ }
110
+ },
111
+ "additionalProperties": false
112
+ }
113
+ },
114
+ "permissions": {
115
+ "type": "object",
116
+ "additionalProperties": false,
117
+ "description": "Declarative per-operation generic-CRUD permissions (ADR-0004; TablePermissions in services/api/src/api/models/plugin_table.py). Default-deny: an absent permissions block, an absent operation key, or an operation with allowed:false ALL make the table invisible to the generic CRUD layer for that operation. required_role is an any-of allow-list matched against the caller's roles (cognito:groups); an empty list means any authenticated caller. Tenant scoping (ADR-0001) is always applied and is deliberately not configurable here. Only the five operation keys below are permitted.",
118
+ "properties": {
119
+ "list": {
120
+ "type": "object",
121
+ "additionalProperties": false,
122
+ "properties": {
123
+ "allowed": {
124
+ "type": "boolean",
125
+ "default": false,
126
+ "description": "Whether the generic CRUD layer exposes this operation at all."
127
+ },
128
+ "required_role": {
129
+ "type": "array",
130
+ "items": { "type": "string" },
131
+ "default": [],
132
+ "description": "Any-of role allow-list; empty means any authenticated caller."
133
+ }
134
+ }
135
+ },
136
+ "read": {
137
+ "type": "object",
138
+ "additionalProperties": false,
139
+ "properties": {
140
+ "allowed": {
141
+ "type": "boolean",
142
+ "default": false,
143
+ "description": "Whether the generic CRUD layer exposes this operation at all."
144
+ },
145
+ "required_role": {
146
+ "type": "array",
147
+ "items": { "type": "string" },
148
+ "default": [],
149
+ "description": "Any-of role allow-list; empty means any authenticated caller."
150
+ }
151
+ }
152
+ },
153
+ "create": {
154
+ "type": "object",
155
+ "additionalProperties": false,
156
+ "properties": {
157
+ "allowed": {
158
+ "type": "boolean",
159
+ "default": false,
160
+ "description": "Whether the generic CRUD layer exposes this operation at all."
161
+ },
162
+ "required_role": {
163
+ "type": "array",
164
+ "items": { "type": "string" },
165
+ "default": [],
166
+ "description": "Any-of role allow-list; empty means any authenticated caller."
167
+ }
168
+ }
169
+ },
170
+ "update": {
171
+ "type": "object",
172
+ "additionalProperties": false,
173
+ "properties": {
174
+ "allowed": {
175
+ "type": "boolean",
176
+ "default": false,
177
+ "description": "Whether the generic CRUD layer exposes this operation at all."
178
+ },
179
+ "required_role": {
180
+ "type": "array",
181
+ "items": { "type": "string" },
182
+ "default": [],
183
+ "description": "Any-of role allow-list; empty means any authenticated caller."
184
+ }
185
+ }
186
+ },
187
+ "delete": {
188
+ "type": "object",
189
+ "additionalProperties": false,
190
+ "properties": {
191
+ "allowed": {
192
+ "type": "boolean",
193
+ "default": false,
194
+ "description": "Whether the generic CRUD layer exposes this operation at all."
195
+ },
196
+ "required_role": {
197
+ "type": "array",
198
+ "items": { "type": "string" },
199
+ "default": [],
200
+ "description": "Any-of role allow-list; empty means any authenticated caller."
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ },
207
+ "additionalProperties": false
208
+ },
209
+ "description": "Database tables the plugin creates via Alembic migrations."
210
+ },
211
+ "api_routes": {
212
+ "type": "array",
213
+ "items": {
214
+ "type": "object",
215
+ "required": ["method", "path", "table", "operation"],
216
+ "properties": {
217
+ "method": {
218
+ "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"],
219
+ "description": "HTTP method. Must be compatible with `operation` (list/read=GET, create=POST, update=PUT/PATCH, delete=DELETE) — see plugin_route.py's _OPERATION_METHODS."
220
+ },
221
+ "path": {
222
+ "type": "string",
223
+ "pattern": "^/[a-zA-Z0-9/_{}-]+$",
224
+ "description": "Path relative to the plugin's mount point (/api/v1/plugins/<name>), e.g. '/widgets' or '/widgets/{id}'. Must start with '/'. Single-row operations (read/update/delete) address a row via an '{id}' path parameter; collection operations (list/create) must not."
225
+ },
226
+ "table": {
227
+ "type": "string",
228
+ "description": "Name of a table declared in this manifest's `tables` that this route exposes. A route cannot reference another plugin's table (RouteDefinition / parse_plugin_routes_from_manifest in services/api/src/api/models/plugin_route.py)."
229
+ },
230
+ "operation": {
231
+ "enum": ["list", "read", "create", "update", "delete"],
232
+ "description": "Generic CRUD operation the Core API synthesizes for this route against `table`, scoped to the caller's tenant_id (ADR-0001). A plugin manifest cannot ship executable handler code the Core API imports and runs (ADR-0002); routes are declarative (issue #19)."
233
+ },
234
+ "description": { "type": "string" }
235
+ }
236
+ },
237
+ "description": "Declarative generic-CRUD routes the Core API synthesizes against this plugin's own tables (issue #19 / ADR-0002), mounted under /api/v1/plugins/<name>."
238
+ },
239
+ "event_subscriptions": {
240
+ "type": "array",
241
+ "items": {
242
+ "type": "object",
243
+ "required": ["detail_type"],
244
+ "properties": {
245
+ "source": { "type": "string", "default": "biffo.core" },
246
+ "detail_type": { "type": "string" },
247
+ "handler": { "type": "string" }
248
+ }
249
+ },
250
+ "description": "EventBridge events the plugin subscribes to."
251
+ },
252
+ "infra_modules": {
253
+ "type": "array",
254
+ "items": {
255
+ "enum": [
256
+ "compute",
257
+ "storage",
258
+ "events",
259
+ "cdn",
260
+ "database",
261
+ "auth",
262
+ "networking",
263
+ "oidc",
264
+ "api-gateway"
265
+ ]
266
+ },
267
+ "description": "Terraform module categories the plugin provisions."
268
+ },
269
+ "ui_components": {
270
+ "type": "array",
271
+ "items": {
272
+ "type": "object",
273
+ "required": ["type", "label", "path"],
274
+ "properties": {
275
+ "type": {
276
+ "enum": ["nav-link", "page", "dashboard-widget", "modal", "dialog"],
277
+ "description": "UI component type."
278
+ },
279
+ "label": { "type": "string" },
280
+ "path": { "type": "string" },
281
+ "icon": { "type": "string" },
282
+ "requires_auth": { "type": "boolean", "default": true }
283
+ }
284
+ },
285
+ "description": "Portal UI elements the plugin adds."
286
+ },
287
+ "dependencies": {
288
+ "type": "object",
289
+ "additionalProperties": { "type": "string" },
290
+ "description": "Python package dependencies (including biffo-plugin-sdk)."
291
+ }
292
+ },
293
+ "additionalProperties": false
294
+ }
File without changes
@@ -0,0 +1,10 @@
1
+ """Example plugin — reference implementation shipped with the plugin
2
+ repository template.
3
+
4
+ Rename this package (and `example_plugin` throughout the repo) to your
5
+ plugin's own name when you start from this template. See README.md.
6
+ """
7
+
8
+ from .plugin import ExamplePlugin
9
+
10
+ __all__ = ["ExamplePlugin"]
@@ -0,0 +1,63 @@
1
+ """Example plugin Lambda entrypoint.
2
+
3
+ Mirrors the RBAC reference plugin's `services/rbac/src/rbac/main.py` and
4
+ `services/_template/src/service/main.py`'s documented event shape and rules
5
+ (ADR-0002): never import a DB client, talk to the Core API only via
6
+ `BiffoAPIClient`, react to state changes via EventBridge.
7
+
8
+ This handler's only responsibility is dispatching EventBridge events the
9
+ plugin subscribes to (see `plugin.py`) through `ExamplePlugin`'s
10
+ `EventSubscriber`. It is not wired to a real Lambda in this template — that
11
+ wiring (an EventBridge rule targeting this function) belongs to your
12
+ plugin's own `terraform/` module, added once ADR-0003's conditional plugin
13
+ Terraform inclusion (issue #25) lands in a generated project.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+
20
+ from aws_lambda_powertools import Logger, Tracer
21
+ from aws_lambda_powertools.utilities.typing import LambdaContext
22
+ from biffo_plugin_sdk import create_event_handler
23
+
24
+ from .plugin import ExamplePlugin
25
+
26
+ logger = Logger()
27
+ tracer = Tracer()
28
+
29
+ # Reuse one event loop across warm invocations. asyncio.run() closes the loop
30
+ # each call, but the plugin's reused httpx client pools connections bound to
31
+ # the first loop -> "RuntimeError: Event loop is closed" on the next
32
+ # invocation. Mirrors services/api/src/api/main.py.
33
+ _loop = asyncio.new_event_loop()
34
+ asyncio.set_event_loop(_loop)
35
+
36
+ _plugin: ExamplePlugin | None = None
37
+
38
+
39
+ def _get_plugin() -> ExamplePlugin:
40
+ """Lazily construct the plugin singleton, reused across warm invocations
41
+ — a misconfigured environment (e.g. missing BIFFO_CORE_API_URL) surfaces
42
+ at first use rather than being buried inside every event."""
43
+ global _plugin
44
+ if _plugin is None:
45
+ _plugin = ExamplePlugin()
46
+ return _plugin
47
+
48
+
49
+ @logger.inject_lambda_context
50
+ @tracer.capture_lambda_handler
51
+ def handler(event: dict, context: LambdaContext) -> dict:
52
+ logger.info("Received event", extra={"event": event})
53
+
54
+ global _loop
55
+ if _loop.is_closed():
56
+ _loop = asyncio.new_event_loop()
57
+ asyncio.set_event_loop(_loop)
58
+
59
+ biffo_event = create_event_handler(event)
60
+ plugin = _get_plugin()
61
+ _loop.run_until_complete(plugin.events.dispatch(biffo_event))
62
+
63
+ return {"statusCode": 200}
@@ -0,0 +1,30 @@
1
+ """Path to this plugin's manifest file, shared by plugin.py and tests.
2
+
3
+ Mirrors the RBAC reference plugin's `services/rbac/src/rbac/manifest.py`
4
+ (PR #76) — same rationale: `plugin.py` and the tests both need a reliable
5
+ absolute path to `biffo.plugin.json` regardless of the current working
6
+ directory the process was started from.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+
14
+ # src/example_plugin/manifest.py -> src/example_plugin -> src -> repo root
15
+ # (plugin root, where biffo.plugin.json lives alongside pyproject.toml).
16
+ def _resolve_manifest_path() -> Path:
17
+ # Walk up from this module to find biffo.plugin.json, so it resolves in both
18
+ # the repo layout (src/<pkg>/manifest.py, manifest at the plugin root) and the
19
+ # deployed Lambda (handler <pkg>.main.handler unzips <pkg>/ at the task root,
20
+ # manifest bundled at /var/task/biffo.plugin.json). A fixed parents[2]
21
+ # resolved to /var in the Lambda and raised FileNotFoundError on first invoke.
22
+ here = Path(__file__).resolve()
23
+ for base in here.parents:
24
+ candidate = base / "biffo.plugin.json"
25
+ if candidate.is_file():
26
+ return candidate
27
+ return here.parent.parent / "biffo.plugin.json"
28
+
29
+
30
+ MANIFEST_PATH = _resolve_manifest_path()
@@ -0,0 +1,92 @@
1
+ """ExamplePlugin — this plugin's BiffoPluginBase implementation.
2
+
3
+ Seeds a single default widget on install and reacts to
4
+ `biffo.core/UserCreated` by logging the new user. Replace both with real
5
+ logic for your own plugin — this exists to show a working, correctly-wired
6
+ skeleton: constructor -> manifest loading -> `self.api` -> `@self.subscribe`
7
+ -> `on_install`/`on_uninstall`, exercised end to end by the tests in
8
+ `tests/test_example_plugin.py`.
9
+
10
+ Modelled directly on the RBAC reference plugin's
11
+ `services/rbac/src/rbac/plugin.py` (PR #76) in the biffo-template monorepo —
12
+ see that file (and its README) for a more elaborate example with multiple
13
+ tables, an event-driven side effect, and an in-process (non-CRUD) helper.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ from typing import Any
20
+
21
+ from aws_lambda_powertools import Logger
22
+ from biffo_plugin_sdk import BiffoAPIClient, BiffoEvent, BiffoPluginBase, load_manifest
23
+
24
+ from .manifest import MANIFEST_PATH
25
+
26
+ logger = Logger()
27
+
28
+ _PLUGIN_BASE_PATH = "/api/v1/plugins/example-plugin"
29
+
30
+ # Seeded by on_install(). Real plugins would seed whatever baseline data
31
+ # their feature needs (RBAC's plugin.py seeds three roles, for example).
32
+ _DEFAULT_WIDGET: dict[str, Any] = {
33
+ "name": "starter-widget",
34
+ "description": "Created automatically by on_install(). Safe to delete.",
35
+ "is_active": True,
36
+ }
37
+
38
+
39
+ class ExamplePlugin(BiffoPluginBase):
40
+ """The example plugin shipped with the plugin repository template."""
41
+
42
+ def __init__(self, api: BiffoAPIClient | None = None) -> None:
43
+ manifest = load_manifest(MANIFEST_PATH)
44
+ super().__init__(manifest, api=api)
45
+
46
+ @self.subscribe("UserCreated")
47
+ async def _on_user_created(event: BiffoEvent) -> None:
48
+ await self._log_user_created(event)
49
+
50
+ def on_install(self) -> None:
51
+ """Called by the CLI when the plugin is installed.
52
+
53
+ `BiffoPluginBase.on_install` is declared synchronous but
54
+ `BiffoAPIClient` is entirely async (same constraint the RBAC
55
+ reference plugin documents) — bridge with `asyncio.run` here and
56
+ keep the actual async logic in the public `seed_default_widget()`,
57
+ which tests and any future async caller can `await` directly.
58
+ """
59
+ asyncio.run(self.seed_default_widget())
60
+
61
+ async def seed_default_widget(self) -> None:
62
+ """The async core of on_install().
63
+
64
+ Generic CRUD (issue #19) has no upsert — every POST creates a new
65
+ row — so this lists existing widgets first and only creates the
66
+ default one if it's missing by name, making repeated installs a
67
+ no-op instead of creating duplicate rows.
68
+ """
69
+ existing = await self.api.get(f"{_PLUGIN_BASE_PATH}/widgets")
70
+ existing_names = {row.get("name") for row in existing}
71
+ if _DEFAULT_WIDGET["name"] not in existing_names:
72
+ await self.api.post(f"{_PLUGIN_BASE_PATH}/widgets", json=dict(_DEFAULT_WIDGET))
73
+
74
+ def on_uninstall(self) -> None:
75
+ """Called by the CLI when the plugin is uninstalled.
76
+
77
+ No-op: dropping this plugin's tables is the CLI's job (ADR-0003
78
+ section 9 — `biffo plugin uninstall` applies a generated migration
79
+ that drops them). Override this if your plugin needs additional
80
+ cleanup beyond that (e.g. deleting objects it created outside its
81
+ own tables).
82
+ """
83
+ return None
84
+
85
+ async def _log_user_created(self, event: BiffoEvent) -> None:
86
+ """Example event handler. Replace with real logic, or remove the
87
+ `@self.subscribe` registration in `__init__` if your plugin doesn't
88
+ need to react to this event."""
89
+ logger.info(
90
+ "New user created",
91
+ extra={"tenant_id": event.tenant_id, "payload": event.payload},
92
+ )
@@ -0,0 +1,146 @@
1
+ # `terraform/` — this plugin's infrastructure
2
+
3
+ This is the plugin's own Terraform module, per [ADR-0003](https://github.com/keiranholloway/biffo-template/blob/main/docs/ADR/0003-plugin-system-and-marketplace.md) section 2. It is a verbatim copy of biffo-template's `modules/plugins/_template/`, kept here so a plugin built from this skeleton is **deployable out of the box**: without it, the `event_subscriptions` in `biffo.plugin.json` are declared, handled in code, unit-tested — and inert in every real deployment, because nothing creates the Lambda or the EventBridge rule to fire them.
4
+
5
+ `biffo plugin install <name>@<minor>` copies this directory into the user's monorepo at `modules/plugins/<name>/`. Note that the install path is where it becomes valid Terraform: `main.tf`'s `source = "../../cloud/aws/compute"` is relative to `modules/plugins/<name>/`, so `terraform validate` does **not** run against this directory in place — neither here nor in biffo-template, whose Terraform CI job scopes itself to `infra/` and `modules/`. Do not "fix" that path; changing it breaks the install target.
6
+
7
+ Rename `example_plugin`/`<name>` references to your plugin's slug and adjust `handler`, `event_subscriptions`, and `memory_size`/`timeout` to match your manifest. The rest of this file is the module's own documentation, unchanged.
8
+
9
+ ---
10
+
11
+ ## Original module notes
12
+
13
+ ## What it wraps, and why
14
+
15
+ This module is a thin wrapper around two existing modules, not a reimplementation:
16
+
17
+ - **`modules/cloud/aws/compute`** — the plugin's Lambda function (IAM role, DLQ, log group, tracing — the same baseline every Biffo function gets).
18
+ - **`modules/cloud/aws/events`**, via the `event_bus_name` passed in from the root config — this module only adds the EventBridge rule/target/permission needed to route the plugin's declared `event_subscriptions` to its Lambda. It never creates a bus of its own.
19
+
20
+ Per **ADR-0002** ("no DB clients outside `services/api/`; microservices call the API via HTTP and react to EventBridge events"), a plugin module:
21
+
22
+ - **Never receives `db_credentials_secret_arn`.** Only the Core API's Lambda (`module.core_api` in the root config) gets that grant. A plugin that needs platform data calls the Core API over HTTPS using `BIFFO_CORE_API_URL` (wired here from `var.core_api_url`) via the plugin SDK's Core client — see [Calling the Core API](#calling-the-core-api-adr-0009) below for how that call is authenticated.
23
+ - **Is not VPC-attached by default.** `enable_vpc_access` defaults to `false` in both this module and `modules/cloud/aws/compute`, so the plugin's Lambda runs with normal Lambda internet egress — required to reach the Core API's public endpoint, especially in NAT-less environments like dev (`enable_nat_gateway = false`). Only set `enable_vpc_access = true` (and pass `vpc_id`/`private_subnet_ids`) if the plugin has a genuine ADR-0002-compliant reason to reach a VPC-only resource (e.g. ElastiCache) — never to reach the database directly. `enable_vpc_access` must be an explicit boolean, not inferred from `vpc_id` — Terraform's `count`/`for_each` can't depend on a `vpc_id` that's only known after apply.
24
+
25
+ ## Variables
26
+
27
+ See `variables.tf`. The standard set every plugin module receives from the root config: `project_name`, `environment`, `plugin_name`, `event_bus_name`, `core_api_url`, `tags`, plus `core_api_execution_arn` for any plugin that calls the Core API. Everything else (`handler`, `event_subscriptions`, `environment_variables`, etc.) is plugin-specific.
28
+
29
+ ## Calling the Core API (ADR-0009)
30
+
31
+ A plugin may not touch the database (ADR-0002), so anything it needs from the
32
+ platform it fetches from the Core API. The Core API's **internal** routes
33
+ (`/api/v1/internal/*`) are authorized by **IAM SigV4**, not by a Cognito JWT —
34
+ per [ADR-0009](https://github.com/keiranholloway/biffo-template/blob/main/docs/ADR/0009-internal-service-authentication.md). There
35
+ is no bearer token to issue, store, or rotate; the credential is the plugin
36
+ Lambda's own role.
37
+
38
+ Three things must line up, and all three are wired for you — this module
39
+ does the first two, and `biffo plugin install` does the third:
40
+
41
+ 1. **Sign the request.** The plugin SDK's `SignedCoreClient` does this, and
42
+ `BiffoPluginBase` builds one by default (`create_core_client()`), so plugin
43
+ code that just calls `self.api.post(...)` is already signing. `botocore` is
44
+ preinstalled in the Lambda Python runtime; outside Lambda install the
45
+ `biffo-plugin-sdk[sigv4]` extra.
46
+ 2. **Grant `execute-api:Invoke`.** Set `core_api_execution_arn` (the root
47
+ config's `module.api_gateway.execution_arn`) and this module attaches an
48
+ inline policy to the plugin's Lambda role allowing `execute-api:Invoke` on
49
+ `<execution_arn>/*/*/api/v1/internal/*` — that prefix only, never the whole
50
+ API. Leave the variable empty and no grant is created.
51
+ 3. **Allowlist the role on the Core API.** _Automatic since issue #201._ The
52
+ Core API independently re-checks the resolved caller ARN against
53
+ `BIFFO_SERVICE_PRINCIPAL_ARN_ALLOWLIST` and **fails closed** on an empty or
54
+ non-matching allowlist, so step 2 alone would get you a `403`. At runtime the
55
+ caller ARN is the assumed-role session form, so the allowlist entry is a
56
+ glob:
57
+
58
+ ```
59
+ arn:aws:sts::<account-id>:assumed-role/<project>-<env>-plugin-<name>-role/*
60
+ ```
61
+
62
+ The template-owned `modules/cloud/aws/plugin-allowlist` module builds that
63
+ list, mapping over the root config's `var.enabled_plugins`
64
+ — so adding the plugin to `enabled_plugins` (which `biffo plugin install`
65
+ does for you, via the generated `plugins.auto.tfvars.json`) is what
66
+ allowlists it. Nothing to copy by hand, and the grant and the allowlist
67
+ cannot drift apart.
68
+
69
+ The role name is deterministic, which is what makes this possible:
70
+ `modules/cloud/aws/compute` names the role `<function_name>-role`, where
71
+ `function_name` is `<project_name>-<environment>-plugin-<plugin_name>`. It is
72
+ also this module's `role_name` output — useful for auditing, but the root
73
+ config deliberately does not read it (see below).
74
+
75
+ ### Why the allowlist is a static string, not this module's output
76
+
77
+ Tempting as it is to write
78
+ `BIFFO_SERVICE_PRINCIPAL_ARN_ALLOWLIST = module.plugin_<name>.role_arn`, that
79
+ creates a Terraform **dependency cycle** (issue
80
+ [#201](https://github.com/keiranholloway/biffo-template/issues/201)):
81
+
82
+ ```
83
+ core_api -> api_gateway -> plugin -> core_api
84
+ (needs the plugin's role ARN) (needs the API's execution ARN)
85
+ ```
86
+
87
+ The plugin needs the API Gateway's `execution_arn` to scope its grant, and the
88
+ Core API Lambda would need the plugin's `role_arn` for its allowlist env var.
89
+ Terraform cannot order that.
90
+
91
+ The resolution — the same one the orchestrator uses — is that the arrow only
92
+ ever points **one way**: API Gateway -> plugin. The allowlist entry is written as
93
+ the _predictable_ role-name glob above, interpolated from values the root config
94
+ already knows (`project_name`, `environment`, plugin name) rather than read back
95
+ out of the plugin module.
96
+
97
+ **How close the cycle actually is.** Measured on the current module, wiring
98
+ `role_arn` does _not_ deadlock today: Terraform's dependency graph is
99
+ resource-level, not module-level, and this module's `aws_iam_role` does not
100
+ itself depend on API Gateway — only the separate `aws_iam_role_policy.core_api`
101
+ does. So a `terraform plan` with `role_arn` wired in currently builds. That is
102
+ an accident of this module's internals, not a property you can rely on: a plugin
103
+ that attached its Core API policy to the role resource itself (an inline
104
+ `inline_policy` block rather than a separate `aws_iam_role_policy`) closes the
105
+ loop immediately, and the resulting cycle error lands on whoever _installed_
106
+ that plugin, far from the code that caused it. It would also make the Core API
107
+ un-plannable whenever any installed plugin module is broken.
108
+
109
+ The static glob has no dependency on the plugin module at all, for any plugin,
110
+ which is the property worth having. Keep it that way.
111
+
112
+ ## Loose coupling
113
+
114
+ This module must never reference another plugin's module or resources. Each plugin is instantiated independently by the root config's `for_each`-gated `module "plugin_<name>"` block — no plugin module may assume any other plugin is installed.
115
+
116
+ ## Wiring into the root config
117
+
118
+ `biffo plugin install` generates this block for you, into a CLI-owned
119
+ `infra/environments/<env>/plugins.generated.tf` (issue #201) — you should not
120
+ need to write it by hand. It is reproduced here so you know what your module is
121
+ instantiated with.
122
+
123
+ The root config cannot dynamically discover plugin module directories —
124
+ Terraform requires a module's `source` argument to be a static string literal,
125
+ so each plugin needs its own explicit block, gated on membership in
126
+ `enabled_plugins`:
127
+
128
+ ```hcl
129
+ module "plugin_<name>" {
130
+ source = "../../../modules/plugins/<name>"
131
+ for_each = contains(var.enabled_plugins, "<name>") ? { "<name>" = true } : {}
132
+
133
+ project_name = var.project_name
134
+ environment = local.environment
135
+ plugin_name = "<name>"
136
+ handler = "src.lambda.main.handler"
137
+ event_bus_name = module.events.event_bus_name
138
+ core_api_url = module.api_gateway.api_endpoint
139
+ tags = local.tags
140
+
141
+ # Only if the plugin calls the Core API — see "Calling the Core API" below.
142
+ core_api_execution_arn = module.api_gateway.execution_arn
143
+ }
144
+ ```
145
+
146
+ The generator emits only the arguments your module actually declares a `variable` block for, so a module predating one of these inputs still wires in cleanly. See `infra/environments/dev/README.md` for the full convention.