@bongos/core 1.25.15 → 1.25.17

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 (66) hide show
  1. package/.bongos-core.json +128 -58
  2. package/.claude/skills/goal-create/SKILL.md +1 -0
  3. package/.claude/skills/planning-session/SKILL.md +5 -4
  4. package/bin/bongos.js +1 -0
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +14 -0
  7. package/clients/bongos-client/index.cjs +14 -0
  8. package/clients/bongos-client/index.d.ts +22 -2
  9. package/clients/bongos-client/index.mjs +14 -0
  10. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
  11. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
  12. package/docs/api/openapi.json +415 -3
  13. package/docs/api-reference.md +13 -2
  14. package/docs/copy-inventory.md +120 -109
  15. package/docs/copy-registry.json +275 -165
  16. package/docs/module-api-changelog.md +4 -2
  17. package/docs/page-inventory.json +2 -0
  18. package/docs/page-readings.json +107 -107
  19. package/docs/project-context.template.md +1 -0
  20. package/modules/government/decision-rules.js +1 -0
  21. package/modules/hall-ui/public/dom-utils.js +5 -0
  22. package/modules/hall-ui/public/genesis-home.js +23 -8
  23. package/modules/hall-ui/public/setup-gate.js +1 -1
  24. package/modules/hall-ui/public/style.css +21 -1
  25. package/modules/lifecycle/first-read-db.js +152 -0
  26. package/modules/lifecycle/first-read.js +265 -0
  27. package/modules/lifecycle/kickoff-checklist.js +29 -6
  28. package/modules/lifecycle/kickoff-seed.js +27 -4
  29. package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
  30. package/modules/lifecycle/module.json +2 -1
  31. package/modules/lifecycle/routes/first-read.js +79 -0
  32. package/modules/onboarding/genesis-home.js +11 -2
  33. package/modules/onboarding/genesis-work.js +28 -5
  34. package/modules/provisioning/pollers/app-liveness.js +23 -1
  35. package/modules/provisioning/render-sandbox.js +113 -0
  36. package/modules/provisioning/render-standup.js +3 -1
  37. package/modules/provisioning/routes/provisioning.js +4 -1
  38. package/modules/provisioning/routes/render-standup.js +49 -1
  39. package/modules/public-landing/public/assets/guide-scene.css +245 -0
  40. package/modules/public-landing/public/assets/guide-scene.js +566 -0
  41. package/modules/public-landing/public/index.html +62 -2
  42. package/modules/public-landing/public/projects.html +101 -4
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +23 -1
  46. package/scripts/bongos/first-read.js +116 -0
  47. package/scripts/bongos/genesis.js +2 -0
  48. package/scripts/bongos/government-walk.js +4 -4
  49. package/scripts/bongos/provision-render-sandbox.js +171 -0
  50. package/scripts/bongos/provision-render.js +37 -2
  51. package/scripts/bongos/provision-teardown.js +1 -1
  52. package/scripts/bongos/render-api.js +33 -3
  53. package/scripts/bongos/render-payload.js +39 -1
  54. package/scripts/gds/first-read.js +7 -0
  55. package/scripts/gds/provision-render-sandbox.js +6 -0
  56. package/src/module-api.js +1 -1
  57. package/tests/first_read.mjs +303 -0
  58. package/tests/genesis_home.mjs +70 -2
  59. package/tests/genesis_work.mjs +49 -0
  60. package/tests/government_act_alone_buildings.mjs +2 -0
  61. package/tests/landing_guide_scene.mjs +504 -0
  62. package/tests/provisioning_app_status.mjs +3 -2
  63. package/tests/provisioning_render_route.mjs +1 -1
  64. package/tests/provisioning_teardown_intent.mjs +50 -0
  65. package/tests/render_api.mjs +5 -2
  66. package/tests/render_sandbox.mjs +447 -0
