@bongos/core 1.19.1080 → 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 +78 -28
- 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/module-api-changelog.md +2 -0
- 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 +1 -1
- package/release-notes.json +10 -0
- 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/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). |
|
|
@@ -2651,5 +2651,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2651
2651
|
landed since 1.19.1078 with no explicit bump. run 36602720244. (task 1002620)
|
|
2652
2652
|
1.19.1080 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2653
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)
|
|
2654
2656
|
---------------------------------------------------------------------------
|
|
2655
2657
|
```
|
|
@@ -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",
|
package/release-notes.json
CHANGED
|
@@ -7873,5 +7873,15 @@
|
|
|
7873
7873
|
"id": "1003786",
|
|
7874
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
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
|
+
}
|
|
7876
7886
|
]
|
|
7877
7887
|
}
|