@bongos/core 1.20.82 → 1.21.1

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 (76) hide show
  1. package/.bongos-core.json +154 -64
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
  8. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
  9. package/docs/adr/README.md +1 -1
  10. package/docs/api/openapi.json +153 -4
  11. package/docs/api-reference.md +5 -3
  12. package/docs/architecture.md +3 -0
  13. package/docs/copy-inventory.md +10 -10
  14. package/docs/copy-registry.json +12 -12
  15. package/docs/design/project-startup-direction.md +63 -0
  16. package/docs/file-map.md +2 -0
  17. package/docs/module-api-changelog.md +8 -0
  18. package/docs/page-inventory.json +8 -3
  19. package/docs/page-readings.json +382 -373
  20. package/docs/recipes/private-npm-distribution.md +1 -1
  21. package/migrations/core_269_session_transcripts.sql +35 -0
  22. package/modules/hall-ui/public/genesis-home.js +17 -3
  23. package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
  24. package/modules/lifecycle/done-when.js +82 -42
  25. package/modules/lifecycle/workflow-dispatch.js +21 -11
  26. package/modules/npm-release/release.js +10 -1
  27. package/modules/onboarding/founding-birth.js +116 -0
  28. package/modules/onboarding/genesis-home.js +72 -5
  29. package/modules/onboarding/port.js +9 -2
  30. package/modules/onboarding/routes/onboarding.js +8 -1
  31. package/modules/provisioning/birth.js +64 -0
  32. package/modules/provisioning/dns-resolves.js +54 -0
  33. package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
  34. package/modules/provisioning/pollers/liveness-sweep.js +5 -0
  35. package/modules/provisioning/provisioning.js +7 -6
  36. package/modules/provisioning/routes/provisioning.js +6 -2
  37. package/modules/provisioning/tests/liveness.mjs +48 -0
  38. package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
  39. package/modules/public-landing/public/projects.html +40 -15
  40. package/modules/sessions/db.js +58 -0
  41. package/modules/sessions/routes/sessions.js +97 -0
  42. package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
  43. package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
  44. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
  45. package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
  46. package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +41 -0
  50. package/scripts/gds/provision.js +77 -53
  51. package/scripts/gds/session-digest.js +11 -3
  52. package/scripts/gds/session-transcript-build.js +155 -0
  53. package/scripts/gds/session-transcript-upload.js +29 -0
  54. package/scripts/gds/ship-session-upload.js +6 -1
  55. package/scripts/gds/upgrade-commit-identity.js +32 -0
  56. package/scripts/gds/upgrade.js +1 -1
  57. package/src/bongos/routes.js +3 -0
  58. package/src/branding.js +9 -0
  59. package/src/module-api.js +1 -1
  60. package/tests/auto_satisfy_criteria.mjs +20 -0
  61. package/tests/founding_birth.mjs +200 -0
  62. package/tests/founding_mode.mjs +3 -1
  63. package/tests/genesis_home.mjs +71 -2
  64. package/tests/goal_closure_invariants.mjs +68 -0
  65. package/tests/hub_map_orb_states.mjs +5 -1
  66. package/tests/npm_release_release.mjs +37 -2
  67. package/tests/projects_hub.mjs +5 -3
  68. package/tests/projects_hub_dns_ready.mjs +153 -0
  69. package/tests/projects_hub_pre_uat.mjs +9 -5
  70. package/tests/provision_dns_first.mjs +171 -0
  71. package/tests/provision_settings_apply.mjs +6 -1
  72. package/tests/provisioning_settings_env.mjs +49 -0
  73. package/tests/session_transcript.mjs +273 -0
  74. package/tests/ui_design_kit.mjs +1 -1
  75. package/tests/upgrade_commit_identity.mjs +89 -0
  76. package/tests/wizard_demo.mjs +29 -3
