@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.
Files changed (35) hide show
  1. package/.bongos-core.json +86 -31
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +2 -0
  8. package/docs/adr/0347-every-store-module-ships-a-how-to.md +135 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +126 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/architecture.md +1 -1
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/modules-contract.md +7 -1
  15. package/modules/provisioning/migrations/provisioning_028_render_standup.sql +46 -0
  16. package/modules/provisioning/module.json +2 -1
  17. package/modules/provisioning/provisioning.js +5 -5
  18. package/modules/provisioning/render-standup.js +91 -0
  19. package/modules/provisioning/routes/render-standup.js +90 -0
  20. package/package-lock.json +2 -2
  21. package/package.json +2 -2
  22. package/release-notes.json +16 -0
  23. package/scripts/gds/module.js +176 -46
  24. package/scripts/gds/provision-render.js +158 -0
  25. package/scripts/gds/provision-teardown.js +2 -0
  26. package/scripts/gds/provision.js +2 -2
  27. package/scripts/gds/render-api.js +178 -0
  28. package/scripts/gds/render-payload.js +110 -0
  29. package/src/module-api.js +1 -1
  30. package/tests/module_update.mjs +232 -0
  31. package/tests/provision_render.mjs +269 -0
  32. package/tests/provisioning_render_route.mjs +216 -0
  33. package/tests/provisioning_settings_apply.mjs +1 -1
  34. package/tests/provisioning_teardown_intent.mjs +2 -2
  35. package/tests/render_api.mjs +153 -0
