@bongos/core 1.20.83 → 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 (73) hide show
  1. package/.bongos-core.json +151 -61
  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 +6 -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/workflow-dispatch.js +21 -11
  25. package/modules/npm-release/release.js +10 -1
  26. package/modules/onboarding/founding-birth.js +116 -0
  27. package/modules/onboarding/genesis-home.js +72 -5
  28. package/modules/onboarding/port.js +9 -2
  29. package/modules/onboarding/routes/onboarding.js +8 -1
  30. package/modules/provisioning/birth.js +64 -0
  31. package/modules/provisioning/dns-resolves.js +54 -0
  32. package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
  33. package/modules/provisioning/pollers/liveness-sweep.js +5 -0
  34. package/modules/provisioning/provisioning.js +7 -6
  35. package/modules/provisioning/routes/provisioning.js +6 -2
  36. package/modules/provisioning/tests/liveness.mjs +48 -0
  37. package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
  38. package/modules/public-landing/public/projects.html +40 -15
  39. package/modules/sessions/db.js +58 -0
  40. package/modules/sessions/routes/sessions.js +97 -0
  41. package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
  42. package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
  43. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
  44. package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
  45. package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
  46. package/package-lock.json +2 -2
  47. package/package.json +1 -1
  48. package/release-notes.json +35 -0
  49. package/scripts/gds/provision.js +77 -53
  50. package/scripts/gds/session-digest.js +11 -3
  51. package/scripts/gds/session-transcript-build.js +155 -0
  52. package/scripts/gds/session-transcript-upload.js +29 -0
  53. package/scripts/gds/ship-session-upload.js +6 -1
  54. package/scripts/gds/upgrade-commit-identity.js +32 -0
  55. package/scripts/gds/upgrade.js +1 -1
  56. package/src/bongos/routes.js +3 -0
  57. package/src/branding.js +9 -0
  58. package/src/module-api.js +1 -1
  59. package/tests/founding_birth.mjs +200 -0
  60. package/tests/founding_mode.mjs +3 -1
  61. package/tests/genesis_home.mjs +71 -2
  62. package/tests/hub_map_orb_states.mjs +5 -1
  63. package/tests/npm_release_release.mjs +37 -2
  64. package/tests/projects_hub.mjs +5 -3
  65. package/tests/projects_hub_dns_ready.mjs +153 -0
  66. package/tests/projects_hub_pre_uat.mjs +9 -5
  67. package/tests/provision_dns_first.mjs +171 -0
  68. package/tests/provision_settings_apply.mjs +6 -1
  69. package/tests/provisioning_settings_env.mjs +49 -0
  70. package/tests/session_transcript.mjs +273 -0
  71. package/tests/ui_design_kit.mjs +1 -1
  72. package/tests/upgrade_commit_identity.mjs +89 -0
  73. package/tests/wizard_demo.mjs +29 -3
@@ -224,6 +224,11 @@ async function sweepOnce(deps = {}) {
224
224
  }
225
225
  const probe = await probeHealthz(url, { timeoutMs: deps.timeoutMs, fetchImpl: deps.fetchImpl, pin });
226
226
  const rec = await provisioning.recordLivenessProbe(db, row.id, probe.up);
227
+ // The gate's lookup IS the platform's own lookup (task 1004505): an address it
228
+ // resolved, or one that answered, may now be linked on the hub. First sighting only.
229
+ if (row.domain && !row.dns_resolved_at && (pin || probe.up)) {
230
+ await provisioning.recordDnsResolved(db, row.id).catch((e) => log(`[liveness] dns record failed (non-blocking): ${e && e.message ? e.message : e}`));
231
+ }
227
232
  return { row, probed: true, prevUp: rec ? rec.prev_up : null, ...probe };
228
233
  }));
229
234
 
@@ -24,7 +24,8 @@ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundle
24
24
  // projection both have to read them.
25
25
  const { PLANET_PHYSICS_QUESTIONS, canonicalAnswer } = require('./planet-physics');
26
26
  const { softLimitsFor } = require('./soft-limits'); const { demoView } = require('./demo'); // the demo marker's owner view (task 1004420)
27
- const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look');
27
+ const { dnsResolves, recordDnsResolved, lookupAnswers } = require('./dns-resolves'); // has the platform's lookup seen the address answer (task 1004505)
28
+ const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look'); const { BIRTH_COMPANION_ENV } = require('./birth'); // born into genesis (task 1004506)
28
29
 
29
30
  // ---------------------------------------------------------------------------
30
31
  // Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