@@ -0,0 +1,273 @@
1
+ // tests/session_transcript.mjs — ADR 0051 / task 1001004: full-transcript capture.
2
+ //
3
+ // Proves, DB-free (the db seam is stubbed like session_caps.mjs):
4
+ // 1. buildSessionTranscript keeps prompts, assistant text, tool calls AND results
5
+ // — the content the thin digest threw away — and a PLANTED SECRET in every
6
+ // position (prompt, reply, tool input, tool result) never survives.
7
+ // 2. Size bounds: a field is capped, and an over-ceiling transcript keeps head +
8
+ // tail and says it truncated.
9
+ // 3. PUT /sessions/:sid/transcript: owner forced to the caller, bad shapes 400,
10
+ // no owned digest row → 404.
11
+ // 4. GET /sessions/:id/transcript: owner reads own; a stranger gets 404; an
12
+ // Archon reads cross-builder ONLY after an audit row is written, and a failed
13
+ // audit withholds the content (fail-closed).
14
+ //
15
+ // Run: node tests/session_transcript.mjs
16
+ import { strict as assert } from 'node:assert';
17
+ import http from 'node:http';
18
+ import { createRequire } from 'node:module';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const express = require('express');
22
+ const auth = require('../src/module-api');
23
+ const db = require('../modules/sessions/db.js');
24
+ const economySeams = require('../src/module-seams.js');
25
+ if (!economySeams.hasProvider('reward')) economySeams.registerProvider('reward', require('../modules/economy/reward.js'));
26
+ const buildSessionsRouter = require('../modules/sessions/routes/sessions.js');
27
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
28
+ const { buildSessionTranscript, MAX_FIELD_CHARS } = require('../scripts/gds/session-transcript-build.js');
29
+
30
+ let passed = 0; let failed = 0;
31
+ async function t(name, fn) {
32
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
33
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); }
34
+ }
35
+
36
+ // ---- Layer 1: the pure builder ----
37
+ console.log('buildSessionTranscript:');
38
+ // Fake credentials assembled at runtime so no secret-shaped literal sits in source.
39
+ const KEY = ['sk', 'ant', 'api03', 'ABCDEFGHIJKLMNOPQRSTUVWX'].join('-');
40
+ const GH = ['ghp', 'abcdefghijklmnopqrstuvwxyz0123456789'].join('_');
41
+ const entries = [
42
+ { type: 'user', timestamp: '2026-10-01T10:00:00Z', message: { content: `please use ${KEY} to deploy` } },
43
+ { type: 'assistant', timestamp: '2026-10-01T10:00:05Z', message: { id: 'm1', content: [
44
+ { type: 'thinking', thinking: 'internal' },
45
+ { type: 'text', text: `I will export TOKEN=${GH} now` },
46
+ { type: 'tool_use', name: 'Bash', input: { command: `curl -H "Authorization: Bearer ${KEY}" x` } },
47
+ ] } },
48
+ { type: 'user', timestamp: '2026-10-01T10:00:09Z', message: { content: [
49
+ { type: 'tool_result', content: `output with ${GH}`, is_error: true },
50
+ { type: 'text', text: 'thanks' },
51
+ ] } },
52
+ { type: 'assistant', message: { id: 'm2', content: [{ type: 'thinking', thinking: 'only thinking' }] } },
53
+ ];
54
+
55
+ await t('keeps prompts, assistant text, tool calls and results', () => {
56
+ const r = buildSessionTranscript(entries);
57
+ assert.equal(r.turns.length, 3, 'the thinking-only line is dropped');
58
+ assert.match(r.turns[0].text, /please use/);
59
+ assert.equal(r.turns[1].role, 'assistant');
60
+ assert.match(r.turns[1].text, /I will export/);
61
+ assert.equal(r.turns[1].tool_calls[0].name, 'Bash');
62
+ assert.ok(r.turns[1].tool_calls[0].input.includes('curl'));
63
+ assert.equal(r.turns[2].tool_results[0].is_error, true);
64
+ assert.equal(r.turns[2].text, 'thanks');
65
+ assert.ok(!JSON.stringify(r.turns).includes('internal'), 'thinking is not captured');
66
+ });
67
+
68
+ await t('a planted secret in ANY position never survives', () => {
69
+ const out = JSON.stringify(buildSessionTranscript(entries).turns);
70
+ assert.ok(!out.includes(KEY), 'Anthropic key leaked');
71
+ assert.ok(!out.includes(GH), 'GitHub token leaked');
72
+ const withLiteral = buildSessionTranscript([
73
+ { type: 'user', message: { content: 'my token is literal-session-token-123 ok' } },
74
+ ], { extraSecrets: ['literal-session-token-123'] });
75
+ assert.ok(!JSON.stringify(withLiteral.turns).includes('literal-session-token-123'), 'literal extra secret leaked');
76
+ });
77
+
78
+ await t('NUL bytes are stripped (Postgres jsonb rejects them)', () => {
79
+ const r = buildSessionTranscript([{ type: 'user', message: { content: 'a\0b' } }]);
80
+ assert.equal(r.turns[0].text, 'ab');
81
+ });
82
+
83
+ await t('one oversized field is capped and says so', () => {
84
+ const r = buildSessionTranscript([{ type: 'user', message: { content: 'word '.repeat(MAX_FIELD_CHARS) } }]);
85
+ assert.ok(r.turns[0].text.length < MAX_FIELD_CHARS + 100);
86
+ assert.match(r.turns[0].text, /\[\+\d+ chars\]$/);
87
+ });
88
+
89
+ await t('over the byte ceiling keeps head + tail, drops the middle, flags truncated', () => {
90
+ const many = [];
91
+ for (let n = 0; n < 200; n++) many.push({ type: 'user', message: { content: `turn-${n} ${'x '.repeat(500)}` } });
92
+ const r = buildSessionTranscript(many, { maxBytes: 50 * 1024 });
93
+ assert.equal(r.truncated, true);
94
+ assert.equal(r.turnCount, 200);
95
+ assert.ok(r.turns.length < 200);
96
+ assert.match(r.turns[0].text, /^turn-0 /);
97
+ assert.match(r.turns[r.turns.length - 1].text, /^turn-199 /);
98
+ assert.ok(r.byteSize <= 50 * 1024);
99
+ });
100
+
101
+ await t('a credential straddling the field cap is redacted whole, not sliced', () => {
102
+ const key = ['sk', 'ant', 'api03', 'ZYXWVUTSRQPONMLKJIHGFEDCBA'].join('-');
103
+ const text = 'a '.repeat((MAX_FIELD_CHARS - 10) / 2) + key + ' tail';
104
+ const out = buildSessionTranscript([{ type: 'user', message: { content: text } }]).turns[0].text;
105
+ assert.ok(!out.includes(key.slice(0, 14)), 'a fragment of the credential survived the cap');
106
+ });
107
+
108
+ await t('over the turn-count ceiling is trimmed to it and flagged', () => {
109
+ const many = Array.from({ length: 50 }, (_, n) => ({ type: 'user', message: { content: `t${n}` } }));
110
+ const r = buildSessionTranscript(many, { maxTurns: 10 });
111
+ assert.equal(r.truncated, true);
112
+ assert.ok(r.turns.length <= 10);
113
+ assert.equal(r.turns[0].text, 't0');
114
+ assert.equal(r.turns[r.turns.length - 1].text, 't49');
115
+ });
116
+
117
+ await t('a turn with many max-size tool calls is shrunk under the route per-turn cap', () => {
118
+ const big = 'word '.repeat(MAX_FIELD_CHARS);
119
+ const content = Array.from({ length: 20 }, () => ({ type: 'tool_use', name: 'Bash', input: { command: big } }));
120
+ const r = buildSessionTranscript([{ type: 'assistant', message: { id: 'x', content } }]);
121
+ const bytes = Buffer.byteLength(JSON.stringify(r.turns[0]), 'utf8');
122
+ assert.ok(bytes <= 256 * 1024, `turn is ${bytes} bytes`);
123
+ assert.equal(r.turns[0].tool_calls.length, 20, 'every call is still recorded');
124
+ });
125
+
126
+ await t('byteSize is the number the route measures (turns + commas + brackets)', () => {
127
+ const r = buildSessionTranscript(entries);
128
+ const real = Buffer.byteLength(JSON.stringify(r.turns), 'utf8');
129
+ assert.ok(r.byteSize >= real && r.byteSize <= real + 1, 'same conservative count the route uses');
130
+ });
131
+
132
+ await t('an under-ceiling transcript is not marked truncated', () => {
133
+ assert.equal(buildSessionTranscript(entries).truncated, false);
134
+ });
135
+
136
+ // ---- Layer 2: the routes ----
137
+ console.log('\ntranscript routes (real router, fake auth, stubbed db):');
138
+ const orig = {
139
+ requireBuilder: auth.requireBuilder, insertAuditLog: auth.insertAuditLog,
140
+ upsert: db.upsertSessionTranscript, get: db.getSessionTranscript,
141
+ };
142
+ let caller = { id: 7, rank: 'xenos' };
143
+ const calls = { upserts: [], audits: [] };
144
+ let auditShouldFail = false;
145
+ let stored = null; // what getSessionTranscript returns
146
+ auth.requireBuilder = (req, _res, next) => { req.builder = caller; next(); };
147
+ auth.insertAuditLog = async (row) => { if (auditShouldFail) throw new Error('audit down'); calls.audits.push(row); };
148
+ db.upsertSessionTranscript = async (args) => { calls.upserts.push(args); return args.sessionId === 'owned' ? { session_id: args.sessionId, turn_count: args.turnCount } : null; };
149
+ db.getSessionTranscript = async () => stored;
150
+
151
+ const app = express();
152
+ app.use(attachFail);
153
+ app.use('/api/gds', buildSessionsRouter());
154
+ const server = http.createServer(app);
155
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
156
+ const base = `http://127.0.0.1:${server.address().port}/api/gds`;
157
+ const send = (method, path, body) => fetch(base + path, {
158
+ method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined,
159
+ });
160
+
161
+ await t('PUT stores for the CALLER (owner forced), 201', async () => {
162
+ calls.upserts = [];
163
+ const r = await send('PUT', '/sessions/owned/transcript', { turns: [{ i: 0, role: 'user', text: 'hi' }] });
164
+ assert.equal(r.status, 201, await r.clone().text());
165
+ assert.equal(calls.upserts[0].builderId, 7, 'owner is the caller');
166
+ assert.equal(calls.upserts[0].sessionId, 'owned');
167
+ });
168
+ await t('PUT cannot smuggle a builder_id (unknown field → 400, no write)', async () => {
169
+ calls.upserts = [];
170
+ const r = await send('PUT', '/sessions/owned/transcript', { turns: [], builder_id: 999 });
171
+ assert.equal(r.status, 400);
172
+ assert.equal(calls.upserts.length, 0);
173
+ });
174
+ await t('PUT for a session the caller has no digest for → 404', async () => {
175
+ const r = await send('PUT', '/sessions/someone-elses/transcript', { turns: [{ i: 0 }] });
176
+ assert.equal(r.status, 404);
177
+ });
178
+ await t('PUT stores the turns serialized ONCE and the byte count it measured', async () => {
179
+ calls.upserts = [];
180
+ await send('PUT', '/sessions/owned/transcript', { turns: [{ i: 0, text: 'a' }, { i: 1, text: 'b' }] });
181
+ assert.equal(calls.upserts[0].turnsJson, '[{"i":0,"text":"a"},{"i":1,"text":"b"}]');
182
+ assert.equal(calls.upserts[0].turnCount, 2);
183
+ });
184
+ await t('PUT: one over-cap turn → 413, no write', async () => {
185
+ calls.upserts = [];
186
+ const r = await send('PUT', '/sessions/owned/transcript', { turns: [{ i: 0, text: 'x'.repeat(300 * 1024) }] });
187
+ assert.equal(r.status, 413);
188
+ assert.equal(calls.upserts.length, 0);
189
+ });
190
+ await t('PUT: a total over the byte cap → 413, no write', async () => {
191
+ calls.upserts = [];
192
+ const turn = { text: 'y'.repeat(200 * 1024) };
193
+ const r = await send('PUT', '/sessions/owned/transcript', { turns: Array.from({ length: 70 }, () => turn) });
194
+ assert.equal(r.status, 413);
195
+ assert.equal(calls.upserts.length, 0);
196
+ });
197
+ await t('PUT with a non-object turn → 400, no write', async () => {
198
+ calls.upserts = [];
199
+ const r = await send('PUT', '/sessions/owned/transcript', { turns: ['nope'] });
200
+ assert.equal(r.status, 400);
201
+ assert.equal(calls.upserts.length, 0);
202
+ });
203
+ await t('PUT with a missing turns array → 400', async () => {
204
+ assert.equal((await send('PUT', '/sessions/owned/transcript', {})).status, 400);
205
+ });
206
+ await t('PUT with a path-like session id → 400', async () => {
207
+ assert.equal((await send('PUT', '/sessions/a.b/transcript', { turns: [] })).status, 400);
208
+ });
209
+
210
+ stored = { id: 5, session_id: 's', builder_id: 7, turns: [{ i: 0, role: 'user', text: 'secret-ish prose' }] };
211
+ await t('GET: the owner reads their own, with no audit row', async () => {
212
+ calls.audits = [];
213
+ const r = await send('GET', '/sessions/5/transcript');
214
+ assert.equal(r.status, 200);
215
+ assert.equal((await r.json()).transcript.turns[0].text, 'secret-ish prose');
216
+ assert.equal(calls.audits.length, 0);
217
+ });
218
+ await t('GET: a non-owner non-Archon gets 404, not 403 (no existence oracle)', async () => {
219
+ caller = { id: 8, rank: 'metic' };
220
+ assert.equal((await send('GET', '/sessions/5/transcript')).status, 404);
221
+ });
222
+ await t('GET: an Archon reads cross-builder AFTER an audit row is written', async () => {
223
+ caller = { id: 1, rank: 'archon' }; calls.audits = [];
224
+ const r = await send('GET', '/sessions/5/transcript');
225
+ assert.equal(r.status, 200);
226
+ assert.equal(calls.audits.length, 1);
227
+ assert.equal(calls.audits[0].requestBodyRedacted.action, 'bfg_session_read');
228
+ assert.equal(calls.audits[0].requestBodyRedacted.target, 'transcript:5');
229
+ });
230
+ await t('GET: a failed audit write WITHHOLDS the transcript (fail-closed)', async () => {
231
+ auditShouldFail = true;
232
+ const r = await send('GET', '/sessions/5/transcript');
233
+ const text = await r.text();
234
+ assert.equal(r.status, 500);
235
+ assert.ok(!text.includes('secret-ish prose'), 'content must not be served without an audit row');
236
+ auditShouldFail = false;
237
+ });
238
+ await t('GET: an absent transcript → 404', async () => {
239
+ stored = null;
240
+ assert.equal((await send('GET', '/sessions/5/transcript')).status, 404);
241
+ });
242
+
243
+ // ---- Layer 3: the real SQL, captured through a patched pool.query ----
244
+ console.log('\nsession_transcripts SQL (captured pool.query):');
245
+ const { pool } = require('../src/module-api');
246
+ const origQuery = pool.query;
247
+ const captured = [];
248
+ pool.query = async (sql, params) => { captured.push({ sql, params }); return { rows: [] }; };
249
+ db.upsertSessionTranscript = orig.upsert; db.getSessionTranscript = orig.get;
250
+ await t('upsert is created only FROM the caller own session_records row', async () => {
251
+ captured.length = 0;
252
+ await db.upsertSessionTranscript({ sessionId: 's1', builderId: 7, turnsJson: '[]', turnCount: 0 });
253
+ const { sql, params } = captured[0];
254
+ assert.ok(sql.replace(/\s+/g, ' ').includes('FROM session_records sr WHERE sr.session_id = $1 AND sr.builder_id = $2'));
255
+ assert.ok(sql.includes("WHERE session_transcripts.builder_id = EXCLUDED.builder_id"));
256
+ assert.equal(params[0], 's1'); assert.equal(params[1], 7);
257
+ });
258
+ await t('read pages in SQL and clamps offset/limit', async () => {
259
+ captured.length = 0;
260
+ await db.getSessionTranscript(5, { offset: -9, limit: 999999 });
261
+ assert.ok(captured[0].sql.includes("jsonb_array_elements(st.turns) WITH ORDINALITY"));
262
+ assert.deepEqual(captured[0].params, [5, 0, 2000]);
263
+ captured.length = 0;
264
+ await db.getSessionTranscript(5, {});
265
+ assert.deepEqual(captured[0].params, [5, 0, 500]);
266
+ });
267
+ pool.query = origQuery;
268
+
269
+ Object.assign(auth, { requireBuilder: orig.requireBuilder, insertAuditLog: orig.insertAuditLog });
270
+ db.upsertSessionTranscript = orig.upsert; db.getSessionTranscript = orig.get;
271
+ server.close();
272
+ console.log(`\n${passed} passed, ${failed} failed`);
273
+ process.exit(failed ? 1 : 0);
@@ -34,7 +34,7 @@ test('the platform floors are a pinned list — a floor cannot vanish quietly',
34
34
  });