@@ -0,0 +1,216 @@
1
+ // tests/provisioning_render_route.mjs — POST /provisioning/instances/:id/render and its
2
+ // queue seam (task 1004183; ADR 0327).
3
+ //
4
+ // The load-bearing assertions:
5
+ // • OWNER ONLY: the key and the Render account are the owner's, so an archon on someone
6
+ // else's project gets the same 404 as a stranger, and nothing is encrypted or queued
7
+ // • only CIPHERTEXT reaches the queue — the plaintext key is never passed on, never in
8
+ // the reply, never in the audit event
9
+ // • the shape is checked before anything is kept; the web tier makes no Render call
10
+ // • no secret box → 503, and the key is not kept
11
+ // • a conflicting open intent → 409, and the key is not kept
12
+ // • the queue seam writes the intent AND the row in ONE statement, so a collision on
13
+ // the open-intent index stores nothing at all
14
+ //
15
+ // DB-free: data access is stubbed on the cached module objects, over a real mounted
16
+ // router. No network beyond localhost. Run: node tests/provisioning_render_route.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+ import { readFileSync } from 'node:fs';
21
+ import http from 'node:http';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const provisioning = require('../modules/provisioning/provisioning.js');
25
+ const renderStandup = require('../modules/provisioning/render-standup.js');
26
+ const secretBox = require('../src/bongos/secret-box.js');
27
+ const guard = require('../modules/provisioning/control-plane-guard.js');
28
+
29
+ let passed = 0, failed = 0;
30
+ async function ta(name, fn) {
31
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
32
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
33
+ }
34
+
35
+ const KEY = 'rnd_abcdefghijklmnopqrstuvwx';
36
+ let instance = null, enqueueCalls = [], enqueueResult = null, events = [], boxOn = true;
37
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
38
+ provisioning.recordEvent = async (_db, e) => { events.push(e); };
39
+ const realEnqueue = renderStandup.enqueueRenderStandup;
40
+ renderStandup.enqueueRenderStandup = async (_db, id, args) => {
41
+ enqueueCalls.push({ id, ...args });
42
+ return enqueueResult || { intent: { id: 9, action: 'render-standup', state: 'pending' } };
43
+ };
44
+ secretBox.isConfigured = () => boxOn;
45
+ secretBox.encrypt = (plain) => `v1.sealed(${plain.length})`;
46
+
47
+ const api = require('../src/module-api.js');
48
+ let builder = null;
49
+ api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
50
+ api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
51
+
52
+ const express = require('express');
53
+ const buildRouter = require('../modules/provisioning/routes/render-standup.js');
54
+ const app = express();
55
+ app.use(express.json());
56
+ app.use((_req, res, next) => {
57
+ res.fail = (code, opts, details) => {
58
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
59
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(Array.isArray(details) ? { details } : {}) } });
60
+ };
61
+ next();
62
+ });
63
+ app.use('/api/bongos', buildRouter());
64
+ const server = app.listen(0);
65
+ await new Promise((r) => server.once('listening', r));
66
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
67
+
68
+ function post(path, body) {
69
+ return new Promise((resolve, reject) => {
70
+ const payload = JSON.stringify(body);
71
+ const req = http.request(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, (res) => {
72
+ let d = '';
73
+ res.on('data', (c) => { d += c; });
74
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
75
+ });
76
+ req.on('error', reject);
77
+ req.end(payload);
78
+ });
79
+ }
80
+
81
+ const LIVE = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', target_ref: 'example-owner/spike' };
82
+ function reset(patch = {}) {
83
+ instance = { ...LIVE, ...patch };
84
+ enqueueCalls = []; enqueueResult = null; events = []; boxOn = true;
85
+ builder = { id: 7, github_login: 'owner', rank: 'thetes' };
86
+ }
87
+ const PATH = '/provisioning/instances/18/render';
88
+ const GREEN = { key: KEY, owner_id: 'tea-abcdefgh1234', mode: 'greenfield' };
89
+ const noKeyAnywhere = (...xs) => xs.forEach((x) => assert.ok(!JSON.stringify(x).includes(KEY), 'the plaintext key leaked: ' + JSON.stringify(x)));
90
+
91
+ console.log('who may ask:');
92
+ await ta('the owner queues it: ciphertext only, the request recorded on the row, one audit event', async () => {
93
+ reset();
94
+ const r = await post(PATH, { ...GREEN, web_plan: 'standard' });
95
+ assert.equal(r.status, 200, r.raw);
96
+ assert.equal(enqueueCalls.length, 1);
97
+ assert.equal(enqueueCalls[0].keyEnc, `v1.sealed(${KEY.length})`);
98
+ assert.deepEqual(enqueueCalls[0].appHost, { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234', state: 'requested', error: null, webPlan: 'standard' });
99
+ assert.equal(events.length, 1);
100
+ noKeyAnywhere(enqueueCalls, events, r.body);
101
+ });
102
+ await ta('an ARCHON on someone else\'s project gets 404 — nothing encrypted, queued or recorded', async () => {
103
+ reset();
104
+ builder = { id: 42, github_login: 'arch', rank: 'archon' };
105
+ let sealed = 0; const enc = secretBox.encrypt; secretBox.encrypt = (p) => { sealed++; return enc(p); };
106
+ const r = await post(PATH, GREEN);
107
+ secretBox.encrypt = enc;
108
+ assert.equal(r.status, 404, r.raw);
109
+ assert.equal(sealed, 0);
110
+ assert.equal(enqueueCalls.length, 0);
111
+ assert.equal(events.length, 0);
112
+ });
113
+ await ta('a missing project is 404', async () => {
114
+ reset(); instance = null;
115
+ assert.equal((await post(PATH, GREEN)).status, 404);
116
+ });
117
+
118
+ console.log('\nthe shape is checked first:');
119
+ for (const [name, body, re] of [
120
+ ['a key that is not a Render key', { ...GREEN, key: 'ghp_notrender' }, /Render API key/],
121
+ ['a workspace id of the wrong shape', { ...GREEN, owner_id: 'srv-abcdefgh1234' }, /workspace/],
122
+ ['an unknown mode', { ...GREEN, mode: 'migrate' }, /link a Render service/],
123
+ ['adopt with no service', { ...GREEN, mode: 'adopt' }, /service to link/],
124
+ ['greenfield naming a service', { ...GREEN, service_id: 'srv-abcdefgh1234' }, /leave the service out/],
125
+ ['an unknown plan', { ...GREEN, db_plan: 'free' }, /not a Render database plan/],
126
+ ]) {
127
+ await ta(`${name} → 400, nothing kept`, async () => {
128
+ reset();
129
+ const r = await post(PATH, body);
130
+ assert.equal(r.status, 400, r.raw);
131
+ assert.match(r.body.error.message, re);
132
+ assert.equal(enqueueCalls.length, 0);
133
+ noKeyAnywhere(r.body);
134
+ });
135
+ }
136
+ await ta('an unknown field is refused by the strict validator', async () => {
137
+ reset();
138
+ const r = await post(PATH, { ...GREEN, value: 'x' });
139
+ assert.equal(r.status, 400, r.raw);
140
+ assert.equal(enqueueCalls.length, 0);
141
+ });
142
+ await ta('adopt with a service id is accepted and records it', async () => {
143
+ reset();
144
+ const r = await post(PATH, { ...GREEN, mode: 'adopt', service_id: 'srv-abcdefgh1234' });
145
+ assert.equal(r.status, 200, r.raw);
146
+ assert.equal(enqueueCalls[0].appHost.serviceId, 'srv-abcdefgh1234');
147
+ });
148
+
149
+ console.log('\nrefusals keep nothing:');
150
+ await ta('greenfield with no repository → 409', async () => {
151
+ reset({ target_ref: null });
152
+ const r = await post(PATH, GREEN);
153
+ assert.equal(r.status, 409, r.raw);
154
+ assert.equal(enqueueCalls.length, 0);
155
+ });
156
+ await ta('a removed project → 409', async () => {
157
+ reset({ status: 'torn_down' });
158
+ assert.equal((await post(PATH, GREEN)).status, 409);
159
+ assert.equal(enqueueCalls.length, 0);
160
+ });
161
+ await ta('no secret box → 503, and the key is not kept', async () => {
162
+ reset(); boxOn = false;
163
+ const r = await post(PATH, GREEN);
164
+ assert.equal(r.status, 503, r.raw);
165
+ assert.equal(enqueueCalls.length, 0);
166
+ });
167
+ await ta('something already open → 409 saying the key was not kept', async () => {
168
+ reset(); enqueueResult = { conflict: 'open_intent', intent: { action: 'provision', state: 'running' } };
169
+ const r = await post(PATH, GREEN);
170
+ assert.equal(r.status, 409, r.raw);
171
+ assert.match(r.body.error.message, /not kept/);
172
+ assert.equal(events.length, 0);
173
+ });
174
+
175
+ console.log('\nthe queue seam:');
176
+ const realGuard = guard.assertIntentAllowed;
177
+ guard.assertIntentAllowed = async () => {};
178
+ await ta('one statement writes the intent with the ciphertext and merges the row', async () => {
179
+ const sqls = [];
180
+ const db = { query: async (sql, params) => { sqls.push({ sql, params }); return { rows: [{ id: 5, action: 'render-standup', state: 'pending' }] }; } };
181
+ const out = await realEnqueue(db, 18, { keyEnc: 'v1.x', appHost: { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234' }, requestedBy: 'api:self' });
182
+ assert.equal(out.intent.id, 5);
183
+ assert.equal(sqls.length, 1, 'a single statement — both land or neither does');
184
+ assert.match(sqls[0].sql, /INSERT INTO provisioning_intents[\s\S]*borrowed_key_enc[\s\S]*UPDATE provisioning_instances[\s\S]*app_host/);
185
+ assert.equal(sqls[0].params[2], 'v1.x');
186
+ assert.doesNotMatch(sqls[0].sql, /RETURNING \*[\s\S]*SELECT i\.\*/, 'the ciphertext is not read back');
187
+ });
188
+ await ta('a collision on the open-intent index stores nothing and names the blocker', async () => {
189
+ let n = 0;
190
+ const db = { query: async () => { if (n++ === 0) throw Object.assign(new Error('dup'), { code: '23505' }); return { rows: [{ id: 3, action: 'teardown', state: 'pending' }] }; } };
191
+ const out = await realEnqueue(db, 18, { keyEnc: 'v1.x', appHost: { mode: 'greenfield', ownerId: 'tea-abcdefgh1234' } });
192
+ assert.equal(out.conflict, 'open_intent');
193
+ assert.equal(out.intent.action, 'teardown');
194
+ });
195
+ guard.assertIntentAllowed = realGuard;
196
+
197
+ console.log('\nwiring:');
198
+ await ta('module.json mounts the route; the action is in the vocabulary and the newest CHECK', () => {
199
+ const manifest = JSON.parse(readFileSync(new URL('../modules/provisioning/module.json', import.meta.url), 'utf8'));
200
+ assert.ok(manifest.contributes.routes.includes('render-standup'));
201
+ assert.ok(provisioning.INTENT_ACTIONS.includes('render-standup'));
202
+ const mig = readFileSync(new URL('../modules/provisioning/migrations/provisioning_028_render_standup.sql', import.meta.url), 'utf8');
203
+ assert.match(mig, /borrowed_key_enc IS NULL OR state IN \('pending', 'running'\)/, 'a resolved intent cannot keep a key');
204
+ });
205
+ await ta('every path that resolves an intent clears the key', () => {
206
+ const src = readFileSync(new URL('../modules/provisioning/provisioning.js', import.meta.url), 'utf8');
207
+ const resolve = src.slice(src.indexOf('async function resolveIntent'), src.indexOf('async function resolveIntent') + 400);
208
+ assert.match(resolve, /borrowed_key_enc = NULL/);
209
+ const teardown = src.slice(src.indexOf('async function enqueueTeardown'), src.indexOf('async function enqueueTeardown') + 600);
210
+ assert.match(teardown, /borrowed_key_enc = NULL/, 'a superseded standup drops its key');
211
+ assert.match(teardown, /'render-standup'\) AND state = 'pending'/);
212
+ });
213
+
214
+ server.close();
215
+ console.log(`\nprovisioning_render_route: ${passed} passed, ${failed} failed`);
216
+ process.exit(failed ? 1 : 0);
@@ -169,7 +169,7 @@ await ta('enqueueTeardown supersedes a PENDING settings push (a push into a proj
169
169
  const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
170
170
  assert.equal(r.created, true);
171
171
  assert.equal(r.superseded, true, 'the queued push was cancelled so the teardown could take the slot');
172
- assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
172
+ assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
173
173
  });
174
174
 
175
175
  // ── the route, over a real mounted router (stubbed data access) ─────────────────
@@ -31,7 +31,7 @@ const require = createRequire(import.meta.url);
31
31
  const provisioning = require('../modules/provisioning/provisioning.js');
32
32
 
33
33
  // The actions a teardown supersedes, mirroring enqueueTeardown's SQL.
34
- const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private']);
34
+ const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private', 'render-standup']);
35
35
 
36
36
  let passed = 0, failed = 0;
37
37
  function t(name, fn) {
@@ -81,7 +81,7 @@ function fakeIntentDb(seed = [], { shape = 'co-tenant' } = {}) {
81
81
  // dead work in front of a teardown (task 1003140 widened it to the settings
82
82
  // push, task 1003238 to the restart; an open teardown is left alone for the
83
83
  // idempotent path).
84
- assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/);
84
+ assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup'\) AND state = 'pending'/);
85
85
  updates++;
86
86
  // The supersede must skip the platform's own row IN ITS OWN SQL (task 1004286): the
87
87
  // guard refuses that teardown only afterwards, in enqueueIntent. Modelled, not assumed.
@@ -0,0 +1,153 @@
1
+ // tests/render_api.mjs — the Render client's workspace guard (task 1004183; ADR 0327).
2
+ //
3
+ // The load-bearing assertions:
4
+ // • a client cannot exist without the workspace it acts in — never defaulted
5
+ // • a response from ANOTHER workspace is refused (RenderOwnerMismatch), and so is a
6
+ // response carrying no workspace — in both of Render's shapes (service `ownerId`,
7
+ // Postgres `owner.id`) and on every element that carries one
8
+ // • the calls whose answer carries no owner (connection-info, env-var PUT, deploy) are
9
+ // only sent after the parent resource was read through the guard — so a service in
10
+ // another workspace gets NO write
11
+ // • there is no delete, and the verb is refused outright
12
+ // • a dry-run sends nothing and never prints an env var value
13
+ //
14
+ // Hermetic: a recording fake fetch. Run: node tests/render_api.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const { RenderApi, RenderApiError, RenderOwnerMismatch, ownerOf } = require('../scripts/gds/render-api.js');
21
+
22
+ let passed = 0, failed = 0;
23
+ async function ta(name, fn) {
24
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
25
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
26
+ }
27
+
28
+ const OURS = 'tea-ours00000001';
29
+ const THEIRS = 'tea-theirs000001';
30
+
31
+ // A fake Render: `routes` maps "METHOD path" to the JSON it answers.
32
+ function fakeFetch(routes) {
33
+ const calls = [];
34
+ const fetchImpl = async (url, init) => {
35
+ const path = url.replace('https://api.render.com/v1', '');
36
+ calls.push({ method: init.method, path, body: init.body ? JSON.parse(init.body) : null, auth: init.headers.Authorization });
37
+ const key = `${init.method} ${path}`;
38
+ if (!(key in routes)) return { ok: false, status: 404, text: async () => JSON.stringify({ message: 'not found' }) };
39
+ return { ok: true, status: 200, text: async () => JSON.stringify(routes[key]) };
40
+ };
41
+ return { fetchImpl, calls };
42
+ }
43
+ const live = (routes, ownerId = OURS) => {
44
+ const f = fakeFetch(routes);
45
+ return { api: new RenderApi({ token: 'rnd_test', ownerId, dryRun: false, fetchImpl: f.fetchImpl }), calls: f.calls };
46
+ };
47
+
48
+ console.log('the workspace is required:');
49
+ await ta('no ownerId → the client refuses to exist, even in dry-run', () => {
50
+ assert.throws(() => new RenderApi({ token: 'rnd_x', dryRun: false, fetchImpl: () => {} }), RenderApiError);
51
+ assert.throws(() => new RenderApi({}), /never defaulted/);
52
+ assert.throws(() => new RenderApi({ ownerId: ' ' }), RenderApiError);
53
+ });
54
+ await ta('live with no key → refuses', () => {
55
+ assert.throws(() => new RenderApi({ ownerId: OURS, dryRun: false, fetchImpl: () => {} }), /no API key/);
56
+ });
57
+
58
+ console.log('\nthe guard is on the RESPONSE:');
59
+ await ta('a service from another workspace is refused', async () => {
60
+ const { api } = live({ 'GET /services/srv-1': { id: 'srv-1', ownerId: THEIRS } });
61
+ await assert.rejects(api.getService('srv-1'), RenderOwnerMismatch);
62
+ });
63
+ await ta('a service carrying no workspace is refused', async () => {
64
+ const { api } = live({ 'GET /services/srv-1': { id: 'srv-1' } });
65
+ await assert.rejects(api.getService('srv-1'), /no workspace/);
66
+ });
67
+ await ta('a service from our workspace passes, in either envelope', async () => {
68
+ const { api } = live({ 'GET /services/srv-1': { id: 'srv-1', ownerId: OURS }, 'GET /services/srv-2': { service: { id: 'srv-2', ownerId: OURS } } });
69
+ assert.equal((await api.getService('srv-1')).id, 'srv-1');
70
+ assert.equal((await api.getService('srv-2')).id, 'srv-2');
71
+ });
72
+ await ta('a Postgres carries its owner as owner.id — a mismatch there is refused too', async () => {
73
+ const { api } = live({ 'GET /postgres/dpg-1': { id: 'dpg-1', owner: { id: THEIRS } } });
74
+ await assert.rejects(api.getPostgres('dpg-1'), RenderOwnerMismatch);
75
+ assert.equal(ownerOf({ owner: { id: OURS } }), OURS);
76
+ assert.equal(ownerOf({ ownerId: OURS }), OURS);
77
+ assert.equal(ownerOf({}), null);
78
+ });
79
+ await ta('a create answered from another workspace is refused', async () => {
80
+ const { api } = live({
81
+ 'POST /services': { deployId: 'dep-1', service: { id: 'srv-9', ownerId: THEIRS } },
82
+ 'POST /postgres': { id: 'dpg-9', owner: { id: THEIRS } },
83
+ });
84
+ await assert.rejects(api.createService({ ownerId: OURS, name: 'x' }), RenderOwnerMismatch);
85
+ await assert.rejects(api.createPostgres({ ownerId: OURS, name: 'x' }), RenderOwnerMismatch);
86
+ });
87
+ await ta('a payload naming another workspace is never sent', async () => {
88
+ const { api, calls } = live({});
89
+ await assert.rejects(api.createService({ ownerId: THEIRS, name: 'x' }), /this client's workspace/);
90
+ await assert.rejects(api.createPostgres({ name: 'x' }), /this client's workspace/);
91
+ assert.equal(calls.length, 0);
92
+ });
93
+
94
+ console.log('\nownerless answers are reached only through a checked parent:');
95
+ await ta('an env var for a service in another workspace is never PUT', async () => {
96
+ const { api, calls } = live({ 'GET /services/srv-1': { id: 'srv-1', ownerId: THEIRS }, 'PUT /services/srv-1/env-vars/K': { key: 'K' } });
97
+ await assert.rejects(api.putEnvVar('srv-1', 'K', 'v'), RenderOwnerMismatch);
98
+ assert.deepEqual(calls.map((c) => c.method), ['GET'], 'the PUT is never sent');
99
+ });
100
+ await ta('a deploy for a service in another workspace is never triggered', async () => {
101
+ const { api, calls } = live({ 'GET /services/srv-1': { id: 'srv-1', ownerId: THEIRS } });
102
+ await assert.rejects(api.triggerDeploy('srv-1'), RenderOwnerMismatch);
103
+ assert.deepEqual(calls.map((c) => c.method), ['GET']);
104
+ });
105
+ await ta('connection-info for a database in another workspace is never read', async () => {
106
+ const { api, calls } = live({ 'GET /postgres/dpg-1': { id: 'dpg-1', owner: { id: THEIRS } }, 'GET /postgres/dpg-1/connection-info': { internalConnectionString: 'postgres://leak' } });
107
+ await assert.rejects(api.postgresConnectionInfo('dpg-1'), RenderOwnerMismatch);
108
+ assert.equal(calls.length, 1);
109
+ });
110
+ await ta('on our own resources they go through, with the key as a bearer', async () => {
111
+ const { api, calls } = live({
112
+ 'GET /services/srv-1': { id: 'srv-1', ownerId: OURS },
113
+ 'PUT /services/srv-1/env-vars/K': { key: 'K' },
114
+ 'POST /services/srv-1/deploys': { id: 'dep-2' },
115
+ });
116
+ await api.putEnvVar('srv-1', 'K', 'v');
117
+ await api.triggerDeploy('srv-1');
118
+ assert.deepEqual(calls.map((c) => `${c.method} ${c.path}`), ['GET /services/srv-1', 'PUT /services/srv-1/env-vars/K', 'GET /services/srv-1', 'POST /services/srv-1/deploys']);
119
+ assert.deepEqual(calls[1].body, { value: 'v' });
120
+ assert.ok(calls.every((c) => c.auth === 'Bearer rnd_test'));
121
+ });
122
+
123
+ console.log('\nthe workspace list is for membership, never selection:');
124
+ await ta('listOwners returns every workspace the key reaches, unwrapped', async () => {
125
+ const { api } = live({ 'GET /owners?limit=100': [{ owner: { id: THEIRS, type: 'team' }, cursor: 'a' }, { owner: { id: OURS, type: 'team' }, cursor: 'b' }] });
126
+ assert.deepEqual((await api.listOwners()).map((o) => o.id), [THEIRS, OURS]);
127
+ });
128
+
129
+ console.log('\nno delete:');
130
+ await ta('there is no delete method, and the verb is refused', async () => {
131
+ const { api, calls } = live({});
132
+ const methods = Object.getOwnPropertyNames(RenderApi.prototype);
133
+ assert.ok(!methods.some((m) => /delete|destroy|remove|suspend/i.test(m)), `no destructive method: ${methods.join(', ')}`);
134
+ await assert.rejects(api._req('DELETE', '/services/srv-1'), /never deletes/);
135
+ assert.equal(calls.length, 0);
136
+ });
137
+
138
+ console.log('\ndry-run:');
139
+ await ta('dry-run is the default, sends nothing, and prints no env var value', async () => {
140
+ const lines = [];
141
+ let sent = 0;
142
+ const api = new RenderApi({ ownerId: OURS, fetchImpl: () => { sent++; }, log: (l) => lines.push(l) });
143
+ const r = await api.createService({ ownerId: OURS, name: 'demo', envVars: [{ key: 'DATABASE_URL', value: 'postgres://secret' }] });
144
+ assert.equal(sent, 0);
145
+ assert.equal(r.service.ownerId, OURS);
146
+ await api.putEnvVar('srv-1', 'BUILDER_SECRET_KEY', 'deadbeef');
147
+ const out = lines.join('\n');
148
+ assert.ok(!out.includes('postgres://secret') && !out.includes('deadbeef'), out);
149
+ assert.match(out, /DATABASE_URL/, 'the name is still shown');
150
+ });
151
+
152
+ console.log(`\n${passed} passed, ${failed} failed`);
153
+ if (failed) process.exit(1);