@@ -466,7 +467,7 @@ function publicInstance(row) {
466
467
  last_verified_up: row.last_verified_up == null ? null : !!row.last_verified_up,
467
468
  // The outstanding manual-DNS step, if the runner had no hook to point the
468
469
  // A record (task 1002720 / F16). null = nothing outstanding.
469
- dns_note: row.dns_note || null,
470
+ dns_note: row.dns_note || null, dns_resolves: dnsResolves(row), // the platform's lookup saw it answer; the hub links it only then (task 1004505)
470
471
  cost_estimate_usd: Number(row.cost_estimate_usd || 0),
471
472
  error_note: row.error_note || null,
472
473
  created_at: row.created_at,
@@ -518,7 +519,7 @@ function settingsEnvVars(row, prefix) {
518
519
  // cutoff and every unresolved review counts, which is the right answer for a
519
520
  // project that has been strict since it stood up.
520
521
  const SETTINGS_COMPANION_ENV = Object.freeze({
521
- ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, // + accent, logo (look.js)
522
+ ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, ...BIRTH_COMPANION_ENV, // + accent, logo (look.js); birth, demo box (birth.js)
522
523
  });
523
524
 
524
525
  // The web.env block the runner writes at STANDUP (task 1003139): a comment that
@@ -974,7 +975,7 @@ async function updateInstanceDomain(db, id, domain, releasing = null) {
974
975
  ELSE array_append(released_domains, $3)
975
976
  END,
976
977
  last_verified_at = CASE WHEN domain IS DISTINCT FROM $2 THEN NULL ELSE last_verified_at END,
977
- last_verified_up = CASE WHEN domain IS DISTINCT FROM $2 THEN NULL ELSE last_verified_up END,
978
+ last_verified_up = CASE WHEN domain IS DISTINCT FROM $2 THEN NULL ELSE last_verified_up END, dns_resolved_at = CASE WHEN domain IS DISTINCT FROM $2 THEN NULL ELSE dns_resolved_at END,
978
979
  updated_at = now()
979
980
  WHERE id = $1 RETURNING *`,
980
981
  [id, domain, host, MAX_RELEASED_DOMAINS]
@@ -1004,7 +1005,7 @@ async function clearReleasedDomains(db, id) {
1004
1005
  // against it would only ever record a false "down". `limit` is a runaway guard.
1005
1006
  async function listActiveInstancesForLiveness(db, { limit = 500 } = {}) {
1006
1007
  const { rows } = await db.query(
1007
- `SELECT id, slug, domain, port, settings FROM provisioning_instances
1008
+ `SELECT id, slug, domain, port, settings, dns_resolved_at FROM provisioning_instances
1008
1009
  WHERE status = 'active'
1009
1010
  ORDER BY id
1010
1011
  LIMIT $1`,
@@ -1486,7 +1487,7 @@ module.exports = {
1486
1487
  isProvisionedHostname,
1487
1488
  getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
1488
1489
  // data access — verification overlay (task 1002696)
1489
- listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote, ownsErrorNote,
1490
+ listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote, ownsErrorNote, dnsResolves, recordDnsResolved, lookupAnswers,
1490
1491
  // data access — the owed-push marker (task 1003524)
1491
1492
  recordSettingsPushOwed,
1492
1493
  createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings, updateInstanceDetail,
@@ -64,7 +64,7 @@ const catalogBridge = require('../catalog-bridge');
64
64
  const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
65
65
  const { callbackPage } = require('./callback-page');
66
66
  // the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
67
- const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo');
67
+ const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo'); const birth = require('../birth');
68
68
  const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
69
69
  const { settingsPush, failPushConflict } = require('../settings-push'); // a settings save's push + receipt, shared with routes/look.js (task 1004421)
70
70
  // the kernel ports this module provides (moved out at the size ratchet, task 1003578)
@@ -471,6 +471,11 @@ module.exports = function provisioningRoutes() {
471
471
  if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
472
472
  return res.fail('slug_taken', { status: 409, message: 'That instance slug is already in use.' });
473
473
  }
474
+ // Born into genesis (task 1004506, ../birth.js): only the row this request MADE, and before the
475
+ // provision intent exists, so the runner's web.env already carries the marker and the demo's box.
476
+ // Best-effort: a project that misses its marker opens as an ordinary hall; the create still stands.
477
+ if (created) await birth.stampBirth(pool, instance.id).catch((err) => log.error('[provisioning] birth marker not stamped', err));
478
+ const demoRow = created && demoAsk.demo ? await demo.startDemoProject(pool, provisioning, instance.id, demoAsk.demo) : null;
474
479
  // Enqueue the provision intent (idempotent: an already-open intent is returned)
475
480
  // — but ONLY when the instance actually needs standing up. Blindly enqueueing on
476
481
  // a re-request of an ACTIVE instance left a stale open provision intent that then
@@ -493,7 +498,6 @@ module.exports = function provisioningRoutes() {
493
498
  // description exists — the publish gate alone decides that — but the row has to be
494
499
  // there for the gate to have anything to decide about, and for the wizard's invite
495
500
  // step to have a project to invite into while standup is still running.
496
- const demoRow = created && demoAsk.demo ? await demo.startDemoProject(pool, provisioning, instance.id, demoAsk.demo) : null;
497
501
  await catalogBridge.publishInstanceToCatalog(pool, demoRow || instance).catch(() => {});
498
502
  // A re-request cannot change an existing instance's domain — SAY so instead of
499
503
  // silently dropping it (task 1002692); the domain route is the real path.
@@ -234,6 +234,54 @@ await ta('an unresolvable domain still probes and records an honest DOWN (the qa
234
234
  assert.deepEqual({ blocked: out.blocked, down: out.down }, { blocked: 0, down: 1 });
235
235
  assert.deepEqual(out.reasons, ['ghostly:ENOTFOUND']);
236
236
  assert.deepEqual(db.updates.map((u) => u.up), [false], 'DNS-less domain = honest down, not a silent skip');
237
+ assert.ok(!db.updates.some((u) => /dns_resolved_at/.test(u.sql)), 'an address that did not resolve is never marked as resolving');
238
+ });
239
+
240
+ console.log('\ndns_resolves (task 1004505 — the hub links an address only once the platform saw it answer):');
241
+
242
+ const isDnsWrite = (u) => /SET dns_resolved_at = now\(\)/.test(u.sql);
243
+
244
+ await ta('a domain the gate RESOLVED is recorded as resolving — once, even when the probe itself is down', async () => {
245
+ const db = fakeDb([{ id: 9, slug: 'fresh', domain: 'fresh.example.com', port: null, dns_resolved_at: null }]);
246
+ await liveness.sweepOnce({
247
+ pool: db, log: () => {},
248
+ lookup: async () => [{ address: '93.184.216.34', family: 4 }],
249
+ fetchImpl: async () => { throw Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }); },
250
+ });
251
+ const writes = db.updates.filter(isDnsWrite);
252
+ assert.equal(writes.length, 1, 'the lookup answered, so the address may be linked');
253
+ assert.equal(writes[0].id, 9);
254
+ assert.match(writes[0].sql, /dns_resolved_at IS NULL/, 'only the first sighting writes');
255
+ assert.ok(!/status|updated_at/.test(writes[0].sql), 'an overlay: no status, no updated_at');
256
+ });
257
+
258
+ await ta('an address already marked is not written again', async () => {
259
+ const db = fakeDb([{ id: 10, slug: 'old', domain: 'old.example.com', port: null, dns_resolved_at: '2026-10-01T10:00:00Z' }]);
260
+ await liveness.sweepOnce({
261
+ pool: db, log: () => {},
262
+ lookup: async () => [{ address: '93.184.216.34', family: 4 }],
263
+ fetchImpl: async () => new Response('ok', { status: 200 }),
264
+ });
265
+ assert.equal(db.updates.filter(isDnsWrite).length, 0);
266
+ });
267
+
268
+ t('publicInstance projects dns_resolves: false without a sighting or a domain, true with both', () => {
269
+ assert.equal(provisioning.publicInstance({ id: 1, slug: 'a', status: 'active', domain: 'a.example.com' }).dns_resolves, false);
270
+ assert.equal(provisioning.publicInstance({ id: 1, slug: 'a', status: 'active', domain: 'a.example.com', dns_resolved_at: '2026-10-01T10:00:00Z' }).dns_resolves, true);
271
+ assert.equal(provisioning.publicInstance({ id: 1, slug: 'a', status: 'active', domain: null, dns_resolved_at: '2026-10-01T10:00:00Z' }).dns_resolves, false);
272
+ });
273
+
274
+ await ta('lookupAnswers: an address is an answer; a miss, a throw or a hang is not', async () => {
275
+ assert.equal(await provisioning.lookupAnswers('a.example.com', { lookup: async () => [{ address: '1.2.3.4', family: 4 }] }), true);
276
+ assert.equal(await provisioning.lookupAnswers('a.example.com', { lookup: async () => [] }), false);
277
+ assert.equal(await provisioning.lookupAnswers('a.example.com', { lookup: async () => { throw new Error('ENOTFOUND'); } }), false);
278
+ assert.equal(await provisioning.lookupAnswers('a.example.com', { lookup: () => new Promise(() => {}), timeoutMs: 20 }), false);
279
+ assert.equal(await provisioning.lookupAnswers('', {}), false);
280
+ });
281
+
282
+ t('a changed domain clears the sighting with the rest of the overlay', () => {
283
+ const src = provisioning.updateInstanceDomain.toString();
284
+ assert.match(src, /dns_resolved_at = CASE WHEN domain IS DISTINCT FROM \$2 THEN NULL ELSE dns_resolved_at END/);
237
285
  });
238
286
 
239
287
  await ta('the gate hands back the address it vetted, so the probe can pin to it (no re-resolution)', async () => {
@@ -0,0 +1,85 @@
1
+ {
2
+ "_": "Projects whose address does not resolve yet (task 1004505): the same page, the same stub, the instance list aliased to two projects the platform's own lookup has not seen answer. The mine rows and the manage page say the address is being set up and carry no link to it — a click before the name exists is cached as not found for up to half an hour. Rendered by name: node modules/ui-design/kit/render.js --states public-landing/projects-dns-pending.states.json",
3
+ "page": "/projects",
4
+ "surface": "public-landing",
5
+ "modes": [
6
+ "dark"
7
+ ],
8
+ "modeQuery": false,
9
+ "stub": {
10
+ "fixtures": {
11
+ "provisioning-instances": "provisioning-instances-dns-pending"
12
+ }
13
+ },
14
+ "ignoreRequests": [
15
+ "/api/(gds|bongos)/me$",
16
+ "/my-projects",
17
+ "/provisioning/onboard-plan",
18
+ "/env-manifest$"
19
+ ],
20
+ "ignoreConsole": [
21
+ "/api/(gds|bongos)/me",
22
+ "\\b401\\b",
23
+ "\\b404\\b",
24
+ "Unauthorized"
25
+ ],
26
+ "ignoreControls": [
27
+ ".skip",
28
+ ".vh"
29
+ ],
30
+ "states": {
31
+ "in-mine-dns-pending": {
32
+ "auth": true,
33
+ "url": "/projects?view=mine",
34
+ "expect": {
35
+ "visible": [
36
+ "#projectsList .proj .addr"
37
+ ],
38
+ "count": [
39
+ [
40
+ "#projectsList .proj",
41
+ 2
42
+ ],
43
+ [
44
+ "#projectsList .proj .actions a",
45
+ 2
46
+ ],
47
+ [
48
+ "#projectsList a[href^=\"https://\"]",
49
+ 0
50
+ ],
51
+ [
52
+ "#projectsList a.worldOrb",
53
+ 0
54
+ ]
55
+ ]
56
+ }
57
+ },
58
+ "in-manage-dns-pending": {
59
+ "auth": true,
60
+ "url": "/projects?view=manage&id=106",
61
+ "expect": {
62
+ "visible": [
63
+ "#mMain",
64
+ "#mAddr"
65
+ ],
66
+ "count": [
67
+ [
68
+ "#mAddr a",
69
+ 0
70
+ ],
71
+ [
72
+ "#mActs a[href^=\"https://\"]",
73
+ 0
74
+ ]
75
+ ],
76
+ "text": [
77
+ [
78
+ "#mName",
79
+ "beacon"
80
+ ]
81
+ ]
82
+ }
83
+ }
84
+ }
85
+ }
@@ -3193,8 +3193,8 @@ summary{min-height:24px;padding:3px 0;}
3193
3193
  '<div class="runs"><span class="rLbl">runs</span>' + tagsHtml(p.tags) + '</div>' +
3194
3194
  /* at most two doors (task 1003356): the site, and the hall where the
3195
3195
  viewer builds there and the hall answers at a different address */
3196
- '<div class="rowDoors">' + (safeHttpUrl(p.origin)
3197
- ? '<a class="rowGo" href="' + esc(safeHttpUrl(p.origin)) + '" target="_blank" rel="noopener">view</a>'
3196
+ '<div class="rowDoors">' + (siteHref(p)
3197
+ ? '<a class="rowGo" href="' + esc(siteHref(p)) + '" target="_blank" rel="noopener">view</a>'
3198
3198
  : '') + hallGlyphHtml(p, rel) + '</div>' +
3199
3199
  '</li>';
3200
3200
  }).join(''));
@@ -3413,8 +3413,12 @@ summary{min-height:24px;padding:3px 0;}
3413
3413
  var HALL_GLYPH = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M2.8 7.4 10 3.5l7.2 3.9"/><path d="M5.2 8.8v5.6M10 8.8v5.6M14.8 8.8v5.6"/><path d="M3.4 16.3h13.2"/></svg>';
3414
3414
  function isMember(rel) { return rel === 'owner' || rel === 'builder'; }
3415
3415
  /* the row's hall glyph: a member's door, only where the hall is elsewhere */
3416
+ /* the site door for a map entry: one of the viewer's own projects gets none until
3417
+ its address answers (task 1004505, addressReady) */
3418
+ function siteHref(p) { return p._inst && !addressReady(p._inst) ? '' : safeHttpUrl(p.origin); }
3416
3419
  function hallGlyphHtml(p, rel) {
3417
3420
  var hall = isMember(rel) ? hallUrlOf(p) : '';
3421
+ if (p._inst && !addressReady(p._inst)) hall = ''; /* your own project's hall waits for its name too (task 1004505) */
3418
3422
  return hall
3419
3423
  ? '<a class="rowHall" href="' + esc(hall) + '" target="_blank" rel="noopener" aria-label="Builders\' hall" title="Builders\' hall">' + HALL_GLYPH + '</a>'
3420
3424
  : '';
@@ -3472,7 +3476,7 @@ summary{min-height:24px;padding:3px 0;}
3472
3476
  }
3473
3477
 
3474
3478
  function openCard(p, rel, returnTo) {
3475
- var href = p.isSelf ? '/' : safeHttpUrl(p.origin);
3479
+ var href = p.isSelf ? '/' : siteHref(p);
3476
3480
  /* the art: catalog text an Archon typed, scheme-checked server-side and
3477
3481
  again here before it becomes a src */
3478
3482
  cardArt(p.isSelf ? '' : (backdropPlate(p.card_backdrop) || safeHttpUrl(p.art_url)));
@@ -6937,7 +6941,7 @@ summary{min-height:24px;padding:3px 0;}
6937
6941
  return '<a href="' + (instanceId ? '?view=manage&id=' + encodeURIComponent(instanceId) : '?view=mine') +
6938
6942
  '">' + label + '</a>';
6939
6943
  }
6940
- var live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
6944
+ var live = { instStatus: null, addrReady: false, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
6941
6945
  var planPromises = {}; /* mode|domain → Promise<steps[]> */
6942
6946
  var lastStepsHtml = '';
6943
6947
  var lastProgHtml = ''; /* same change-only contract, for the progress strip */
@@ -7083,7 +7087,7 @@ summary{min-height:24px;padding:3px 0;}
7083
7087
  /* the door opens only when the project ANSWERS and sign-in is wired: a
7084
7088
  link to an address that does not resolve yet is the worst first
7085
7089
  impression a founder can get of their own project (task 1003232) */
7086
- if (live.instStatus !== 'active' || live.manifest !== 'done') {
7090
+ if (live.instStatus !== 'active' || !live.addrReady || live.manifest !== 'done') {
7087
7091
  return '<li class="slocked" data-onboard-step="first-sign-in"><b>First sign-in</b><p>Unlocks once your project is answering and sign-in above is wired. Then you’ll open <code style="font-family:var(--font-mono)">' + esc(domain) + '</code>, sign in with GitHub once, and be seated as the owner — the founding Archon.</p></li>';
7088
7092
  }
7089
7093
  return '<li data-onboard-step="first-sign-in"><b>First sign-in</b><p>Open <a href="https://' + esc(domain) + '" target="_blank" rel="noopener"><code style="font-family:var(--font-mono)">' + esc(domain) + '</code></a> and sign in with GitHub once. That seats you as the owner — the founding Archon.</p></li>';
@@ -7091,7 +7095,7 @@ summary{min-height:24px;padding:3px 0;}
7091
7095
 
7092
7096
  function renderStepEnterHall() {
7093
7097
  var domain = resolvedDomain();
7094
- if (domain && (live.instStatus !== 'active' || live.manifest !== 'done')) {
7098
+ if (domain && (live.instStatus !== 'active' || !live.addrReady || live.manifest !== 'done')) {
7095
7099
  return '<li class="slocked" data-onboard-step="enter-hall"><b>Enter your builders hall</b><p>Unlocks with the first sign-in — your project’s home will be <code style="font-family:var(--font-mono)">' + esc(domain) + '/builders</code>.</p></li>';
7096
7100
  }
7097
7101
  return domain
@@ -7383,6 +7387,14 @@ summary{min-height:24px;padding:3px 0;}
7383
7387
  projectPageLink('its own page') + '; the steps below that need one unlock then.', '', flow));
7384
7388
  return true;
7385
7389
  }
7390
+ /* task 1004505: no link until the platform's own lookup has seen the name
7391
+ answer. A founder's first click on a name that does not resolve yet is
7392
+ cached as "not found" by their resolver for up to half an hour. */
7393
+ if (!addressReady(inst)) {
7394
+ setProgress(progressMarkup(2, 2,
7395
+ 'The build finished — your address is being set up. This panel links it the moment it answers.', '', flow));
7396
+ return false;
7397
+ }
7386
7398
  if (inst.last_verified_up === false) {
7387
7399
  setProgress(progressMarkup(2, 2,
7388
7400
  'The build finished, but ' + link + ' isn’t answering right now — this panel keeps checking.', '', flow));
@@ -7471,8 +7483,8 @@ summary{min-height:24px;padding:3px 0;}
7471
7483
  /* the checklist reflects live state (task 1002694): a status transition —
7472
7484
  e.g. the scaffold landing — re-renders the steps (change-only, so the
7473
7485
  5s tick never clobbers an open dropdown or mid-flight button) */
7474
- if (inst && inst.status !== live.instStatus) {
7475
- live.instStatus = inst.status; renderSteps();
7486
+ if (inst && (inst.status !== live.instStatus || addressReady(inst) !== live.addrReady)) {
7487
+ live.instStatus = inst.status; live.addrReady = addressReady(inst); renderSteps();
7476
7488
  if (state.step === 13) paintRail(13); /* the rail finishes only when the project answers */
7477
7489
  }
7478
7490
  var terminal = renderProgress(inst);
@@ -7505,7 +7517,7 @@ summary{min-height:24px;padding:3px 0;}
7505
7517
  instanceId = null;
7506
7518
  watchPending = false; manifestPending = false; manifestState = null;
7507
7519
  manifestStartedAt = 0; manifestStale = false;
7508
- live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
7520
+ live = { instStatus: null, addrReady: false, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
7509
7521
  lastStepsHtml = '';
7510
7522
  lastProgHtml = '';
7511
7523
  try { sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
@@ -7797,6 +7809,11 @@ summary{min-height:24px;padding:3px 0;}
7797
7809
  so the page can never say "Answering at …" beside a chip that says "live?". */
7798
7810
  function verifiedAt(inst) { return inst.last_verified_at ? new Date(inst.last_verified_at).getTime() : 0; }
7799
7811
  function freshUp(inst) { var at = verifiedAt(inst); return !!(inst.last_verified_up && at && (Date.now() - at) <= FRESH_MS); }
7812
+ /* an address is LINKED only once the platform's own lookup has seen it answer (task
7813
+ 1004505): a click made before the name exists is cached as "not found" by the
7814
+ visitor's resolver for up to half an hour, so the provision status alone is never
7815
+ enough. dns_resolves is the server's sighting; an up probe is the same proof. */
7816
+ function addressReady(inst) { return !!(inst && inst.domain && (inst.dns_resolves || inst.last_verified_up)); }
7800
7817
  /* the landing moment (task 1003243): a standup or revive that VERIFIABLY came
7801
7818
  up, still recent enough to be news. Two gates, both already on the card —
7802
7819
  freshUp() so "live" stays ONE rule (a lit third stage beside a "live?" chip
@@ -7933,7 +7950,7 @@ summary{min-height:24px;padding:3px 0;}
7933
7950
  else if (inst.status === 'error' || (inst.status === 'active' && inst.last_verified_up === false)) cls += ' ashen';
7934
7951
  else if (inst.status === 'active' && !freshUp(inst)) cls += ' dim';
7935
7952
  var attrs = planetAttrs(pl) + ' style="--acc:' + pl.acc + ';--d:64px"';
7936
- if (inst.status === 'active' && inst.domain) {
7953
+ if (inst.status === 'active' && addressReady(inst)) {
7937
7954
  return '<a class="' + cls + '"' + attrs + ' href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener" title="Open ' + esc(inst.slug) + '" aria-label="Open ' + esc(inst.slug) + '"><span class="disc"></span></a>';
7938
7955
  }
7939
7956
  return '<span class="' + cls + '"' + attrs + ' aria-hidden="true"><span class="disc"></span></span>';
@@ -8085,6 +8102,7 @@ summary{min-height:24px;padding:3px 0;}
8085
8102
  has. Since task 1003355 the list row acts on nothing, so a row's sentence
8086
8103
  sends the owner to the project's own page and the manage page's says
8087
8104
  "below" — the ADR 0199 §8 rule that a refusal must name a reachable door. */
8105
+ var ADDRESS_SETTING_UP = 'Your address is being set up — it shows here, with a link, as soon as it answers.';
8088
8106
  function addressRow(inst, onManage) {
8089
8107
  /* state FIRST, then domain: a torn-down or snagged project still holds its
8090
8108
  domain (ADR 0181 keeps port + address), and the standup promise below
@@ -8099,6 +8117,13 @@ summary{min-height:24px;padding:3px 0;}
8099
8117
  /* "answering" is a VERIFIED claim (direction §3 / F16): only a FRESH up
8100
8118
  probe earns it — never the runner finishing its script, and never a
8101
8119
  probe the chip has already aged into "live?" */
8120
+ /* task 1004505: no link — and no name to type — until the platform's own
8121
+ lookup has seen it answer; the manual-DNS note still names the address,
8122
+ because pointing it is the owner's own step */
8123
+ if (!addressReady(inst)) {
8124
+ return inst.dns_note ? 'Its address is <code>' + esc(inst.domain) + '</code>. ' + esc(inst.dns_note)
8125
+ : ADDRESS_SETTING_UP;
8126
+ }
8102
8127
  var link = '<a href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">' + esc(inst.domain) + '</a>';
8103
8128
  if (freshUp(inst)) return 'Answering at ' + link;
8104
8129
  /* the runner had no DNS hook (F16): the CAUSE beats the bare symptom —
@@ -8119,7 +8144,7 @@ summary{min-height:24px;padding:3px 0;}
8119
8144
  return 'Its address is ' + link + ' — confirming it answers.';
8120
8145
  }
8121
8146
  if (inst.status === 'error') return 'Address <code>' + esc(inst.domain) + '</code> — held for this project, but nothing is answering there yet.';
8122
- return 'Address: <code>' + esc(inst.domain) + '</code> — it answers there once the instance is live.';
8147
+ return inst.dns_note ? 'Its address is <code>' + esc(inst.domain) + '</code>. ' + esc(inst.dns_note) : ADDRESS_SETTING_UP;
8123
8148
  }
8124
8149
  /* the HONEST no-address state: sign-in on the instance needs a real domain */
8125
8150
  return '<span class="none">No address yet</span> — sign-in on this project can’t be set up until it has one. ' +
@@ -8191,7 +8216,7 @@ summary{min-height:24px;padding:3px 0;}
8191
8216
  links, so middle/ctrl-click works; the delegated handler upgrades a plain
8192
8217
  click on Manage (task 1003041). */
8193
8218
  var acts = [];
8194
- if (inst.status === 'active' && inst.domain) acts.push('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">View</a>');
8219
+ if (inst.status === 'active' && addressReady(inst)) acts.push('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">View</a>');
8195
8220
  acts.push('<a class="btn small ghost" data-manage="' + iid + '" href="?view=manage&id=' + iid + '">Manage</a>');
8196
8221
  body += '<div class="actions">' + acts.join('') + '</div>';
8197
8222
  /* sig() buckets recency per minute, so a live row re-renders on the
@@ -8514,7 +8539,7 @@ summary{min-height:24px;padding:3px 0;}
8514
8539
  $('mErr').innerHTML = snag ? errMarkup(inst) : '';
8515
8540
  $('mErr').hidden = !snag;
8516
8541
  var acts = [];
8517
- if (inst.status === 'active' && inst.domain) acts.push('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">Open site →</a>');
8542
+ if (inst.status === 'active' && addressReady(inst)) acts.push('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">Open site →</a>');
8518
8543
  if (canRetry(inst)) acts.push('<button type="button" class="btn small" data-m-retry>Try again</button>');
8519
8544
  if (canRevive(inst)) acts.push('<button type="button" class="btn small" data-m-revive>Bring it back</button>');
8520
8545
  if (canRestart(inst)) acts.push('<button type="button" class="btn small" data-m-restart>Restart it</button>');
@@ -9319,7 +9344,7 @@ summary{min-height:24px;padding:3px 0;}
9319
9344
  /* what the project RUNS: its own public manifest, read cross-origin (the
9320
9345
  same GET /instance the platform's runner reads after a restart) */
9321
9346
  function loadVisRuns(inst) {
9322
- if (!inst || inst.status !== 'active' || !inst.domain) { visRuns(''); return; }
9347
+ if (!inst || inst.status !== 'active' || !addressReady(inst)) { visRuns(''); return; } /* the browser's own lookup waits for the name too (task 1004505) */
9323
9348
  var asked = manageId;
9324
9349
  visRuns('Checking what the project is running…');
9325
9350
  ensureManifest(inst.domain).then(function (ans) {
@@ -9772,7 +9797,7 @@ summary{min-height:24px;padding:3px 0;}
9772
9797
  /* what the project RUNS: the SAME shared manifest read the reach card uses
9773
9798
  (task 1003554) — a different key out of one answer, not a second GET */
9774
9799
  function loadJoinRuns(inst) {
9775
- if (!inst || inst.status !== 'active' || !inst.domain) { joinRuns(''); return; }
9800
+ if (!inst || inst.status !== 'active' || !addressReady(inst)) { joinRuns(''); return; } /* the browser's own lookup waits for the name too (task 1004505) */
9776
9801
  var asked = manageId;
9777
9802
  joinRuns('Checking what the project is running…');
9778
9803
  ensureManifest(inst.domain).then(function (ans) {
@@ -658,6 +658,62 @@ async function getSessionById(id) {
658
658
  return rows[0] || null;
659
659
  }
660
660
 
661
+ const TRANSCRIPT_PAGE_DEFAULT = 500; // turns per read; the reader pages with offset=
662
+ const TRANSCRIPT_PAGE_MAX = 2000;
663
+
664
+ // upsertSessionTranscript — store/refresh one session's FULL scrubbed transcript
665
+ // (ADR 0051, task 1001004). Keyed on session_id like session_records, and the same
666
+ // ownership guard applies: a row can only be rewritten by the builder who owns it,
667
+ // so guessing another builder's session_id cannot overwrite their transcript. The
668
+ // content arrives already scrubbed (the uploader's box is the scrub point); this
669
+ // layer stores it and never inspects it. `turnsJson` is the ALREADY-serialized
670
+ // array (the route serializes once to measure it; re-stringifying 12 MB here would
671
+ // block the event loop a second time). The row is only ever created FROM the
672
+ // caller's own session_records row, so a transcript cannot exist for a session the
673
+ // builder did not upload a digest for (and a stranger cannot squat a session_id
674
+ // ahead of its owner). Returns { session_id, turn_count, byte_size, truncated } or
675
+ // null when there is no such owned session.
676
+ async function upsertSessionTranscript({ sessionId, builderId, turnsJson, turnCount = 0, truncated = false, byteSize = 0 }) {
677
+ const { rows } = await pool.query(
678
+ `INSERT INTO session_transcripts (session_id, builder_id, turn_count, byte_size, truncated, turns)
679
+ SELECT sr.session_id, sr.builder_id, $3, $4, $5, $6::jsonb
680
+ FROM session_records sr
681
+ WHERE sr.session_id = $1 AND sr.builder_id = $2
682
+ ON CONFLICT (session_id) DO UPDATE SET
683
+ turn_count = EXCLUDED.turn_count,
684
+ byte_size = EXCLUDED.byte_size,
685
+ truncated = EXCLUDED.truncated,
686
+ turns = EXCLUDED.turns,
687
+ uploaded_at = now()
688
+ WHERE session_transcripts.builder_id = EXCLUDED.builder_id
689
+ RETURNING session_id, turn_count, byte_size, truncated`,
690
+ [sessionId, builderId, Math.max(0, Math.trunc(Number(turnCount) || 0)), Math.max(0, Math.trunc(Number(byteSize) || 0)), !!truncated, turnsJson]
691
+ );
692
+ return rows[0] || null;
693
+ }
694
+
695
+ // getSessionTranscript — one PAGE of the full transcript for one session_records row (by its
696
+ // numeric id, the same id /sessions/:id uses), with the owner so the route can
697
+ // apply the own-vs-Archon rule. null when the session or its transcript is absent.
698
+ async function getSessionTranscript(id, { offset = 0, limit = TRANSCRIPT_PAGE_DEFAULT } = {}) {
699
+ const off = Math.max(0, Math.trunc(Number(offset) || 0));
700
+ const lim = Math.min(TRANSCRIPT_PAGE_MAX, Math.max(1, Math.trunc(Number(limit) || TRANSCRIPT_PAGE_DEFAULT)));
701
+ // The slice happens in Postgres so a page read never ships the whole MB-scale
702
+ // jsonb to Node just to throw most of it away.
703
+ const { rows } = await pool.query(
704
+ `SELECT sr.id, sr.session_id, sr.builder_id, st.turn_count, st.byte_size,
705
+ st.truncated, st.uploaded_at,
706
+ COALESCE((SELECT jsonb_agg(e.v ORDER BY e.n)
707
+ FROM jsonb_array_elements(st.turns) WITH ORDINALITY AS e(v, n)
708
+ WHERE e.n > $2 AND e.n <= $2 + $3), '[]'::jsonb) AS turns
709
+ FROM session_records sr
710
+ JOIN session_transcripts st ON st.session_id = sr.session_id
711
+ WHERE sr.id = $1`,
712
+ [Number(id), off, lim]
713
+ );
714
+ return rows[0] ? { ...rows[0], offset: off, limit: lim } : null;
715
+ }
716
+
661
717
  // listSessionsByBuilder — a builder's OWN sessions (self-serve summary list).
662
718
  async function listSessionsByBuilder(builderId, limit = 25) {
663
719
  const { rows } = await pool.query(
@@ -865,6 +921,8 @@ async function sessionTypeCounts() {
865
921
  module.exports = {
866
922
  // write side (6D.1)
867
923
  upsertSessionRecord,
924
+ upsertSessionTranscript, // ADR 0051 — full scrubbed transcript (separate table)
925
+ getSessionTranscript,
868
926
  upsertSessionRecordWithReward, // route entry point — upsert + token reward (task 1005)
869
927
  awardDeferredSessionRewardForTask, // ADR 0120 land-side trigger — task.shipped listener calls it
870
928
  reconcileUnbookedSessionRewards, // ADR 0120 state-based safety net — books rewards for out-of-band ships
@@ -16,6 +16,11 @@
16
16
  // audited (the BFG principal posture until 6C.1).
17
17
  // GET /sessions/:id — one session's full detail for deep-dive. Owner
18
18
  // self-serve; cross-builder requires Archon + audit.
19
+ // PUT /sessions/:sid/transcript
20
+ // — upload the FULL scrubbed transcript of your OWN
21
+ // session (ADR 0051). Own 16 MB parser.
22
+ // GET /sessions/:id/transcript
23
+ // — read it. Owner, or Archon + audit written first.
19
24
  //
20
25
  // Cross-builder reads are GETs, which the global audit middleware does NOT
21
26
  // instrument — so each writes its own audit_log row (fail-closed: logged BEFORE
@@ -54,6 +59,16 @@ const seams = api; // resolveOptional('reward' | 'builder-settings') — kernel-
54
59
  const { validateOrRespond, parseId } = api;
55
60
 
56
61
  const UPLOAD_BODY_LIMIT = '4mb';
62
+ // ADR 0051 — the full transcript is MB-scale. The client's own ceiling is 12 MB of
63
+ // content (scripts/gds/session-transcript-build.js); 16 MB leaves JSON overhead.
64
+ const TRANSCRIPT_BODY_LIMIT = '16mb';
65
+ const MAX_TRANSCRIPT_TURNS = 20000;
66
+ // SR-17 for the transcript (the sibling POST bounds detail[] to 2 MB; this row is
67
+ // deliberately ~6x that because a full transcript IS the product, but it is still
68
+ // bounded IN the handler, not just by the parser): a per-turn ceiling stops one
69
+ // absurd turn, and the serialized total matches the client's own ceiling.
70
+ const MAX_TRANSCRIPT_TURN_BYTES = 256 * 1024;
71
+ const MAX_TRANSCRIPT_TOTAL_BYTES = 12 * 1024 * 1024;
57
72
  const MAX_DETAIL_TURNS = 5000; // a hard ceiling; ship.js bounds well under this
58
73
 
59
74
  // Disk-fill defense (SR-17 / task 997). The 4 MB body limit + maxItems=5000 cap
@@ -189,6 +204,7 @@ module.exports = function buildSessionsRouter() {
189
204
  registerSeams();
190
205
  const router = express.Router();
191
206
  const uploadJson = express.json({ limit: UPLOAD_BODY_LIMIT });
207
+ const transcriptJson = express.json({ limit: TRANSCRIPT_BODY_LIMIT });
192
208
 
193
209
  // rank: any-builder — upload your OWN session digest. Owner is forced to the
194
210
  // authenticated caller; the body cannot attribute a record to another builder.
@@ -315,6 +331,59 @@ module.exports = function buildSessionsRouter() {
315
331
  }
316
332
  });
317
333
 
334
+ // rank: any-builder — upload the FULL scrubbed transcript of your OWN session
335
+ // (ADR 0051, task 1001004). Separate from POST /sessions on purpose: the digest
336
+ // is KB and carries the reward/evaluator path under a 4 MB parser; the transcript
337
+ // is MB and gets its own larger parser, so a transcript failure can never block a
338
+ // digest. The row is created only FROM the caller's own session_records row (no
339
+ // digest → 404, someone else's session_id → 404, never a confirmation it exists).
340
+ // The content is scrubbed on the uploader's box; the server bounds SHAPE, not
341
+ // secrets — every turn must be a plain object and the whole body is capped.
342
+ router.put('/sessions/:sid/transcript', auth.requireBuilder, transcriptJson, async (req, res) => {
343
+ const sid = String(req.params.sid || '');
344
+ if (!/^[A-Za-z0-9_-]{1,256}$/.test(sid)) return res.fail('bad_session_id', 400);
345
+ if (validateOrRespond(req, res, {
346
+ turns: { required: true, type: 'array', maxItems: MAX_TRANSCRIPT_TURNS },
347
+ truncated: { type: 'boolean' },
348
+ })) return;
349
+ const body = req.body || {};
350
+ const turns = body.turns;
351
+ // Serialize each turn ONCE: the same strings are measured, then joined into the
352
+ // jsonb text the db stores, so a 12 MB transcript is not stringified twice.
353
+ const parts = new Array(turns.length);
354
+ let totalBytes = 2;
355
+ for (let idx = 0; idx < turns.length; idx++) {
356
+ const t = turns[idx];
357
+ if (!t || typeof t !== 'object' || Array.isArray(t)) {
358
+ return res.fail('bad_transcript_turn', { status: 400, message: `turns[${idx}] must be an object` });
359
+ }
360
+ parts[idx] = JSON.stringify(t);
361
+ const turnBytes = Buffer.byteLength(parts[idx], 'utf8');
362
+ if (turnBytes > MAX_TRANSCRIPT_TURN_BYTES) {
363
+ return res.fail('transcript_turn_too_large', { status: 413, message: `turns[${idx}] is ${turnBytes} bytes; the per-turn cap is ${MAX_TRANSCRIPT_TURN_BYTES} bytes` });
364
+ }
365
+ totalBytes += turnBytes + 1;
366
+ if (totalBytes > MAX_TRANSCRIPT_TOTAL_BYTES) {
367
+ return res.fail('transcript_too_large', { status: 413, message: `transcript exceeds the ${MAX_TRANSCRIPT_TOTAL_BYTES}-byte total cap` });
368
+ }
369
+ }
370
+ try {
371
+ const stored = await db.upsertSessionTranscript({
372
+ sessionId: sid,
373
+ builderId: req.builder.id, // owner = caller, never the body
374
+ turnsJson: `[${parts.join(',')}]`,
375
+ turnCount: turns.length,
376
+ truncated: body.truncated === true,
377
+ byteSize: totalBytes,
378
+ });
379
+ if (!stored) return res.fail('not_found', 404);
380
+ res.status(201).json({ transcript: stored });
381
+ } catch (err) {
382
+ api.logger('sessions').error(err, 'PUT /sessions/:sid/transcript failed');
383
+ res.fail('upload_failed', 500);
384
+ }
385
+ });
386
+
318
387
  // GET /sessions/search + /sessions/mine are declared BEFORE /sessions/:id so
319
388
  // Express matches the literal paths first (the numeric :id never swallows them).
320
389
 
@@ -458,5 +527,33 @@ module.exports = function buildSessionsRouter() {
458
527
  }
459
528
  });
460
529
 
530
+ // rank: any-builder — the FULL transcript of one session (ADR 0051). Same rule as
531
+ // GET /sessions/:id: the owner reads their own; a cross-builder read is
532
+ // Archon-only, 404 (never 403) for everyone else, and the fail-closed audit row
533
+ // is written BEFORE any transcript content is served — transcripts are the most
534
+ // sensitive thing this module holds. Paged (?offset=&limit=, default 500 turns,
535
+ // max 2000): a transcript is MB-scale and the response carries turn_count so the
536
+ // reader knows how far to go.
537
+ router.get('/sessions/:id/transcript', auth.requireBuilder, readRateLimit, async (req, res) => {
538
+ const id = parseId(req, res, { code: 'bad_session_id', positiveInt: true });
539
+ if (id === null) return;
540
+ try {
541
+ const t = await db.getSessionTranscript(id, { offset: req.query.offset, limit: req.query.limit });
542
+ if (!t) return res.fail('not_found', 404);
543
+ if (String(t.builder_id) !== String(req.builder.id)) {
544
+ if (req.builder.rank !== 'archon') return res.fail('not_found', 404);
545
+ await recordSessionRead(req, {
546
+ route: '/api/bongos/sessions/:id/transcript',
547
+ targetBuilderId: t.builder_id,
548
+ target: `transcript:${id}`,
549
+ });
550
+ }
551
+ res.json({ transcript: t });
552
+ } catch (err) {
553
+ api.logger('sessions').error(err, 'GET /sessions/:id/transcript failed');
554
+ res.fail('fetch_failed', 500);
555
+ }
556
+ });
557
+
461
558
  return router;
462
559
  };