@bongos/core 1.19.1079 → 1.19.1081
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.
- package/.bongos-core.json +86 -31
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +2 -0
- package/docs/adr/0347-every-store-module-ships-a-how-to.md +135 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +126 -3
- package/docs/api-reference.md +3 -2
- package/docs/architecture.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +7 -1
- package/modules/provisioning/migrations/provisioning_028_render_standup.sql +46 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +5 -5
- package/modules/provisioning/render-standup.js +91 -0
- package/modules/provisioning/routes/render-standup.js +90 -0
- package/package-lock.json +2 -2
- package/package.json +2 -2
- package/release-notes.json +16 -0
- package/scripts/gds/module.js +176 -46
- package/scripts/gds/provision-render.js +158 -0
- package/scripts/gds/provision-teardown.js +2 -0
- package/scripts/gds/provision.js +2 -2
- package/scripts/gds/render-api.js +178 -0
- package/scripts/gds/render-payload.js +110 -0
- package/src/module-api.js +1 -1
- package/tests/module_update.mjs +232 -0
- package/tests/provision_render.mjs +269 -0
- package/tests/provisioning_render_route.mjs +216 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/render_api.mjs +153 -0
package/docs/api/openapi.json
CHANGED
|
@@ -14645,6 +14645,69 @@
|
|
|
14645
14645
|
]
|
|
14646
14646
|
}
|
|
14647
14647
|
},
|
|
14648
|
+
"/provisioning/instances/{id}/render": {
|
|
14649
|
+
"post": {
|
|
14650
|
+
"operationId": "post_provisioning_instances_id_render",
|
|
14651
|
+
"tags": [
|
|
14652
|
+
"provisioning"
|
|
14653
|
+
],
|
|
14654
|
+
"summary": "POST /provisioning/instances/:id/render",
|
|
14655
|
+
"description": "POST /provisioning/instances/:id/render — rank: any authenticated builder, and OWNER ONLY: the key is the owner's and so is the account it acts in, so an archon on someone else's project gets the same 404 as a stranger. The reply is a receipt; nothing has happened on Render when it is sent.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
14656
|
+
"x-rank": "any-builder",
|
|
14657
|
+
"x-source": "modules/provisioning/routes/render-standup.js",
|
|
14658
|
+
"parameters": [
|
|
14659
|
+
{
|
|
14660
|
+
"name": "id",
|
|
14661
|
+
"in": "path",
|
|
14662
|
+
"required": true,
|
|
14663
|
+
"schema": {
|
|
14664
|
+
"type": "string"
|
|
14665
|
+
},
|
|
14666
|
+
"description": "Path parameter `id`."
|
|
14667
|
+
}
|
|
14668
|
+
],
|
|
14669
|
+
"requestBody": {
|
|
14670
|
+
"required": true,
|
|
14671
|
+
"content": {
|
|
14672
|
+
"application/json": {
|
|
14673
|
+
"schema": {
|
|
14674
|
+
"$ref": "#/components/schemas/PostProvisioningInstancesIdRenderRequest"
|
|
14675
|
+
}
|
|
14676
|
+
}
|
|
14677
|
+
},
|
|
14678
|
+
"x-validated": true
|
|
14679
|
+
},
|
|
14680
|
+
"responses": {
|
|
14681
|
+
"200": {
|
|
14682
|
+
"description": "Success.",
|
|
14683
|
+
"content": {
|
|
14684
|
+
"application/json": {
|
|
14685
|
+
"schema": {
|
|
14686
|
+
"$ref": "#/components/schemas/PostProvisioningInstancesIdRenderResponse"
|
|
14687
|
+
}
|
|
14688
|
+
}
|
|
14689
|
+
}
|
|
14690
|
+
},
|
|
14691
|
+
"400": {
|
|
14692
|
+
"$ref": "#/components/responses/ValidationFailed"
|
|
14693
|
+
},
|
|
14694
|
+
"401": {
|
|
14695
|
+
"$ref": "#/components/responses/Unauthorized"
|
|
14696
|
+
},
|
|
14697
|
+
"403": {
|
|
14698
|
+
"$ref": "#/components/responses/Forbidden"
|
|
14699
|
+
},
|
|
14700
|
+
"404": {
|
|
14701
|
+
"$ref": "#/components/responses/NotFound"
|
|
14702
|
+
}
|
|
14703
|
+
},
|
|
14704
|
+
"security": [
|
|
14705
|
+
{
|
|
14706
|
+
"builderSession": []
|
|
14707
|
+
}
|
|
14708
|
+
]
|
|
14709
|
+
}
|
|
14710
|
+
},
|
|
14648
14711
|
"/provisioning/instances/{id}/repo-private": {
|
|
14649
14712
|
"post": {
|
|
14650
14713
|
"operationId": "post_provisioning_instances_id_repo_private",
|
|
@@ -26353,6 +26416,66 @@
|
|
|
26353
26416
|
"expires_at"
|
|
26354
26417
|
]
|
|
26355
26418
|
},
|
|
26419
|
+
"PostProvisioningInstancesIdRenderRequest": {
|
|
26420
|
+
"type": "object",
|
|
26421
|
+
"properties": {
|
|
26422
|
+
"key": {
|
|
26423
|
+
"type": "string",
|
|
26424
|
+
"maxLength": 200
|
|
26425
|
+
},
|
|
26426
|
+
"owner_id": {
|
|
26427
|
+
"type": "string",
|
|
26428
|
+
"maxLength": 80
|
|
26429
|
+
},
|
|
26430
|
+
"mode": {
|
|
26431
|
+
"type": "string",
|
|
26432
|
+
"maxLength": 20
|
|
26433
|
+
},
|
|
26434
|
+
"service_id": {
|
|
26435
|
+
"type": "string",
|
|
26436
|
+
"maxLength": 80
|
|
26437
|
+
},
|
|
26438
|
+
"web_plan": {
|
|
26439
|
+
"type": "string",
|
|
26440
|
+
"maxLength": 40
|
|
26441
|
+
},
|
|
26442
|
+
"db_plan": {
|
|
26443
|
+
"type": "string",
|
|
26444
|
+
"maxLength": 40
|
|
26445
|
+
},
|
|
26446
|
+
"region": {
|
|
26447
|
+
"type": "string",
|
|
26448
|
+
"maxLength": 40
|
|
26449
|
+
}
|
|
26450
|
+
},
|
|
26451
|
+
"required": [
|
|
26452
|
+
"key",
|
|
26453
|
+
"owner_id",
|
|
26454
|
+
"mode"
|
|
26455
|
+
],
|
|
26456
|
+
"additionalProperties": false
|
|
26457
|
+
},
|
|
26458
|
+
"PostProvisioningInstancesIdRenderResponse": {
|
|
26459
|
+
"type": "object",
|
|
26460
|
+
"properties": {
|
|
26461
|
+
"ok": {
|
|
26462
|
+
"type": "boolean"
|
|
26463
|
+
},
|
|
26464
|
+
"queued": {
|
|
26465
|
+
"type": "boolean"
|
|
26466
|
+
},
|
|
26467
|
+
"action": {},
|
|
26468
|
+
"mode": {},
|
|
26469
|
+
"message": {}
|
|
26470
|
+
},
|
|
26471
|
+
"required": [
|
|
26472
|
+
"ok",
|
|
26473
|
+
"queued",
|
|
26474
|
+
"action",
|
|
26475
|
+
"mode",
|
|
26476
|
+
"message"
|
|
26477
|
+
]
|
|
26478
|
+
},
|
|
26356
26479
|
"PostProvisioningInstancesIdRepoPrivateResponse": {
|
|
26357
26480
|
"type": "object",
|
|
26358
26481
|
"properties": {
|
|
@@ -28072,9 +28195,9 @@
|
|
|
28072
28195
|
"description": "A required dependency/feature is not configured or is temporarily down."
|
|
28073
28196
|
}
|
|
28074
28197
|
},
|
|
28075
|
-
"x-endpoint-count":
|
|
28076
|
-
"x-schema-count":
|
|
28198
|
+
"x-endpoint-count": 440,
|
|
28199
|
+
"x-schema-count": 473,
|
|
28077
28200
|
"x-undocumented-bodies": 11,
|
|
28078
|
-
"x-response-schemas":
|
|
28201
|
+
"x-response-schemas": 323,
|
|
28079
28202
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
28080
28203
|
}
|
package/docs/api-reference.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Bongos API reference
|
|
4
4
|
|
|
5
|
-
> **Generated from the live route files** — the route file is authoritative.
|
|
5
|
+
> **Generated from the live route files** — the route file is authoritative. 440 endpoints across 76 route files.
|
|
6
6
|
> Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
|
|
7
7
|
|
|
8
8
|
Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust-boundary-server-enforced-permissions.md)): `public` < `any-builder` < `metic+archon` < `archon`.
|
|
@@ -538,7 +538,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
|
|
|
538
538
|
| GET | `/api/bongos/projects/featured` | `public` | — | the public map of projects; no auth by design. |
|
|
539
539
|
| POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | own-or-metic in effect: invite a builder into a project (ADR 0141 §5, task 1002115), called by a `project.curate` holder OR the project's… |
|
|
540
540
|
|
|
541
|
-
## `provisioning` (
|
|
541
|
+
## `provisioning` (32)
|
|
542
542
|
|
|
543
543
|
| Method | Path | Rank | Body | Description |
|
|
544
544
|
|---|---|---|---|---|
|
|
@@ -563,6 +563,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
|
|
|
563
563
|
| POST | `/api/bongos/provisioning/instances/:id/force-teardown` | `metic+archon` | — | POST /provisioning/instances/:id/force-teardown — Archon tears down ANY instance. |
|
|
564
564
|
| POST | `/api/bongos/provisioning/instances/:id/github-app` | `any-builder` | `account` | POST /provisioning/instances/:id/github-app — mint a GitHub-App-Manifest onboarding link for the caller's OWN instance (task 2080, ADR 01… |
|
|
565
565
|
| GET | `/api/bongos/provisioning/instances/:id/invite-suggestions` | `any-builder` | — | ── Who is worth inviting (BV1.R28, task 1002347, goal 1000046 C6) ──────── GET /provisioning/instances/:id/invite-suggestions — turn the … |
|
|
566
|
+
| POST | `/api/bongos/provisioning/instances/:id/render` | `any-builder` | `key`, `owner_id`, `mode`, `service_id`, `web_plan`, `db_plan`, `region` | POST /provisioning/instances/:id/render — rank: any authenticated builder, and OWNER ONLY: the key is the owner's and so is the account i… |
|
|
566
567
|
| POST | `/api/bongos/provisioning/instances/:id/repo-private` | `any-builder` | validated | POST /provisioning/instances/:id/repo-private — rank: any authenticated builder, and OWNER ONLY: no archon override, because the runner f… |
|
|
567
568
|
| POST | `/api/bongos/provisioning/instances/:id/restart` | `any-builder` | — | POST /provisioning/instances/:id/restart — restart an ACTIVE project the heartbeat cannot reach (task 1003238). |
|
|
568
569
|
| GET | `/api/bongos/provisioning/instances/:id/settings` | `any-builder` | — | GET /provisioning/instances/:id/settings — read a project's effective settings + the allowed values (so a form can render its choices). |
|
package/docs/architecture.md
CHANGED
|
@@ -261,7 +261,7 @@ Bongos views: `claimable_tasks` (rebuilt in 006 to use SELECT t.*; rebuilt again
|
|
|
261
261
|
- Authenticated CLI/web: `GET /me` (returns `rank`), `GET /versions[/progress]`, `GET /tasks[?version=&status=&kind=&discipline=]`, `GET /tasks/claimable[?version=&discipline=]`, `GET /tasks/:id`, `POST /tasks` (accepts `discipline` + `criterion_ids`; **metic+** — [ADR 0090](adr/0090-metic-task-authoring.md)), `PATCH /tasks/:id` (supports `parent_task_id`, `kind`, `discipline`, `goal_id` — (re)assign the task's goal; must be a goal in the task's own version, task 1763; **metic+**), `POST /tasks/:id/promote` (**metic+**), `POST /claims`, `POST /claims/:id/resolve`, `POST /cost`.
|
|
262
262
|
- Criterion rollup + task↔criterion links ([ADR 0025](adr/0025-structured-criterion-task-link.md), [#435](https://example.com/builders#/task/435)/[#438](https://example.com/builders#/task/438)): `GET /versions/:id/progress` rolls up each done-when criterion → its gating tasks (via `task_criteria`) → live status counts + the not-yet-shipped `remaining[]` + `unattributed_tasks` (the read behind the `/status` skill, in `src/bongos/done-when.js criterionProgress`). The link CRUD mirrors the dependency endpoints: `GET /tasks/:id/criteria`, `POST /tasks/:id/criteria` (**metic+** — [ADR 0090](adr/0090-metic-task-authoring.md)) and `DELETE /tasks/:id/criteria/:criterionId` (**metic+**). A criterion ref is a numeric `done_when_criteria.id`, a positional `"Cn"` token, or a `criterion_id` slug, resolved against the task's version (`POST /tasks` links at create time so `/status` counts the task with no backfill).
|
|
263
263
|
- Module store publish (ADR 0338 D1, task 1004271): `POST /store/modules/:key/versions` (`requireBuilder` + `requirePermission('module.submit')`, Metic floor). The body is the raw gzip tarball `bongos module publish` builds (own `express.raw` parser, 5 MB cap; 16 MB / 2000 files unpacked). `scripts/gds/module-artifact.js` re-verifies every hash and the publish gate, then `src/bongos/module-store.js` keeps the file under `var/module-store/` and inserts the `store_module_versions` row in one transaction. The first publish makes the caller the author; later versions are author-only, newer than the last and never overwritten; a delisted key takes none.
|
|
264
|
-
- Module store install (ADR 0338 D1, task 1003785), all `requireBuilder` and own-scoped (the holder is the caller): `POST /store/modules/:key/acquire` (`{version?}`; grants a free entitlement — no price row, or a zero price, is free — or `402 payment_required` for a priced module; refuses a delisted one), `GET /store/modules/:key/versions/:version/tarball` (served only to a live entitlement holder; `X-Tarball-Sha256`), `POST /store/modules/:key/acquired` (`{version}` → `recordAcquired`). Read side in `src/bongos/module-store.js` (`resolveAcquirable`, `isFreePrice`, `versionArtifactFile`). No channel check yet (tasks 1003815, 1003805).
|
|
264
|
+
- Module store install (ADR 0338 D1, task 1003785), all `requireBuilder` and own-scoped (the holder is the caller): `POST /store/modules/:key/acquire` (`{version?}`; grants a free entitlement — no price row, or a zero price, is free — or `402 payment_required` for a priced module; refuses a delisted one), `GET /store/modules/:key/versions/:version/tarball` (served only to a live entitlement holder; `X-Tarball-Sha256`), `POST /store/modules/:key/acquired` (`{version}` → `recordAcquired`). Read side in `src/bongos/module-store.js` (`resolveAcquirable`, `isFreePrice`, `versionArtifactFile`). No channel check yet (tasks 1003815, 1003805). `bongos module update` (task 1003786) reuses the same three calls; the acquire route reads price only for a caller with no entitlement, so a holder is never charged again.
|
|
265
265
|
- Server-mediated branch publish (ADR 0055 / [#1025](https://example.com/builders#/task/1025) — lets a checkout with no GitHub push credential ship). Owner-gated like ship: `POST /tasks/:id/publish-branch` (`requireBuilder` + `gateTaskOwnership`; own 32 MB json parser for the base64 thin-bundle body, 20 MB decoded cap; the server pushes the branch + opens the PR + auto-merges with a server-side push credential — a **GitHub App** installation token (short-lived, repo-scoped; preferred, [ADR 0055](adr/0055-server-mediated-branch-publish.md) update / task 1028) or the `GITHUB_PUSH_TOKEN` PAT fallback; `503 push_unconfigured` when neither is set; `400 tip_mismatch` if the bundle tip ≠ the claimed `head_sha`) and `GET /tasks/:id/publish-status?branch=…` (polls PR + deploy-prod state derived live from GitHub). `branch` is optional: without it the server finds the task's own PR by its `task <id>: …` title, and the answer names the `branch` it used and its `branch_source` (`query` / `task_pr`, or `null` with a `branch_hint` when there is no such PR) — never a `400` (task 1003764). When the PR is still open it also carries `merge_driver`, whose `reason` says why it has not merged; on a red PR `failing_tests` names the failing unit tests (read from the `unit-report` commit status the `unit` workflow posts) and `checks_unreadable: true` says the check list is blind because the App lacks `Checks: read` — so an empty `checks[]` is never read as "nothing failed" (task 1003988, ADR 0301). Because the server's credential is the PR's AUTHOR, publish also **assigns the PR to the task's claim holder** and names them in the body (`modules/lifecycle/pr-assign.js`, task 1003991) — best-effort: the 201 carries `pr_assignee` (the login, or `null` when GitHub would not assign it) and a failed assignment never fails the publish. `ship.js` uses these in `ci` mode only when `pushVia()` resolves to server (no authenticated `gh` on the machine, or `<PREFIX>_PUSH_VIA_SERVER=1`); a machine with `gh` keeps the local push path unchanged. Implementation in `src/bongos/github-push.js`.
|
|
266
266
|
- Archon monitoring reads backing the `/watch` page (ADR 0036): `GET /builders/roster`, `GET /grades/by-builder[?days=N]` (**archon-only**, [#726](https://example.com/builders#/task/726) — per-builder grade breakdown for the MARKS section; the project-wide aggregate stays public at `/public/grades`), `GET /audit-log`, `GET /override-requests`, `GET /access-requests`, `GET /security/reports`. Personal-prefs writes backing `/settings`: `GET/PATCH /me/skill-prefs`, `PATCH /me/disciplines`.
|
|
267
267
|
- Per-builder "needs" + own Gemini key ([ADR 0073](adr/0073-builder-needs-signal-and-byok-gemini-key.md), [#1013](https://example.com/builders#/task/1013)): `GET /me` now also carries `needs` ({items, action_needed_count} — the consistent "the system needs an input from you" signal; `modules/builder-settings/builder-needs.js` is the SSOT — carved out in BV1.R80, resolved by `GET /me` via the `builder-settings` kernel port — rendered by the hall Standing card + the CLI `printNeedsNudge`). The own-key (pragmatic BYOK) endpoints, all own-scoped: `GET /me/art-key/own` (masked meta — last4 only), `PUT /me/art-key/own` (validate-on-save via a Google list-models call → encrypt with `src/bongos/secret-box.js` → store; `503 storage_not_configured` until `BUILDER_SECRET_KEY` is set, `422 invalid_key` on a bad key), `DELETE /me/art-key/own`. The existing `GET /me/art-key` (shared-key delivery) is extended to also deliver the decrypted own key, which `scripts/gds/fetch-art-key.js` syncs into the local `gemini_api_key` slot (own key wins in the `gen_api.py` cascade).
|
|
@@ -2649,5 +2649,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2649
2649
|
landed since 1.19.1077 with no explicit bump. run 36600137276. (task 1002620)
|
|
2650
2650
|
1.19.1079 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2651
2651
|
landed since 1.19.1078 with no explicit bump. run 36602720244. (task 1002620)
|
|
2652
|
+
1.19.1080 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2653
|
+
landed since 1.19.1079 with no explicit bump. run 36638268217. (task 1002620)
|
|
2654
|
+
1.19.1081 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2655
|
+
landed since 1.19.1080 with no explicit bump. run 36639564721. (task 1002620)
|
|
2652
2656
|
---------------------------------------------------------------------------
|
|
2653
2657
|
```
|
package/docs/modules-contract.md
CHANGED
|
@@ -364,9 +364,15 @@ Run `bongos upgrade` to confirm coreVersion compatibility, then `node scripts/gd
|
|
|
364
364
|
- The download is checked with `verifyModuleArtifact`, the same checker the store runs on publish, and must match the version and hashes the store described. A failed check places nothing.
|
|
365
365
|
- A module with no price, or a zero price, is free and granted on the spot. A priced one answers 402 until buying exists (area 8). An entitlement is kept for good, even if a price is added later (ADR 0338 D2).
|
|
366
366
|
- Install never enables. Switch the module on from the hall's Modules tab.
|
|
367
|
-
- A module already on disk is refused; moving one to a newer version is `update`. A module this core cannot mount is refused before download.
|
|
367
|
+
- A module already on disk, in the instance's `modules/` or bundled with the core, is refused; moving one to a newer version is `update`. A module this core cannot mount is refused before download.
|
|
368
368
|
- A delisted module cannot be acquired or downloaded. Channels are not checked yet: every version is general until the channel tasks land.
|
|
369
369
|
|
|
370
|
+
`bongos module update <key> [--version X.Y.Z]` moves an installed store module forward through the same three calls, so a paid module and a free one take one identical path, and a module you already hold is never charged again (`tests/module_update.mjs` fails if a price-conditional branch appears).
|
|
371
|
+
|
|
372
|
+
- With no `--version` it takes the newest, and says "up to date" without downloading when that isn't newer. An older `--version` is refused: update only moves forward.
|
|
373
|
+
- The new version is written beside the old one and swapped in; any failure leaves the installed version in place. Files the new version dropped are gone afterwards.
|
|
374
|
+
- The on/off switch is untouched. A module bundled with the core is refused; it updates with the core (`bongos upgrade`).
|
|
375
|
+
|
|
370
376
|
## Carving an existing core domain (the `db.js` per-domain pattern)
|
|
371
377
|
|
|
372
378
|
Most BONGOS-V1 module work is not scaffolding a *new* module — it's **carving an existing core domain out of the monolith** (ADR 0091, criterion C7). The reference carve is the kernel slice `src/bongos/db-kernel.js` (BV1.R69); the feature precedents are `modules/discord/db.js` + `modules/game/db.js`. Every later carve (memory R72, grading R73, …) copies this recipe. The invariant is **OTB byte-identical** at every step — you are *moving* code, not changing behavior.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- provisioning_028_render_standup.sql — the Render standup leg (task 1004183; ADR 0327, ADR 0345).
|
|
2
|
+
--
|
|
3
|
+
-- WHY THIS EXISTS. A project's owner lends the platform their Render API key once, so the
|
|
4
|
+
-- runner can create their services on THEIR Render account. Nothing afterwards needs the
|
|
5
|
+
-- key (ADR 0327 §2), so it is never stored — but it must still cross from the web tier,
|
|
6
|
+
-- which receives it, to the runner, which spends it. It rides the intent that asks for the
|
|
7
|
+
-- standup, encrypted with src/bongos/secret-box.js, and the runner clears it within the
|
|
8
|
+
-- drain that claims the intent, whether the standup worked or not.
|
|
9
|
+
--
|
|
10
|
+
-- What the encryption buys, said plainly: it protects the key from a database dump or a
|
|
11
|
+
-- nightly backup taken while the intent waits. It does NOT defend against a compromised web
|
|
12
|
+
-- tier — the key arrives there in plaintext before anything can encrypt it.
|
|
13
|
+
--
|
|
14
|
+
-- WHAT IT ADDS.
|
|
15
|
+
-- provisioning_intents.borrowed_key_enc — the encrypted key, only while the intent is
|
|
16
|
+
-- open. The CHECK below is the backstop: a resolved intent CANNOT keep a key, so a
|
|
17
|
+
-- resolution path that forgets to clear it fails loudly instead of leaving it behind.
|
|
18
|
+
-- provisioning_instances.app_host — where the project's app runs and what the platform
|
|
19
|
+
-- needs to know about it afterwards: {platform, mode, ownerId, serviceId, postgresId,
|
|
20
|
+
-- serviceUrl, webPlan, dbPlan, region, state, error}. Ids, plans and a URL — never the
|
|
21
|
+
-- key and never an env var value. One jsonb column rather than a column per field so
|
|
22
|
+
-- a second platform is an added option (ADR 0345 D3), not a second schema change.
|
|
23
|
+
-- publicInstance() is an allow-list and does not carry it.
|
|
24
|
+
-- 'render-standup' in the intents' action allow-list.
|
|
25
|
+
--
|
|
26
|
+
-- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
|
|
27
|
+
|
|
28
|
+
BEGIN;
|
|
29
|
+
|
|
30
|
+
ALTER TABLE provisioning_intents ADD COLUMN IF NOT EXISTS borrowed_key_enc text;
|
|
31
|
+
|
|
32
|
+
ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_borrowed_key_open_only;
|
|
33
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_borrowed_key_open_only
|
|
34
|
+
CHECK (borrowed_key_enc IS NULL OR state IN ('pending', 'running'));
|
|
35
|
+
|
|
36
|
+
ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS app_host jsonb;
|
|
37
|
+
|
|
38
|
+
-- INTENT_ACTIONS in modules/provisioning/provisioning.js is the other spelling of this list;
|
|
39
|
+
-- tests/provisioning_restart.mjs and tests/core_upgrade_door.mjs fail if the two disagree.
|
|
40
|
+
ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
|
|
41
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
|
|
42
|
+
CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach',
|
|
43
|
+
'settings-apply', 'restart', 'disconnect', 'core-upgrade',
|
|
44
|
+
'core-upgrade-preflight', 'repo-private', 'render-standup'));
|
|
45
|
+
|
|
46
|
+
COMMIT;
|
|
@@ -59,7 +59,7 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
|
|
|
59
59
|
// 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
|
|
60
60
|
// 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
|
|
61
61
|
// deploy door. The web tier only QUEUES it; the control-plane runner performs it.
|
|
62
|
-
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight', 'repo-private'];
|
|
62
|
+
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight', 'repo-private', 'render-standup'];
|
|
63
63
|
// Every state a teardown can be asked for — i.e. everything except the one
|
|
64
64
|
// state where there is provably nothing left to release. DERIVED from
|
|
65
65
|
// INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
|
|
@@ -1168,9 +1168,9 @@ async function enqueueIntent(db, instanceId, action, requestedBy, targetVersion
|
|
|
1168
1168
|
async function enqueueTeardown(db, instanceId, requestedBy) {
|
|
1169
1169
|
const { rows: superseded } = await db.query(
|
|
1170
1170
|
`UPDATE provisioning_intents
|
|
1171
|
-
SET state = 'error', last_error = 'superseded: teardown requested',
|
|
1171
|
+
SET state = 'error', last_error = 'superseded: teardown requested', borrowed_key_enc = NULL,
|
|
1172
1172
|
updated_at = now(), resolved_at = now()
|
|
1173
|
-
WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private') AND state = 'pending'
|
|
1173
|
+
WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup') AND state = 'pending'
|
|
1174
1174
|
AND NOT EXISTS (SELECT 1 FROM provisioning_instances WHERE id = $1 AND hosting_shape = 'control-plane') RETURNING id`,
|
|
1175
1175
|
[instanceId]
|
|
1176
1176
|
);
|
|
@@ -1248,8 +1248,8 @@ async function claimNextIntent(db) {
|
|
|
1248
1248
|
async function resolveIntent(db, id, state, lastError) {
|
|
1249
1249
|
await db.query(
|
|
1250
1250
|
`UPDATE provisioning_intents
|
|
1251
|
-
SET state = $2, last_error = $3, updated_at = now(), resolved_at = now()
|
|
1252
|
-
WHERE id = $1`,
|
|
1251
|
+
SET state = $2, last_error = $3, borrowed_key_enc = NULL, updated_at = now(), resolved_at = now()
|
|
1252
|
+
WHERE id = $1`, // a resolved intent never keeps a borrowed key (task 1004183; the table's CHECK is the backstop)
|
|
1253
1253
|
[id, state, lastError ?? null]
|
|
1254
1254
|
);
|
|
1255
1255
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// modules/provisioning/render-standup.js — the queue half of "set this project up on my
|
|
2
|
+
// Render account" (task 1004183; ADR 0327, ADR 0345).
|
|
3
|
+
//
|
|
4
|
+
// The web tier never calls Render (ADR 0111 §2): it checks the SHAPE of what the owner
|
|
5
|
+
// sent, encrypts their key, and queues a `render-standup` intent carrying it. The runner
|
|
6
|
+
// leg, scripts/gds/provision-render.js, spends the key in one drain and clears it.
|
|
7
|
+
//
|
|
8
|
+
// What the encryption buys, said plainly: it protects the key from a database dump or a
|
|
9
|
+
// nightly backup while the intent waits for the runner. It does NOT defend against a
|
|
10
|
+
// compromised web tier — the key arrives here in plaintext before anything can encrypt
|
|
11
|
+
// it. Nothing here stores the key anywhere else, logs it, or sends it back.
|
|
12
|
+
//
|
|
13
|
+
// Its own file because provisioning.js sits at the size ratchet.
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
// The plan and region vocabulary — shared with the runner's payload builder, which
|
|
17
|
+
// requires it from here, so the web tier and the runner can never disagree on a name.
|
|
18
|
+
const WEB_PLANS = ['starter', 'standard', 'pro', 'pro_plus', 'pro_max', 'pro_ultra'];
|
|
19
|
+
const DB_PLANS = ['basic_256mb', 'basic_1gb', 'basic_4gb', 'pro_4gb', 'pro_8gb'];
|
|
20
|
+
const REGIONS = ['virginia', 'ohio', 'oregon', 'frankfurt', 'singapore'];
|
|
21
|
+
const MODES = ['adopt', 'greenfield'];
|
|
22
|
+
|
|
23
|
+
// Render API keys start `rnd_`; workspaces are `tea-…` (a team) or `usr-…` (a personal
|
|
24
|
+
// one); services `srv-…`. Shape checks only — whether the key works, and whether it can
|
|
25
|
+
// reach the workspace, is the runner's to find out, because only the runner calls Render.
|
|
26
|
+
const KEY_RE = /^rnd_[A-Za-z0-9]{16,128}$/;
|
|
27
|
+
const OWNER_RE = /^(tea|usr)-[a-z0-9]{8,64}$/;
|
|
28
|
+
const SERVICE_RE = /^srv-[a-z0-9]{8,64}$/;
|
|
29
|
+
|
|
30
|
+
// The reason a request cannot be queued, in the owner's words, or null. `body` is the
|
|
31
|
+
// validated request body.
|
|
32
|
+
function badRequest(body) {
|
|
33
|
+
const b = body || {};
|
|
34
|
+
if (!KEY_RE.test(String(b.key || ''))) return 'That doesn’t look like a Render API key. It starts with “rnd_” — create one under Account Settings → API Keys in Render.';
|
|
35
|
+
if (!OWNER_RE.test(String(b.owner_id || ''))) return 'Choose the Render workspace to use. Its id starts with “tea-” or “usr-”.';
|
|
36
|
+
if (!MODES.includes(b.mode)) return 'Say whether to link a Render service you already have, or create a new one.';
|
|
37
|
+
if (b.mode === 'adopt' && !SERVICE_RE.test(String(b.service_id || ''))) return 'Choose the Render service to link. Its id starts with “srv-”.';
|
|
38
|
+
if (b.mode === 'greenfield' && b.service_id) return 'A new setup creates its own service, so leave the service out.';
|
|
39
|
+
if (b.web_plan != null && !WEB_PLANS.includes(b.web_plan)) return `“${b.web_plan}” is not a Render web plan.`;
|
|
40
|
+
if (b.db_plan != null && !DB_PLANS.includes(b.db_plan)) return `“${b.db_plan}” is not a Render database plan.`;
|
|
41
|
+
if (b.region != null && !REGIONS.includes(b.region)) return `“${b.region}” is not a Render region.`;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// What the row records about the request — ids and plans, never the key.
|
|
46
|
+
function appHostFrom(body) {
|
|
47
|
+
const h = { platform: 'render', mode: body.mode, ownerId: body.owner_id, state: 'requested', error: null };
|
|
48
|
+
if (body.mode === 'adopt') h.serviceId = body.service_id;
|
|
49
|
+
if (body.web_plan) h.webPlan = body.web_plan;
|
|
50
|
+
if (body.db_plan) h.dbPlan = body.db_plan;
|
|
51
|
+
if (body.region) h.region = body.region;
|
|
52
|
+
return h;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Queue the standup with the encrypted key, and record the request on the row, in ONE
|
|
56
|
+
// statement: either both land or neither does. A re-request for the same workspace and
|
|
57
|
+
// mode keeps the ids an earlier run recorded, so the runner resumes onto what already
|
|
58
|
+
// exists; a different workspace or mode starts clean, because ids from another workspace
|
|
59
|
+
// would only trip the runner's guard.
|
|
60
|
+
//
|
|
61
|
+
// The partial unique index ux_provisioning_intents_open is the arbiter: if anything is
|
|
62
|
+
// already open for this project the insert collides and NOTHING is written — the key is
|
|
63
|
+
// not stored and the row is not touched. Returns { intent } or { conflict: 'open_intent',
|
|
64
|
+
// intent } with the blocking one.
|
|
65
|
+
async function enqueueRenderStandup(db, instanceId, { keyEnc, appHost, requestedBy }) {
|
|
66
|
+
await require('./control-plane-guard').assertIntentAllowed(db, instanceId, 'render-standup');
|
|
67
|
+
try {
|
|
68
|
+
const { rows } = await db.query(
|
|
69
|
+
`WITH i AS (
|
|
70
|
+
INSERT INTO provisioning_intents (instance_id, action, requested_by, state, borrowed_key_enc)
|
|
71
|
+
VALUES ($1, 'render-standup', $2, 'pending', $3) RETURNING *
|
|
72
|
+
), u AS (
|
|
73
|
+
UPDATE provisioning_instances
|
|
74
|
+
SET app_host = CASE WHEN app_host->>'ownerId' = $5 AND app_host->>'mode' = $6 THEN app_host ELSE '{}'::jsonb END || $4::jsonb
|
|
75
|
+
WHERE id = $1 RETURNING id
|
|
76
|
+
)
|
|
77
|
+
SELECT i.id, i.instance_id, i.action, i.state, i.created_at FROM i`,
|
|
78
|
+
[instanceId, requestedBy ?? null, keyEnc, JSON.stringify(appHost), appHost.ownerId, appHost.mode]);
|
|
79
|
+
return { intent: rows[0] };
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (e && e.code === '23505') {
|
|
82
|
+
const { rows } = await db.query(
|
|
83
|
+
`SELECT id, action, state FROM provisioning_intents
|
|
84
|
+
WHERE instance_id = $1 AND state IN ('pending', 'running') ORDER BY created_at DESC LIMIT 1`, [instanceId]);
|
|
85
|
+
return { conflict: 'open_intent', intent: rows[0] || { action: 'unknown', state: 'open' } };
|
|
86
|
+
}
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { WEB_PLANS, DB_PLANS, REGIONS, MODES, badRequest, appHostFrom, enqueueRenderStandup };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// modules/provisioning/routes/render-standup.js — POST /provisioning/instances/:id/render
|
|
2
|
+
// (task 1004183; ADR 0327, ADR 0345).
|
|
3
|
+
//
|
|
4
|
+
// The owner lends their Render API key once, names the workspace, and says whether to link
|
|
5
|
+
// a service they already run (adopt) or create a new one (greenfield). This route checks
|
|
6
|
+
// the shape, encrypts the key with the secret box, and queues a `render-standup` intent;
|
|
7
|
+
// the runner (scripts/gds/provision-render.js) spends the key in one drain and clears it.
|
|
8
|
+
// This process never calls Render (ADR 0111 §2).
|
|
9
|
+
//
|
|
10
|
+
// What the encryption buys, said plainly: protection from a database dump or a backup
|
|
11
|
+
// while the intent waits. Not from a compromised web tier — the key is in plaintext here
|
|
12
|
+
// before it is encrypted. The key is never logged, never stored anywhere else, and never
|
|
13
|
+
// sent back.
|
|
14
|
+
//
|
|
15
|
+
// Its own routes file (declared in module.json) because routes/provisioning.js sits at the
|
|
16
|
+
// size ratchet — the routes/repo-private.js precedent.
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const express = require('express');
|
|
20
|
+
const api = require('../../../src/module-api');
|
|
21
|
+
const { pool, validateOrRespond, parseId } = api;
|
|
22
|
+
const provisioning = require('../provisioning');
|
|
23
|
+
const renderStandup = require('../render-standup');
|
|
24
|
+
const { failFrom } = require('../public-refusal');
|
|
25
|
+
|
|
26
|
+
const log = api.logger('provisioning');
|
|
27
|
+
|
|
28
|
+
// What the runner leg would refuse about the PROJECT, answered here so the owner hears it
|
|
29
|
+
// before lending a key. The runner keeps its own checks.
|
|
30
|
+
function unavailableReason(inst, mode) {
|
|
31
|
+
if (inst.status === 'torn_down' || inst.status === 'tearing_down') return 'This project has been removed, so it can’t be set up on Render.';
|
|
32
|
+
if (mode === 'greenfield' && !inst.target_ref) return 'This project has no GitHub repository linked, so Render has nothing to build.';
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = function buildRenderStandupRouter() {
|
|
37
|
+
const router = express.Router();
|
|
38
|
+
|
|
39
|
+
// POST /provisioning/instances/:id/render — rank: any authenticated builder, and OWNER
|
|
40
|
+
// ONLY: the key is the owner's and so is the account it acts in, so an archon on
|
|
41
|
+
// someone else's project gets the same 404 as a stranger. The reply is a receipt;
|
|
42
|
+
// nothing has happened on Render when it is sent.
|
|
43
|
+
router.post('/provisioning/instances/:id/render', api.requireBuilder, async (req, res) => {
|
|
44
|
+
const id = parseId(req, res, { positiveInt: true });
|
|
45
|
+
if (!id) return;
|
|
46
|
+
if (validateOrRespond(req, res, {
|
|
47
|
+
key: { required: true, type: 'string', maxLength: 200 },
|
|
48
|
+
owner_id: { required: true, type: 'string', maxLength: 80 },
|
|
49
|
+
mode: { required: true, type: 'string', maxLength: 20 },
|
|
50
|
+
service_id: { type: 'string', maxLength: 80 },
|
|
51
|
+
web_plan: { type: 'string', maxLength: 40 },
|
|
52
|
+
db_plan: { type: 'string', maxLength: 40 },
|
|
53
|
+
region: { type: 'string', maxLength: 40 },
|
|
54
|
+
})) return;
|
|
55
|
+
try {
|
|
56
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
57
|
+
if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) return res.fail('instance_not_found', 404);
|
|
58
|
+
const bad = renderStandup.badRequest(req.body);
|
|
59
|
+
if (bad) return res.fail('render_request_invalid', { status: 400, message: bad });
|
|
60
|
+
const why = unavailableReason(inst, req.body.mode);
|
|
61
|
+
if (why) return res.fail('render_unavailable', { status: 409, message: why, details: { status: inst.status } });
|
|
62
|
+
if (!api.secretBox.isConfigured()) {
|
|
63
|
+
return res.fail('render_unavailable', { status: 503, message: 'The platform can’t take a key safely right now, so nothing was sent. Try again later.' });
|
|
64
|
+
}
|
|
65
|
+
const appHost = renderStandup.appHostFrom(req.body);
|
|
66
|
+
const r = await renderStandup.enqueueRenderStandup(pool, id, {
|
|
67
|
+
keyEnc: api.secretBox.encrypt(req.body.key), appHost, requestedBy: 'api:self',
|
|
68
|
+
});
|
|
69
|
+
if (r.conflict) {
|
|
70
|
+
return res.fail('intent_conflict', { status: 409,
|
|
71
|
+
message: `A '${r.intent.action}' run is ${r.intent.state} for this project, so your key was not kept. Try again once it has finished.`,
|
|
72
|
+
details: { action: r.intent.action, state: r.intent.state } });
|
|
73
|
+
}
|
|
74
|
+
await provisioning.recordEvent(pool, {
|
|
75
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'render-standup',
|
|
76
|
+
detail: `Render ${appHost.mode === 'adopt' ? 'link' : 'setup'} requested by owner (workspace ${appHost.ownerId})`, actor: 'api:self',
|
|
77
|
+
}).catch(() => {});
|
|
78
|
+
res.json({ ok: true, queued: true, action: 'render-standup', mode: appHost.mode,
|
|
79
|
+
message: appHost.mode === 'adopt'
|
|
80
|
+
? 'Queued. The platform will check the service in your Render account and link it, without changing anything there. Your key is used once and then thrown away.'
|
|
81
|
+
: 'Queued. The platform will create your database and web service in your Render account. Your key is used once and then thrown away; Render bills you directly.' });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// Never log the request: it carries the key.
|
|
84
|
+
log.error('[provisioning] POST /provisioning/instances/:id/render', err && err.message);
|
|
85
|
+
failFrom(res, err, 'render_standup_failed');
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return router;
|
|
90
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1081",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1081",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1081",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -29,6 +29,6 @@
|
|
|
29
29
|
"pino": "^9.14.0"
|
|
30
30
|
},
|
|
31
31
|
"overrides": {
|
|
32
|
-
"undici": "^6.28.
|
|
32
|
+
"undici": "^6.28.1"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/release-notes.json
CHANGED
|
@@ -7867,5 +7867,21 @@
|
|
|
7867
7867
|
"id": "1003785",
|
|
7868
7868
|
"text": "You can now install a module from the store with one command. It checks the download is exactly what was published and never switches the module on by itself. Modules without a price are free."
|
|
7869
7869
|
}
|
|
7870
|
+
],
|
|
7871
|
+
"1.19.1080": [
|
|
7872
|
+
{
|
|
7873
|
+
"id": "1003786",
|
|
7874
|
+
"text": "Installed modules can now be updated with one command. Paid and free modules update the exact same way, you are never charged twice, and a failed update leaves your working version in place."
|
|
7875
|
+
}
|
|
7876
|
+
],
|
|
7877
|
+
"1.19.1081": [
|
|
7878
|
+
{
|
|
7879
|
+
"id": "1004183",
|
|
7880
|
+
"text": "Bongos can now set a project up on its owner's own Render account. The owner lends their Render key once; it is used for that one run and then erased, and it can never act in a workspace the owner did not name. Linking an exis"
|
|
7881
|
+
},
|
|
7882
|
+
{
|
|
7883
|
+
"id": "1004364",
|
|
7884
|
+
"text": "Every module in the store will now come with a how-to guide. Publishing is refused without one, and an AI gives each guide a Docs score that buyers can see next to the module's other scores."
|
|
7885
|
+
}
|
|
7870
7886
|
]
|
|
7871
7887
|
}
|