@@ -0,0 +1,504 @@
1
+ // tests/landing_guide_scene.mjs — the start-a-project guide, game version, on the landing
2
+ // (task 1005275, goal 1000106).
3
+ //
4
+ // The client of modules/front-door-guide (tasks 1005272 chat, 1005273 plan). What this file
5
+ // holds:
6
+ // * the client states, through the pure state machine: open (starting → chat), sending,
7
+ // cap reached (→ planning → ended with the plan), the fallback (form, a lost chat, a
8
+ // failed start), the retry errors that keep the visitor's words, and a resumed chat;
9
+ // * the plan read from the server's Plan shape, and read as optional;
10
+ // * the server's client rules: the honeypot sent as it stands, the first message held
11
+ // MIN_FIRST_MS after the start, the chat's id + token kept in LOCAL storage and resumed
12
+ // with GET …/conversations/:id, the plan asked for with POST …/plan once the chat ends;
13
+ // * boot() RUN under a small DOM stub: module off → no read and no door taken; status
14
+ // "form" → the door stays a plain link; status "chat" → the door opens the scene, the
15
+ // first send waits, the cap asks for the plan, "Sign up and get started"; a held chat
16
+ // is resumed on load, never started again, and a 404 forgets it;
17
+ // * the markup and the sheet: the doors keep their href, the scene is hidden, no
18
+ // innerHTML, every colour a var()/color-mix() of the fifteen, no :root, no !important
19
+ // (so world.css's reduced-motion switch reaches the waiting dots).
20
+
21
+ import { test } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { createRequire } from 'node:module';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
31
+ const HTML = fs.readFileSync(path.join(LANDING, 'index.html'), 'utf8').replace(/\r\n/g, '\n');
32
+ const CSS = fs.readFileSync(path.join(LANDING, 'assets', 'guide-scene.css'), 'utf8').replace(/\r\n/g, '\n');
33
+ const JS_SRC = fs.readFileSync(path.join(LANDING, 'assets', 'guide-scene.js'), 'utf8');
34
+ const G = require(path.join(LANDING, 'assets', 'guide-scene.js'));
35
+
36
+ const chatStart = (over = {}) => ({
37
+ type: 'started', status: 201, now: 1000,
38
+ body: { ok: true, mode: 'chat', greeting: 'Hi! What would you like to build?', conversation: { id: '7', token: 'tok', messages_used: 0, messages_left: 3, max_message_chars: 1000 }, ...over },
39
+ });
40
+ const open = () => G.reduce(G.reduce(G.initial(), { type: 'start' }), chatStart());
41
+
42
+ // ── the module switch ──────────────────────────────────────────────────────────────
43
+ test('the guide is on only when the injected module set says front-door-guide is true', () => {
44
+ assert.equal(G.guideOn({ enabled: { 'front-door-guide': true } }), true);
45
+ assert.equal(G.guideOn({ enabled: { 'front-door-guide': false } }), false);
46
+ assert.equal(G.guideOn({ enabled: {} }), false);
47
+ assert.equal(G.guideOn(undefined), false);
48
+ });
49
+
50
+ // ── open ───────────────────────────────────────────────────────────────────────────
51
+ test('open: starting, then chat with the greeting, the conversation and the count', () => {
52
+ const s0 = G.reduce(G.initial(), { type: 'start' });
53
+ assert.equal(s0.phase, 'starting');
54
+ const s = G.reduce(s0, chatStart());
55
+ assert.equal(s.phase, 'chat');
56
+ assert.deepEqual(s.conv, { id: '7', token: 'tok' });
57
+ assert.equal(s.left, 3);
58
+ assert.equal(s.total, 3);
59
+ assert.deepEqual(s.messages, [{ role: 'guide', text: 'Hi! What would you like to build?' }]);
60
+ assert.equal(s.plan, null, 'no plan field yet: none shown');
61
+ });
62
+
63
+ // ── sending ────────────────────────────────────────────────────────────────────────
64
+ test('sending: the visitor line shows at once, the reply lands and the count drops', () => {
65
+ const s1 = G.reduce(open(), { type: 'send', text: 'A booking site' });
66
+ assert.equal(s1.phase, 'sending');
67
+ assert.deepEqual(s1.messages.at(-1), { role: 'visitor', text: 'A booking site' });
68
+ const s2 = G.reduce(s1, { type: 'answer', status: 200, body: { ok: true, mode: 'chat', reply: { text: 'Who books?' }, conversation: { messages_used: 1, messages_left: 2 } } });
69
+ assert.equal(s2.phase, 'chat');
70
+ assert.equal(s2.left, 2);
71
+ assert.equal(s2.total, 3);
72
+ assert.deepEqual(s2.messages.at(-1), { role: 'guide', text: 'Who books?' });
73
+ });
74
+
75
+ test('a refused reply (declined: true) is shown like any reply', () => {
76
+ const s = G.reduce(G.reduce(open(), { type: 'send', text: 'x' }), { type: 'answer', status: 200, body: { ok: true, mode: 'chat', declined: true, reply: { text: 'I can only help plan a project.' }, conversation: { messages_used: 1, messages_left: 2 } } });
77
+ assert.equal(s.phase, 'chat');
78
+ assert.equal(s.messages.at(-1).text, 'I can only help plan a project.');
79
+ });
80
+
81
+ // ── cap reached ────────────────────────────────────────────────────────────────────
82
+ test('cap reached: the last reply, the server\'s line, no messages left, then the plan is asked for', () => {
83
+ const s = G.reduce(G.reduce(open(), { type: 'send', text: 'last one' }), { type: 'answer', status: 200, body: {
84
+ ok: true, mode: 'ended', reason: 'message_cap', message: 'That is all the messages this chat has.', next_step: 'plan',
85
+ reply: { text: 'Here is a first plan.' }, conversation: { messages_used: 3, messages_left: 0 },
86
+ } });
87
+ assert.equal(s.phase, 'planning');
88
+ assert.equal(s.left, 0);
89
+ assert.deepEqual(s.messages.slice(-2), [{ role: 'guide', text: 'Here is a first plan.' }, { role: 'note', text: 'That is all the messages this chat has.' }]);
90
+ assert.equal(G.COPY.left(0, 3), 'No messages left');
91
+ });
92
+
93
+ test('a chat answer that leaves nothing is the end too, and the visitor budget ends it without a reply', () => {
94
+ const a = G.reduce(G.reduce(open(), { type: 'send', text: 'x' }), { type: 'answer', status: 200, body: { ok: true, mode: 'chat', reply: { text: 'ok' }, conversation: { messages_used: 3, messages_left: 0 } } });
95
+ assert.equal(a.phase, 'planning');
96
+ const b = G.reduce(G.reduce(open(), { type: 'send', text: 'x' }), { type: 'answer', status: 200, body: { ok: true, mode: 'ended', reason: 'visitor_budget', next_step: 'plan', message: 'That is as far as this chat can go.' } });
97
+ assert.equal(b.phase, 'planning');
98
+ assert.equal(b.messages.at(-1).role, 'note');
99
+ });
100
+
101
+ // ── fallback ───────────────────────────────────────────────────────────────────────
102
+ test('fallback: a "form" start, a "form" answer, a lost chat and a failed start are the plain door, never an error', () => {
103
+ const formStart = G.reduce(G.initial(), { type: 'started', status: 200, body: { ok: true, mode: 'form', reason: 'daily_budget', message: 'Use the short form.' } });
104
+ assert.equal(formStart.phase, 'fallback');
105
+ assert.equal(formStart.fallback, 'Use the short form.');
106
+ assert.equal(formStart.error, '');
107
+
108
+ const formMid = G.reduce(G.reduce(open(), { type: 'send', text: 'x' }), { type: 'answer', status: 200, body: { ok: true, mode: 'form', reason: 'monthly_budget', message: 'Budget spent.' } });
109
+ assert.equal(formMid.phase, 'fallback');
110
+ assert.equal(formMid.fallback, 'Budget spent.');
111
+
112
+ const lost = G.reduce(G.reduce(open(), { type: 'send', text: 'x' }), { type: 'answer', status: 404, body: { ok: false, error: 'not_found' } });
113
+ assert.equal(lost.phase, 'fallback');
114
+ assert.equal(lost.fallback, G.COPY.lost);
115
+
116
+ const noNet = G.reduce(G.reduce(G.initial(), { type: 'start' }), { type: 'started', status: 0, body: {} });
117
+ assert.equal(noNet.phase, 'fallback');
118
+ assert.equal(noNet.fallback, G.COPY.fallback);
119
+ });
120
+
121
+ // ── the retry errors keep the visitor's words ─────────────────────────────────────
122
+ test('too long, too fast, empty and a dropped request each say so and give the words back', () => {
123
+ const sent = G.reduce(open(), { type: 'send', text: 'my words' });
124
+ const long = G.reduce(sent, { type: 'answer', status: 400, body: { ok: false, error: 'too_long', max_message_chars: 500 } });
125
+ assert.equal(long.phase, 'chat');
126
+ assert.equal(long.restore, 'my words');
127
+ assert.equal(long.error, 'That is too long. Keep it under 500 characters.');
128
+ assert.equal(long.maxChars, 500);
129
+ assert.ok(!long.messages.some((m) => m.role === 'visitor'), 'the unsent line leaves the log');
130
+
131
+ const fast = G.reduce(sent, { type: 'answer', status: 429, body: { ok: false, error: 'busy', retry_after_seconds: 2 } });
132
+ assert.equal(fast.error, 'Too quick. Try again in 2 seconds.');
133
+ assert.equal(fast.restore, 'my words');
134
+
135
+ const empty = G.reduce(sent, { type: 'answer', status: 400, body: { ok: false, error: 'empty' } });
136
+ assert.equal(empty.error, G.COPY.empty);
137
+
138
+ const dropped = G.reduce(sent, { type: 'failed' });
139
+ assert.equal(dropped.phase, 'chat');
140
+ assert.equal(dropped.error, G.COPY.failed);
141
+ assert.equal(dropped.restore, 'my words');
142
+ });
143
+
144
+ // ── the first send waits ──────────────────────────────────────────────────────────
145
+ test('the first message waits MIN_FIRST_MS after the start; later ones never wait', () => {
146
+ assert.ok(G.MIN_FIRST_MS >= 1500, 'the server refuses anything sooner than 1500ms');
147
+ const s = open(); // started at 1000
148
+ assert.equal(G.firstSendWait(s, 1000), G.MIN_FIRST_MS);
149
+ assert.equal(G.firstSendWait(s, 1000 + G.MIN_FIRST_MS + 1), 0);
150
+ const after = G.reduce(s, { type: 'send', text: 'x' });
151
+ assert.equal(G.firstSendWait(after, 1001), 0);
152
+ });
153
+
154
+ // ── the plan ───────────────────────────────────────────────────────────────────────
155
+ const PLAN_BODY = {
156
+ summary: 'A booking site for a small yoga studio.', project_type: 'business',
157
+ how_it_works: ['Students book a spot.'], modules: [{ key: 'agents', label: 'Extra agents', summary: 'x', why: 'It fits.' }],
158
+ steps: [{ title: 'Sign up', detail: 'Create your account.' }], needs: [{ item: 'A payments account', detail: 'To take cards.' }],
159
+ open_questions: ['Refunds?'], cost_and_time: 'This plan does not estimate cost or time.',
160
+ };
161
+ const planAnswer = { ok: true, mode: 'plan', plan: PLAN_BODY, next: { action: 'sign_up', label: 'Sign up and get started' }, message: 'Here is your plan.', saved: { message: 'This chat is saved.', keep_days: 30 }, conversation: { messages_used: 2, messages_left: 1 } };
162
+
163
+ test('the plan: the server\'s Plan becomes a summary, sections in order and the cost line', () => {
164
+ const p = G.readPlan(PLAN_BODY);
165
+ assert.equal(p.summary, 'A booking site for a small yoga studio.');
166
+ assert.deepEqual(p.sections, [
167
+ { heading: 'How it works', items: ['Students book a spot.'] },
168
+ { heading: 'Steps', items: ['Sign up: Create your account.'] },
169
+ { heading: 'Modules to start with', items: ['Extra agents: It fits.'] },
170
+ { heading: 'You will need', items: ['A payments account: To take cards.'] },
171
+ { heading: 'Still to decide', items: ['Refunds?'] },
172
+ ]);
173
+ assert.equal(p.note, 'This plan does not estimate cost or time.');
174
+ assert.equal(G.readPlan(null), null);
175
+ assert.equal(G.readPlan(' '), null);
176
+ assert.equal(G.readPlan(42), null);
177
+ assert.equal(G.readPlan({ project_type: 'game' }), null, 'nothing to show is no plan');
178
+ assert.equal(G.readPlan('Just words.').summary, 'Just words.');
179
+ });
180
+
181
+ test('planning → ended: the plan, the closing label and the saved line land; the counter reads none left', () => {
182
+ const s0 = G.reduce(G.reduce(open(), { type: 'send', text: 'x' }), { type: 'answer', status: 200, body: { ok: true, mode: 'chat', reply: { text: 'ok' }, conversation: { messages_used: 1, messages_left: 2 } } });
183
+ const s1 = G.reduce(s0, { type: 'plan' });
184
+ assert.equal(s1.phase, 'planning');
185
+ const s2 = G.reduce(s1, { type: 'planned', status: 200, body: planAnswer });
186
+ assert.equal(s2.phase, 'ended');
187
+ assert.equal(s2.plan.summary, 'A booking site for a small yoga studio.');
188
+ assert.equal(s2.next, 'Sign up and get started');
189
+ assert.equal(s2.saved, 'This chat is saved.');
190
+ assert.equal(s2.left, 0);
191
+ });
192
+
193
+ test('the plan call: too early goes back to the chat, a form answer is the fallback, a failure can be asked again', () => {
194
+ const chat = G.reduce(open(), { type: 'plan' });
195
+ const early = G.reduce(chat, { type: 'planned', status: 409, body: { ok: false, error: 'too_early' } });
196
+ assert.equal(early.phase, 'chat');
197
+ assert.equal(early.error, G.COPY.tooEarly);
198
+ const form = G.reduce(chat, { type: 'planned', status: 200, body: { ok: true, mode: 'form', reason: 'daily_budget', message: 'Budget spent.' } });
199
+ assert.equal(form.phase, 'fallback');
200
+ assert.equal(form.fallback, 'Budget spent.');
201
+ const failed = G.reduce(chat, { type: 'planned', status: 0, body: {} });
202
+ assert.equal(failed.phase, 'ended');
203
+ assert.equal(failed.plan, null, 'ended with no plan: the end strip offers Make my plan again');
204
+ assert.equal(failed.error, G.COPY.planFailed);
205
+ const lost = G.reduce(chat, { type: 'planned', status: 404, body: { ok: false, error: 'not_found' } });
206
+ assert.equal(lost.phase, 'fallback');
207
+ });
208
+
209
+ test('an ended answer that says next_step sign_up with the plan in hand goes straight to the end', () => {
210
+ const s = G.reduce(G.reduce(open(), { type: 'send', text: 'x' }), { type: 'answer', status: 200, body: { ok: true, mode: 'ended', reason: 'planned', next_step: 'sign_up', plan: PLAN_BODY, message: 'Done.', conversation: { messages_used: 1, messages_left: 0 } } });
211
+ assert.equal(s.phase, 'ended');
212
+ assert.ok(s.plan);
213
+ });
214
+
215
+ // ── a returning visitor ─────────────────────────────────────────────────────────────
216
+ test('resumed: the transcript, the plan and the counts come back; an ended chat lands on the end', () => {
217
+ const body = { ok: true, conversation: { id: '7', status: 'ended', end_reason: 'planned', messages_used: 2, messages_left: 1, max_message_chars: 1000 }, transcript: [{ role: 'visitor', text: 'A booking site' }, { role: 'guide', text: 'Who books?' }, { role: 'system', text: 'dropped' }], plan: PLAN_BODY, saved: { message: 'Saved.' } };
218
+ const s = G.reduce(G.initial(), { type: 'resumed', status: 200, body, conv: { id: '7', token: 'tok' }, now: 5 });
219
+ assert.equal(s.phase, 'ended');
220
+ assert.deepEqual(s.messages, [{ role: 'visitor', text: 'A booking site' }, { role: 'guide', text: 'Who books?' }]);
221
+ assert.deepEqual(s.conv, { id: '7', token: 'tok' });
222
+ assert.equal(s.left, 1);
223
+ assert.equal(s.saved, 'Saved.');
224
+ assert.equal(G.firstSendWait(s, 6), 0, 'a chat with a message in it is past the bot check');
225
+ const open2 = G.reduce(G.initial(), { type: 'resumed', status: 200, body: { ...body, plan: null, conversation: { ...body.conversation, status: 'open', messages_used: 0, messages_left: 3 }, transcript: [] }, conv: { id: '7', token: 'tok' }, now: 5 });
226
+ assert.equal(open2.phase, 'chat');
227
+ assert.equal(G.firstSendWait(open2, 5), G.MIN_FIRST_MS, 'nothing said yet: the bot check still applies');
228
+ assert.equal(G.reduce(G.initial(), { type: 'resumed', status: 500, body: {} }).phase, 'closed');
229
+ });
230
+
231
+ // ── boot(), run under a small DOM stub ────────────────────────────────────────────
232
+ function makeDom() {
233
+ const listeners = new Map();
234
+ const on = (target) => (type, fn) => { const k = target; if (!listeners.has(k)) listeners.set(k, {}); (listeners.get(k)[type] ||= []).push(fn); };
235
+ const fire = (target, type, ev) => ((listeners.get(target) || {})[type] || []).forEach((fn) => fn(ev));
236
+ function node(tag = 'div') {
237
+ const n = {
238
+ tagName: tag.toUpperCase(), children: [], className: '', textContent: '', hidden: false, disabled: false, value: '', maxLength: 0, attrs: {}, scrollTop: 0, scrollHeight: 0, offsetParent: {},
239
+ get firstChild() { return this.children[0] || null; },
240
+ appendChild(c) { this.children.push(c); return c; },
241
+ removeChild(c) { this.children.splice(this.children.indexOf(c), 1); return c; },
242
+ setAttribute(k, v) { this.attrs[k] = String(v); }, getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
243
+ removeAttribute(k) { delete this.attrs[k]; },
244
+ classList: { set: new Set(), add(c) { this.set.add(c); }, remove(c) { this.set.delete(c); }, toggle(c, on) { if (on) this.set.add(c); else this.set.delete(c); }, contains(c) { return this.set.has(c); } },
245
+ focus() { doc.activeElement = n; },
246
+ querySelectorAll() { return []; },
247
+ querySelector() { return null; },
248
+ };
249
+ n.addEventListener = on(n);
250
+ Object.defineProperty(n, 'innerHTML', { set() { throw new Error('innerHTML is not allowed'); } });
251
+ return n;
252
+ }
253
+ const parts = {};
254
+ ['log', 'err', 'form', 'text', 'website', 'send', 'end', 'go', 'left', 'segs', 'plan-body', 'plan-toggle', 'plan-close', 'close', 'skip', 'make-plan', 'plan-again', 'saved'].forEach((k) => { parts[k] = node(); });
255
+ // desktop by default: the Plan button is the one-column layout's only, so it is not rendered
256
+ parts['plan-toggle'].offsetParent = null;
257
+ const img = node('img');
258
+ const scene = node();
259
+ scene.hidden = true;
260
+ scene.querySelector = (sel) => {
261
+ const m = sel.match(/^\[data-gd="([^"]+)"\]$/);
262
+ if (m) return parts[m[1]];
263
+ if (sel === '.gd__face img') return img;
264
+ return null;
265
+ };
266
+ const doc = {
267
+ activeElement: null,
268
+ documentElement: node('html'),
269
+ head: node('head'),
270
+ getElementById: (id) => (id === 'guide' ? scene : null),
271
+ createElement: (t) => node(t),
272
+ querySelector: () => null,
273
+ };
274
+ doc.addEventListener = on(doc);
275
+ return { doc, scene, parts, fire, node };
276
+ }
277
+
278
+ function makeWin(mods, routes) {
279
+ const calls = [];
280
+ const timers = [];
281
+ const win = {
282
+ __MODULES__: mods,
283
+ localStorage: { data: {}, getItem(k) { return k in this.data ? this.data[k] : null; }, setItem(k, v) { this.data[k] = String(v); }, removeItem(k) { delete this.data[k]; } },
284
+ setTimeout: (fn, ms) => { timers.push({ fn, ms }); },
285
+ fetch: (url, opts = {}) => {
286
+ calls.push({ url, method: opts.method || 'GET', body: opts.body ? JSON.parse(opts.body) : null, headers: opts.headers || {} });
287
+ const r = routes(url, opts);
288
+ return Promise.resolve({ status: r.status, json: () => Promise.resolve(r.body) });
289
+ },
290
+ };
291
+ return { win, calls, timers };
292
+ }
293
+ const tick = () => new Promise((r) => setImmediate(r));
294
+ function click(dom, door) {
295
+ let prevented = false;
296
+ const ev = { target: { closest: (sel) => (sel === '[data-guide-door]' ? door : null) }, button: 0, preventDefault() { prevented = true; }, get defaultPrevented() { return prevented; } };
297
+ dom.fire(dom.doc, 'click', ev);
298
+ return prevented;
299
+ }
300
+
301
+ test('module off: boot reads nothing and the doors stay plain links', async () => {
302
+ const dom = makeDom();
303
+ const { win, calls } = makeWin({ enabled: { 'front-door-guide': false } }, () => ({ status: 500, body: {} }));
304
+ G.boot(win, dom.doc);
305
+ await tick();
306
+ assert.equal(calls.length, 0, 'no status read with the module off');
307
+ assert.equal(click(dom, dom.node('a')), false, 'the click is not taken');
308
+ assert.equal(dom.scene.hidden, true);
309
+ });
310
+
311
+ test('status "form" (no key, budget spent): the door is the plain link it always was', async () => {
312
+ const dom = makeDom();
313
+ const { win, calls } = makeWin({ enabled: { 'front-door-guide': true } }, () => ({ status: 200, body: { ok: true, mode: 'form', reason: 'no_key', message: 'Use the form.' } }));
314
+ G.boot(win, dom.doc);
315
+ await tick();
316
+ assert.deepEqual(calls.map((c) => c.url), ['/api/bongos/front-door-guide/status']);
317
+ assert.equal(click(dom, dom.node('a')), false);
318
+ assert.equal(dom.scene.hidden, true);
319
+ assert.equal(calls.length, 1, 'no conversation started');
320
+ });
321
+
322
+ function chatRoutes(over = {}) {
323
+ let left = 2;
324
+ return (url, opts) => {
325
+ const m = (opts.method || 'GET') + ' ' + url;
326
+ if (over[m]) return over[m](opts);
327
+ if (url.endsWith('/status')) return { status: 200, body: { ok: true, mode: 'chat', limits: { max_messages: 2, max_message_chars: 1000 } } };
328
+ if (m === 'POST /api/bongos/front-door-guide/conversations') return { status: 201, body: { ok: true, mode: 'chat', greeting: 'Hi!', conversation: { id: '9', token: 'tk', messages_used: 0, messages_left: 2, max_message_chars: 1000 }, saved: { message: 'This chat is saved on this device.', keep_days: 30 } } };
329
+ if (url.endsWith('/plan')) return { status: 200, body: planAnswer };
330
+ left -= 1;
331
+ return left > 0
332
+ ? { status: 200, body: { ok: true, mode: 'chat', reply: { text: 'Tell me more.' }, conversation: { messages_used: 1, messages_left: left } } }
333
+ : { status: 200, body: { ok: true, mode: 'ended', reason: 'message_cap', next_step: 'plan', message: 'That is all.', reply: { text: 'Thanks.' }, conversation: { messages_used: 2, messages_left: 0 } } };
334
+ };
335
+ }
336
+
337
+ test('status "chat": the door opens the scene, the first send waits, the cap asks for the plan, the chat is held in local storage', async () => {
338
+ const dom = makeDom();
339
+ const { win, calls, timers } = makeWin({ enabled: { 'front-door-guide': true } }, chatRoutes());
340
+ G.boot(win, dom.doc);
341
+ await tick();
342
+ const door = dom.node('a');
343
+ assert.equal(click(dom, door), true, 'the click is taken');
344
+ assert.equal(dom.scene.hidden, false);
345
+ assert.ok(dom.doc.documentElement.classList.contains('gd-open'));
346
+ await tick(); await tick();
347
+ assert.equal(dom.scene.getAttribute('data-phase'), 'chat');
348
+ assert.equal(calls[1].url, '/api/bongos/front-door-guide/conversations');
349
+ assert.deepEqual(calls[1].body, { website: '' }, 'the hidden field is sent as it stands');
350
+ assert.deepEqual(JSON.parse(win.localStorage.data[G.STORE_KEY]), { id: '9', token: 'tk' }, 'held in LOCAL storage');
351
+ assert.equal(dom.parts.saved.textContent, 'This chat is saved on this device.');
352
+ assert.equal(dom.parts.left.textContent, '2 of 2 messages left');
353
+ assert.equal(dom.parts['make-plan'].hidden, true, 'nothing said yet: no plan to ask for');
354
+
355
+ // the first message: held for the bot check
356
+ dom.parts.text.value = 'A booking site';
357
+ dom.fire(dom.parts.form, 'submit', { preventDefault() {} });
358
+ assert.equal(dom.scene.getAttribute('data-phase'), 'sending');
359
+ assert.ok(timers[0].ms > 1000, `held ${timers[0].ms}ms`);
360
+ timers.shift().fn();
361
+ await tick(); await tick();
362
+ assert.deepEqual(calls[2].body, { token: 'tk', text: 'A booking site', website: '' });
363
+ assert.equal(dom.parts.left.textContent, '1 of 2 messages left');
364
+ assert.equal(dom.parts['make-plan'].hidden, false, 'after a message the visitor may end early for the plan');
365
+
366
+ // the second: no wait; it reaches the cap, and the plan is asked for by itself
367
+ dom.parts.text.value = 'Students book';
368
+ dom.fire(dom.parts.form, 'submit', { preventDefault() {} });
369
+ assert.equal(timers[0].ms, 0);
370
+ timers.shift().fn();
371
+ await tick(); await tick(); await tick(); await tick();
372
+ const planCall = calls.find((c) => c.url.endsWith('/conversations/9/plan'));
373
+ assert.ok(planCall, 'POST …/plan once the chat ended');
374
+ assert.deepEqual(planCall.body, { token: 'tk', website: '' });
375
+ assert.equal(dom.scene.getAttribute('data-phase'), 'ended');
376
+ assert.equal(dom.parts.form.hidden, true);
377
+ assert.equal(dom.parts.end.hidden, false);
378
+ assert.equal(dom.parts.go.textContent, 'Sign up and get started');
379
+ assert.equal(dom.parts['plan-again'].hidden, true, 'the plan is in: nothing to ask again');
380
+ assert.ok(dom.parts['plan-body'].children.length > 3, 'the plan is drawn');
381
+ assert.equal(dom.parts.left.textContent, 'No messages left');
382
+ assert.equal(dom.doc.activeElement, dom.parts.go, 'focus moves to the way on');
383
+
384
+ // Escape closes and hands focus back to the door
385
+ dom.fire(dom.scene, 'keydown', { key: 'Escape', preventDefault() {} });
386
+ assert.equal(dom.scene.hidden, true);
387
+ assert.equal(dom.doc.activeElement, door);
388
+ assert.ok(!dom.doc.documentElement.classList.contains('gd-open'));
389
+ });
390
+
391
+ test('"Make my plan" ends the chat early and asks for the plan', async () => {
392
+ const dom = makeDom();
393
+ const { win, calls, timers } = makeWin({ enabled: { 'front-door-guide': true } }, chatRoutes());
394
+ G.boot(win, dom.doc);
395
+ await tick();
396
+ click(dom, dom.node('a'));
397
+ await tick(); await tick();
398
+ dom.parts.text.value = 'A booking site';
399
+ dom.fire(dom.parts.form, 'submit', { preventDefault() {} });
400
+ timers.shift().fn();
401
+ await tick(); await tick();
402
+ dom.fire(dom.parts['make-plan'], 'click', {});
403
+ assert.equal(dom.scene.getAttribute('data-phase'), 'planning');
404
+ await tick(); await tick(); await tick();
405
+ assert.equal(calls.filter((c) => c.url.endsWith('/plan')).length, 1);
406
+ assert.equal(dom.scene.getAttribute('data-phase'), 'ended');
407
+ });
408
+
409
+ test('a held chat is resumed on load and opened as it was, never started again', async () => {
410
+ const dom = makeDom();
411
+ const resumed = { ok: true, conversation: { id: '4', status: 'open', messages_used: 1, messages_left: 1, max_message_chars: 1000 }, transcript: [{ role: 'visitor', text: 'Mine' }, { role: 'guide', text: 'Yours' }], plan: null, saved: { message: 'Saved.' } };
412
+ const { win, calls } = makeWin({ enabled: { 'front-door-guide': true } }, (url) => {
413
+ if (url.endsWith('/status')) return { status: 200, body: { ok: true, mode: 'form', reason: 'daily_budget' } };
414
+ if (url.endsWith('/conversations/4')) return { status: 200, body: resumed };
415
+ return { status: 500, body: {} };
416
+ });
417
+ win.localStorage.data[G.STORE_KEY] = JSON.stringify({ id: '4', token: 'held' });
418
+ G.boot(win, dom.doc);
419
+ await tick(); await tick();
420
+ const read = calls.find((c) => c.url.endsWith('/conversations/4'));
421
+ assert.ok(read, 'GET …/conversations/:id on load');
422
+ assert.equal(read.headers['x-guide-token'], 'held');
423
+ // even with today's budget spent, the chat this browser holds is still theirs to see
424
+ assert.equal(click(dom, dom.node('a')), true);
425
+ await tick();
426
+ assert.ok(!calls.some((c) => c.method === 'POST'), 'no new chat started');
427
+ assert.equal(dom.scene.getAttribute('data-phase'), 'chat');
428
+ assert.equal(dom.parts.log.children.length, 2);
429
+ });
430
+
431
+ test('a held chat the server no longer has is forgotten', async () => {
432
+ const dom = makeDom();
433
+ const { win } = makeWin({ enabled: { 'front-door-guide': true } }, (url) => (url.endsWith('/status') ? { status: 200, body: { ok: true, mode: 'chat' } } : { status: 404, body: { ok: false, error: 'not_found' } }));
434
+ win.localStorage.data[G.STORE_KEY] = JSON.stringify({ id: '4', token: 'gone' });
435
+ G.boot(win, dom.doc);
436
+ await tick(); await tick();
437
+ assert.equal(win.localStorage.data[G.STORE_KEY], undefined);
438
+ });
439
+
440
+ test('a modified click (new tab) is never taken', async () => {
441
+ const dom = makeDom();
442
+ const { win } = makeWin({ enabled: { 'front-door-guide': true } }, () => ({ status: 200, body: { ok: true, mode: 'chat' } }));
443
+ G.boot(win, dom.doc);
444
+ await tick();
445
+ let prevented = false;
446
+ dom.fire(dom.doc, 'click', { target: { closest: () => dom.node('a') }, button: 0, ctrlKey: true, preventDefault() { prevented = true; }, defaultPrevented: false });
447
+ assert.equal(prevented, false);
448
+ });
449
+
450
+ // ── the markup and the sheet ──────────────────────────────────────────────────────
451
+ test('both Start a project doors carry the guide hook and keep their href', () => {
452
+ const doors = [...HTML.matchAll(/<a class="door door--go" href="([^"]+)"( data-guide-door)?>Start a project<\/a>/g)];
453
+ assert.equal(doors.length, 2);
454
+ doors.forEach((d) => { assert.equal(d[1], '/projects?view=new'); assert.ok(d[2], 'carries data-guide-door'); });
455
+ });
456
+
457
+ test('the scene is markup, hidden, a labelled modal dialog, with the honeypot out of the tab order', () => {
458
+ assert.match(HTML, /<div class="gd" id="guide" role="dialog" aria-modal="true" aria-labelledby="gdTitle" hidden>/);
459
+ assert.match(HTML, /<input class="gd__hp" type="text" name="website" data-gd="website" tabindex="-1" autocomplete="off" aria-hidden="true">/);
460
+ assert.match(HTML, /<label class="vh" for="gdText">Your message<\/label>/);
461
+ assert.match(HTML, /data-gd="go" href="\/projects\?view=new">Sign up and get started<\/a>/);
462
+ assert.match(HTML, /<button type="button" class="gd__more" data-gd="make-plan" hidden>Make my plan<\/button>/);
463
+ assert.match(HTML, /<button type="button" class="gd__more" data-gd="plan-again" hidden>Make my plan<\/button>/);
464
+ assert.match(HTML, /<p class="gd__saved" data-gd="saved" hidden><\/p>/);
465
+ assert.match(HTML, /<script src="\/assets\/guide-scene\.js"><\/script>/);
466
+ assert.match(HTML, /<link rel="stylesheet" href="\/assets\/guide-scene\.css">/);
467
+ // the guide is the Patron sprite the hall already serves, not a second drawing
468
+ assert.match(HTML, /src="\/modules\/hall-ui\/public\/characters\/patron\.png"/);
469
+ assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'characters', 'patron.png')));
470
+ });
471
+
472
+ test('a signed-in visitor keeps the plain doors: the /me read takes the hook off', () => {
473
+ assert.match(HTML, /if \(!me \|\| !me\.github_login\) return;\n\s+\/\* the start-a-project guide is for a visitor who has not signed up \(task 1005275\) \*\/\n\s+document\.querySelectorAll\('\[data-guide-door\]'\)\.forEach\(function \(d\) \{ d\.removeAttribute\('data-guide-door'\); \}\);/);
474
+ });
475
+
476
+ test('the script writes no innerHTML and talks only to the guide routes', () => {
477
+ assert.doesNotMatch(JS_SRC.replace(/\/\*[\s\S]*?\*\//g, ''), /innerHTML/);
478
+ const urls = [...JS_SRC.matchAll(/API \+ '([^']+)'/g)].map((m) => m[1]);
479
+ assert.deepEqual([...new Set(urls)].sort(), ['/conversations', '/conversations/', '/status']);
480
+ assert.match(JS_SRC, /convUrl\('\/plan'\)/, 'the plan route');
481
+ assert.doesNotMatch(JS_SRC, /sessionStorage/, 'the chat is kept in local storage, as the server\'s contract says');
482
+ assert.equal(G.API, '/api/bongos/front-door-guide');
483
+ });
484
+
485
+ test('the sheet: the fifteen only, no :root, no dark rule of its own, no !important, motion inside the world switch', () => {
486
+ const code = CSS.replace(/\/\*[\s\S]*?\*\//g, '');
487
+ assert.doesNotMatch(code, /#[0-9a-f]{3,8}\b/i, 'no hex colour');
488
+ assert.doesNotMatch(code, /\brgba?\((?!0, 0, 0,)/, 'the only literal is black with alpha (the scrim)');
489
+ assert.doesNotMatch(code, /:root/);
490
+ assert.doesNotMatch(code, /data-mode|prefers-color-scheme/);
491
+ assert.doesNotMatch(code, /!important/);
492
+ // the waiting dots animate; world.css (linked first on the landing) stops every animation
493
+ assert.match(code, /animation: gd-wait/);
494
+ const world = fs.readFileSync(path.join(LANDING, 'assets', 'world.css'), 'utf8');
495
+ assert.match(world, /prefers-reduced-motion: reduce[\s\S]*animation: none !important; transition: none !important;/);
496
+ assert.ok(HTML.indexOf('/assets/world.css') < HTML.indexOf('/assets/guide-scene.css'));
497
+ });
498
+
499
+ test('every control in the scene clears the 24px floor', () => {
500
+ for (const [sel, min] of [['.gd__x', /width: 40px; height: 40px/], ['.gd__send, .gd__go, .gd__planbtn', /min-height: 48px/], ['.gd__skip', /min-height: 24px/], ['.gd__more', /min-height: 40px/]]) {
501
+ const rule = CSS.slice(CSS.indexOf(sel + ' {'));
502
+ assert.match(rule.slice(0, rule.indexOf('}')), min, sel);
503
+ }
504
+ });
@@ -193,6 +193,7 @@ await ta('it knocks only on an onrender.com address, and follows no redirect', a
193
193
  await ta('a sweep marks only answering apps live, compare-and-set on the state it read', async () => {
194
194
  const writes = [];
195
195
  const db = { query: async (sql, params) => {
196
+ if (/app_host->'sandbox'/.test(sql)) return { rows: [] }; // the sandbox read (task 1005289)
196
197
  if (/^\s*SELECT/.test(sql)) {
197
198
  assert.match(sql, /app_host->>'state' IN \('deploying', 'linked'\)/);
198
199
  return { rows: [
@@ -213,7 +214,7 @@ await ta('a sweep marks only answering apps live, compare-and-set on the state i
213
214
  await ta('the knocks run a few at a time, never all at once and never one by one', async () => {
214
215
  let inFlight = 0, peak = 0;
215
216
  const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, slug: `a${i}`, app_host: { state: 'deploying', serviceUrl: `https://a${i}.onrender.com` } }));
216
- const db = { query: async (sql) => (/^\s*SELECT/.test(sql) ? { rows } : { rowCount: 1 }) };
217
+ const db = { query: async (sql) => (/app_host->'sandbox'/.test(sql) ? { rows: [] } : /^\s*SELECT/.test(sql) ? { rows } : { rowCount: 1 }) };
217
218
  const fetchImpl = async () => { inFlight++; peak = Math.max(peak, inFlight); await new Promise((r) => setTimeout(r, 5)); inFlight--; return res(200); };
218
219
  const out = await liveness.sweepOnce({ pool: db, fetchImpl, log: () => {} });
219
220
  assert.deepEqual(out, { checked: 12, live: 12 });
@@ -233,7 +234,7 @@ await ta('a run that starts while the previous sweep is in flight is skipped (ta
233
234
  const [firstOut, second] = await Promise.all([first, secondRun]);
234
235
  assert.deepEqual(second, { skipped: true });
235
236
  assert.deepEqual(firstOut, { checked: 0, live: 0 });
236
- assert.equal(reads, 1, 'the overlapping run never touched the database');
237
+ assert.equal(reads, 2, 'the overlapping run never touched the database (the one run read the apps, then the sandboxes)');
237
238
  assert.deepEqual(await liveness.tick({ pool: { query: async () => ({ rows: [] }) }, log: () => {} }), { checked: 0, live: 0 },
238
239
  'once the first finished, the next run goes ahead');
239
240
  });
@@ -95,7 +95,7 @@ await ta('the owner queues it: ciphertext only, the request recorded on the row,
95
95
  assert.equal(r.status, 200, r.raw);
96
96
  assert.equal(enqueueCalls.length, 1);
97
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,
98
+ assert.deepEqual(enqueueCalls[0].appHost, { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234', state: 'requested', error: null, sandboxWanted: true,
99
99
  runtime: 'node', startCommand: 'npm start', buildCommand: 'npm ci', database: true, webPlan: 'standard' });
100
100
  assert.equal(events.length, 1);
101
101
  noKeyAnywhere(enqueueCalls, events, r.body);
@@ -256,6 +256,56 @@ await ta('a mid-flight provision is a 409 conflict — never the false "teardown
256
256
  assert.doesNotMatch(String(res.body.error.message), /already queued/i, 'the old lie is gone');
257
257
  });
258
258
 
259
+ // The owner's Render key on a teardown removes the project's sandbox (task 1005289, ADR 0406 §7).
260
+ const renderSandbox = require('../modules/provisioning/render-sandbox.js');
261
+ const secretBox = require('../src/bongos/secret-box.js');
262
+ let lentCalls = [];
263
+ renderSandbox.lendKeyToTeardown = async (_db, intentId, keyEnc) => { lentCalls.push({ intentId, keyEnc }); return true; };
264
+ function postWithKey(path, key) {
265
+ return new Promise((resolve, reject) => {
266
+ const req = http.request(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': 2, 'x-render-key': key } }, (res) => {
267
+ let d = ''; res.on('data', (c) => { d += c; });
268
+ res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(d), raw: d }));
269
+ });
270
+ req.on('error', reject);
271
+ req.end('{}');
272
+ });
273
+ }
274
+ const RKEY = 'rnd_abcdefghijklmnopqrstuvwx';
275
+ console.log('\nPOST /provisioning/instances/:id/teardown — the Render key for the sandbox:');
276
+ await ta('a well-formed key is sealed and lent to the teardown just queued, never echoed', async () => {
277
+ const realConf = secretBox.isConfigured, realEnc = secretBox.encrypt;
278
+ secretBox.isConfigured = () => true; secretBox.encrypt = (p) => `v1.sealed(${p.length})`;
279
+ try {
280
+ lentCalls = [];
281
+ teardownOutcome = { intent: { id: 31, action: 'teardown', state: 'pending' }, created: true, superseded: false };
282
+ const res = await postWithKey('/provisioning/instances/16/teardown', RKEY);
283
+ assert.equal(res.status, 200, res.raw);
284
+ assert.equal(res.body.sandbox_key_lent, true);
285
+ assert.deepEqual(lentCalls, [{ intentId: 31, keyEnc: `v1.sealed(${RKEY.length})` }], 'ciphertext only, on THIS teardown');
286
+ assert.ok(!res.raw.includes(RKEY), 'the key is never sent back');
287
+ } finally { secretBox.isConfigured = realConf; secretBox.encrypt = realEnc; }
288
+ });
289
+ await ta('a malformed key, an already-queued teardown, or no safe seal: nothing lent, the teardown still answers', async () => {
290
+ const realConf = secretBox.isConfigured, realEnc = secretBox.encrypt;
291
+ secretBox.encrypt = (p) => `v1.sealed(${p.length})`;
292
+ try {
293
+ secretBox.isConfigured = () => true;
294
+ lentCalls = [];
295
+ teardownOutcome = { intent: { id: 32, action: 'teardown', state: 'pending' }, created: true, superseded: false };
296
+ let res = await postWithKey('/provisioning/instances/16/teardown', 'not-a-key');
297
+ assert.equal(res.status, 200); assert.equal(res.body.sandbox_key_lent, false);
298
+ teardownOutcome = { intent: { id: 32, action: 'teardown', state: 'pending' }, created: false, superseded: false };
299
+ res = await postWithKey('/provisioning/instances/16/teardown', RKEY);
300
+ assert.equal(res.status, 200); assert.equal(res.body.sandbox_key_lent, false, 'not on a teardown queued earlier');
301
+ secretBox.isConfigured = () => false;
302
+ teardownOutcome = { intent: { id: 33, action: 'teardown', state: 'pending' }, created: true, superseded: false };
303
+ res = await postWithKey('/provisioning/instances/16/teardown', RKEY);
304
+ assert.equal(res.status, 200); assert.equal(res.body.sandbox_key_lent, false, 'never stored unsealed');
305
+ assert.equal(lentCalls.length, 0);
306
+ } finally { secretBox.isConfigured = realConf; secretBox.encrypt = realEnc; }
307
+ });
308
+
259
309
  console.log('\nPOST /provisioning/instances/:id/force-teardown — same truthful mapping (archon):');
260
310
 
261
311
  await ta('force-teardown surfaces the same 409 conflict instead of a false success', async () => {
@@ -127,10 +127,13 @@ await ta('listOwners returns every workspace the key reaches, unwrapped', async
127
127
  });
128
128
 
129
129
  console.log('\nno delete:');
130
- await ta('there is no delete method, and the verb is refused', async () => {
130
+ await ta('there is no delete method but the sandbox one, and the verb is refused', async () => {
131
131
  const { api, calls } = live({});
132
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(', ')}`);
133
+ // ADR 0406 §7 / task 1005289: the ONE destructive method removes a free `…-sandbox`
134
+ // service the platform made (tests/render_sandbox.mjs proves it refuses anything else).
135
+ const destructive = methods.filter((m) => /delete|destroy|remove|suspend/i.test(m));
136
+ assert.deepEqual(destructive, ['deleteSandboxService'], `no other destructive method: ${methods.join(', ')}`);
134
137
  await assert.rejects(api._req('DELETE', '/services/srv-1'), /never deletes/);
135
138
  assert.equal(calls.length, 0);
136
139
  });