35
35
 
36
36
  test('the shipped states files validate and sit beside the page they name', () => {
37
- for (const rel of ['modules/public-landing/public/projects.states.json', 'modules/public-landing/public/projects-empty.states.json', 'modules/public-landing/public/index.states.json', 'modules/public-landing/public/account.states.json', 'modules/public-landing/public/account-private.states.json', 'modules/hall-ui/public/ranks.states.json']) {
37
+ for (const rel of ['modules/public-landing/public/projects.states.json', 'modules/public-landing/public/projects-empty.states.json', 'modules/public-landing/public/projects-dns-pending.states.json','modules/public-landing/public/index.states.json', 'modules/public-landing/public/account.states.json', 'modules/public-landing/public/account-private.states.json', 'modules/hall-ui/public/ranks.states.json']) {
38
38
  const json = readJson(rel);
39
39
  const v = lib.validateStatesFile(json);
40
40
  assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
@@ -0,0 +1,89 @@
1
+ // tests/upgrade_commit_identity.mjs
2
+ //
3
+ // task 1004514: a landed core move left its pin STAGED but uncommitted whenever the project's
4
+ // git named nobody (no user.name/user.email in the repo or for the account the move runs as).
5
+ // `git commit` refused, persistPin reported "could not commit", the move still landed, and the
6
+ // NEXT move read those leftovers as a dirty tree. Seen live on cloudbongos.com, 2026-10-01.
7
+ //
8
+ // These run persistPin against a REAL repo with every identity source switched off (global
9
+ // config pointed at /dev/null, system config disabled, invented identities refused), so the refusal is git's own. The first
10
+ // test proves the environment really does refuse; without it the fix test could pass vacuously.
11
+ //
12
+ // Run: node --test tests/upgrade_commit_identity.mjs
13
+
14
+ import { test, before, after } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { createRequire } from 'node:module';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { persistPin } = require('../scripts/gds/upgrade.js');
24
+ const { commitIdentityEnv } = require('../scripts/gds/upgrade-commit-identity.js');
25
+
26
+ // user.useConfigOnly: git on a Mac invents an identity from the hostname, where the box's git
27
+ // refuses; this makes every machine refuse the way the box did.
28
+ const ISOLATE = { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1',
29
+ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'user.useConfigOnly', GIT_CONFIG_VALUE_0: 'true' };
30
+ const IDENTITY_VARS = ['GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL', 'EMAIL'];
31
+ let saved;
32
+ const dirs = [];
33
+
34
+ before(() => {
35
+ saved = Object.fromEntries([...Object.keys(ISOLATE), ...IDENTITY_VARS].map((k) => [k, process.env[k]]));
36
+ Object.assign(process.env, ISOLATE);
37
+ for (const k of IDENTITY_VARS) delete process.env[k];
38
+ });
39
+ after(() => {
40
+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
41
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
42
+ });
43
+
44
+ const git = (cwd, ...args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
45
+
46
+ // A repo whose first commit carries a one-off identity, then names nobody — the live shape.
47
+ function repoWithPinChange({ identity = null } = {}) {
48
+ const dir = mkdtempSync(join(tmpdir(), 'pin-identity-'));
49
+ dirs.push(dir);
50
+ git(dir, 'init', '-q', '-b', 'main');
51
+ if (identity) { git(dir, 'config', 'user.name', identity.name); git(dir, 'config', 'user.email', identity.email); }
52
+ writeFileSync(join(dir, 'package.json'), '{"dependencies":{"@bongos/core":"1.20.77"}}\n');
53
+ writeFileSync(join(dir, 'package-lock.json'), '{}\n');
54
+ git(dir, '-c', 'user.name=scaffold', '-c', 'user.email=scaffold@example.invalid', 'add', '-A');
55
+ git(dir, '-c', 'user.name=scaffold', '-c', 'user.email=scaffold@example.invalid', 'commit', '-qm', 'scaffold');
56
+ writeFileSync(join(dir, 'package.json'), '{"dependencies":{"@bongos/core":"1.20.78"}}\n');
57
+ return dir;
58
+ }
59
+
60
+ const quiet = () => {};
61
+ const pin = (dir) => persistPin({ instanceDir: dir, fromVersion: '1.20.77', toVersion: '1.20.78', commitPin: true, push: false }, spawnSync, quiet, quiet);
62
+
63
+ test('the environment really names nobody: a plain commit is refused', () => {
64
+ const dir = repoWithPinChange();
65
+ git(dir, 'add', 'package.json');
66
+ const r = git(dir, 'commit', '-m', 'probe');
67
+ assert.notEqual(r.status, 0, 'git must refuse here, or the next test proves nothing');
68
+ });
69
+
70
+ test('a repo that names nobody still gets its pin committed, signed as the provisioner', () => {
71
+ const dir = repoWithPinChange();
72
+ const r = pin(dir);
73
+ assert.equal(r.committed, true, `persistPin: ${JSON.stringify(r)}`);
74
+ assert.equal(git(dir, 'status', '--porcelain').stdout, '', 'nothing staged or left behind for the next move');
75
+ const who = git(dir, 'log', '-1', '--format=%an <%ae>|%s').stdout.trim();
76
+ assert.match(who, /^Cloud Bongos <bot@[^>]+>\|pin @bongos\/core 1\.20\.78 \(was 1\.20\.77\)$/);
77
+ });
78
+
79
+ test('a repo that names someone keeps signing as them', () => {
80
+ const dir = repoWithPinChange({ identity: { name: 'Owner Person', email: 'owner@example.invalid' } });
81
+ assert.equal(commitIdentityEnv(dir, spawnSync), null, 'no fallback when git already has an answer');
82
+ assert.equal(pin(dir).committed, true);
83
+ assert.equal(git(dir, 'log', '-1', '--format=%an <%ae>').stdout.trim(), 'Owner Person <owner@example.invalid>');
84
+ });
85
+
86
+ test('an identity the caller already put in the env is not replaced', () => {
87
+ const dir = repoWithPinChange();
88
+ assert.equal(commitIdentityEnv(dir, spawnSync, { ...process.env, GIT_AUTHOR_EMAIL: 'ci@example.invalid' }), null);
89
+ });
@@ -111,14 +111,21 @@ let created = null;
111
111
  let started = null;
112
112
  provisioning.getInstanceBySlug = async () => null;
113
113
  provisioning.getInstanceByDomain = async () => null;
114
+ // The order the create does things in (task 1004506): the birth stamp and the demo
115
+ // marker must both be on the row BEFORE the provision intent exists, so the runner's
116
+ // web.env carries them. 'old-one' stands for a project that already existed.
117
+ const order = [];
118
+ const birth = require('../modules/provisioning/birth.js');
119
+ birth.stampBirth = async (_db, id) => { order.push(`birth:${id}`); };
114
120
  provisioning.createInstance = async (_db, args) => {
115
121
  created = args;
116
- return { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'byo-host', detail: args.detail || {} }, created: true };
122
+ const fresh = args.slug !== 'old-one';
123
+ return { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: fresh ? 'requested' : 'active', hosting_shape: 'byo-host', detail: args.detail || {} }, created: fresh };
117
124
  };
118
- provisioning.enqueueIntent = async (_db, _id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
125
+ provisioning.enqueueIntent = async (_db, _id, action) => { order.push(`enqueue:${action}`); return { intent: { id: 9, action, state: 'pending' }, created: true }; };
119
126
  provisioning.getOpenIntent = async () => null;
120
127
  provisioning.recordEvent = async () => {};
121
- demo.startDemoProject = async (_db, _p, id, d) => { started = { id, d }; return null; };
128
+ demo.startDemoProject = async (_db, _p, id, d) => { started = { id, d }; order.push(`demo:${id}`); return null; };
122
129
  api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
123
130
  let stored = null;
124
131
  provisioning.getInstanceById = async (_db, id) => (id === 41 ? { id: 41, owner_builder_id: 7, demo: stored } : null);
@@ -158,6 +165,25 @@ test('the create files a normal project marked demo, with the demo\'s picked ans
158
165
  assert.equal((await post('/provisioning/instances', { slug: 'bad-demo', hosting_shape: 'byo-host', demo: { people: 99, hours_each: 4 } })).status, 400);
159
166
  });
160
167
 
168
+ test('a new project is stamped born, once, before its provision intent; an existing one never is (task 1004506)', async () => {
169
+ order.length = 0;
170
+ assert.equal((await post('/provisioning/instances', { slug: 'born-demo', hosting_shape: 'byo-host', demo: { people: 2, hours_each: 3 } })).status, 201);
171
+ assert.deepEqual(order, ['birth:41', 'demo:41', 'enqueue:provision'], 'the marker and the demo box are on the row before the runner can read it');
172
+ order.length = 0;
173
+ assert.equal((await post('/provisioning/instances', { slug: 'born-real', hosting_shape: 'byo-host' })).status, 201);
174
+ assert.deepEqual(order, ['birth:41', 'enqueue:provision'], 'a real project is born too, with no demo box');
175
+ order.length = 0;
176
+ const again = await post('/provisioning/instances', { slug: 'old-one', hosting_shape: 'byo-host' });
177
+ assert.ok(again.status < 300, JSON.stringify(again.body));
178
+ assert.ok(!order.some((o) => o.startsWith('birth:') || o.startsWith('demo:')), 'a re-request of a project that already exists is never born (spec D8)');
179
+ const keep = birth.stampBirth;
180
+ birth.stampBirth = async () => { throw new Error('db hiccup'); };
181
+ try {
182
+ assert.equal((await post('/provisioning/instances', { slug: 'unlucky', hosting_shape: 'byo-host' })).status, 201,
183
+ 'a missed marker costs the project its genesis, never its creation');
184
+ } finally { birth.stampBirth = keep; }
185
+ });
186
+
161
187
  test('keep your planet? over the route: the owner answers once', async () => {
162
188
  stored = demo.demoRecord({ people: 3, hours_each: 4 }, new Date(Date.now() - 5 * 3600000));
163
189
  const r = await post('/provisioning/instances/41/demo', { outcome: 'kept' });