@bongos/core 1.19.126 → 1.19.128
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +28 -23
- package/modules/builder-settings/builder-needs.js +59 -2
- package/modules/provisioning/migrations/provisioning_008_dns_note.sql +15 -0
- package/modules/provisioning/provisioning.js +28 -1
- package/modules/provisioning/routes/provisioning.js +26 -1
- package/modules/provisioning/tests/provisioning.mjs +21 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +88 -18
- package/scripts/gds/provision.js +115 -19
- package/src/bongos/db-kernel.js +15 -0
- package/src/bongos/db.js +2 -1
- package/src/bongos/routes/me.js +11 -1
- package/src/module-api.js +5 -1
- package/tests/builder_needs.mjs +114 -0
- package/tests/provision.mjs +272 -6
- package/tests/provisioning_domain_attach.mjs +40 -0
- package/tests/public_landing_projects.mjs +33 -0
package/scripts/gds/provision.js
CHANGED
|
@@ -169,6 +169,14 @@ const CONFIG = {
|
|
|
169
169
|
|
|
170
170
|
const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
|
|
171
171
|
|
|
172
|
+
// Manifest-placement verification budget (task 1002720 / QA-walk F23): a child
|
|
173
|
+
// instance is a full Node boot away from reporting auth_configured=true, and the
|
|
174
|
+
// old 10×1s poll routinely lost that race — recording a failure for a setup that
|
|
175
|
+
// succeeded, then burning the SINGLE-USE manifest code on the retry. ~3 min of
|
|
176
|
+
// patience; the poll runs after the intent loop, so it delays nothing.
|
|
177
|
+
const MANIFEST_VERIFY_TRIES = numEnv('PROVISION_MANIFEST_VERIFY_TRIES', 90);
|
|
178
|
+
const MANIFEST_VERIFY_INTERVAL_MS = numEnv('PROVISION_MANIFEST_VERIFY_INTERVAL_MS', 2000);
|
|
179
|
+
|
|
172
180
|
// ---------------------------------------------------------------------------
|
|
173
181
|
// Lazy dep load (so --help / arg errors need no Postgres) — the box.js pattern.
|
|
174
182
|
// ---------------------------------------------------------------------------
|
|
@@ -1503,9 +1511,21 @@ async function provisionInstance(inst, deps) {
|
|
|
1503
1511
|
// only on the control plane — it must never run on the (token-free) co-hosting
|
|
1504
1512
|
// box even when the box-mutating steps above went there over SSH (task 2065).
|
|
1505
1513
|
controlExec(CONFIG.dnsHook, { env: dnsEnv });
|
|
1514
|
+
// The clear half of the birth-path note (F16): a revive after the operator
|
|
1515
|
+
// configured the hook must retire the stale "point your A record" sentence.
|
|
1516
|
+
if (apply && provisioning.recordDnsNote) {
|
|
1517
|
+
await provisioning.recordDnsNote(db, inst.id, null).catch(() => {});
|
|
1518
|
+
}
|
|
1506
1519
|
}
|
|
1507
1520
|
} else {
|
|
1508
1521
|
log(' [dns] skipped (no domain, or BOX_DNS_HOOK unset)');
|
|
1522
|
+
// Birth-path twin of the domain-attach note (F16): a domain with no hook is
|
|
1523
|
+
// the same silent skip here, so the owner card gets the same sentence.
|
|
1524
|
+
if (apply && inst.domain) {
|
|
1525
|
+
if (provisioning.recordDnsNote) await provisioning.recordDnsNote(db, inst.id,
|
|
1526
|
+
`Automatic DNS isn't set up on this platform — point ${inst.domain}'s A record at ${recordIp || 'your server'}, and this card will show when it answers.`
|
|
1527
|
+
).catch(() => {});
|
|
1528
|
+
}
|
|
1509
1529
|
}
|
|
1510
1530
|
|
|
1511
1531
|
// ── step 6: Caddy per-instance site snippet + reload ──────────────────────
|
|
@@ -1520,17 +1540,55 @@ async function provisionInstance(inst, deps) {
|
|
|
1520
1540
|
} else { log(' (Caddy on the droplet via cloud-init)'); }
|
|
1521
1541
|
|
|
1522
1542
|
// ── step 7: healthz ───────────────────────────────────────────────────────
|
|
1543
|
+
// The probe RESULT is recorded, not discarded (task 1002720 / QA-walk F15: a
|
|
1544
|
+
// refused connection used to ride silently into 'active' and the card said
|
|
1545
|
+
// live). Bounded retry so a slow first boot is not slandered — the
|
|
1546
|
+
// placeManifestCreds loop shape. Still ok:true either way: a failed probe
|
|
1547
|
+
// must not flip the intent to failed (cmdRunIntents would burn the 3-attempt
|
|
1548
|
+
// budget on an instance that may simply be slow); the liveness overlay is
|
|
1549
|
+
// what tells the owner the truth.
|
|
1523
1550
|
log(' [healthz] verify the instance answers');
|
|
1524
|
-
|
|
1525
|
-
|
|
1551
|
+
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1552
|
+
let healthy = null; // null = not observed (dry-run / dedicated)
|
|
1553
|
+
if (local) {
|
|
1554
|
+
const tries = apply ? 10 : 1;
|
|
1555
|
+
for (let i = 0; i < tries; i++) {
|
|
1556
|
+
const res = boxExec(healthzCmd(inst), { allowFail: true });
|
|
1557
|
+
if (!apply) break; // dry-run: exactly one plan line
|
|
1558
|
+
if (res && !res.softFailed) { healthy = true; break; }
|
|
1559
|
+
healthy = false;
|
|
1560
|
+
if (i < tries - 1) await sleep(1000);
|
|
1561
|
+
}
|
|
1562
|
+
} else log(' (verified post-boot)');
|
|
1526
1563
|
|
|
1527
|
-
// Mark active.
|
|
1564
|
+
// Mark active. error_note: null — a stale note from an earlier failed run
|
|
1565
|
+
// must not ride into the active state (F15's second half; the co-tenant +
|
|
1566
|
+
// standalone paths never cleared it). The runner hands its own observation
|
|
1567
|
+
// to the liveness overlay so the card gets an honest verdict immediately
|
|
1568
|
+
// instead of waiting ~5 min for the first sweep.
|
|
1528
1569
|
if (apply) {
|
|
1529
|
-
await provisioning.setInstanceStatus(db, inst.id, 'active', { ...patch, provisioned_at: new Date().toISOString() });
|
|
1530
|
-
|
|
1570
|
+
await provisioning.setInstanceStatus(db, inst.id, 'active', { ...patch, error_note: null, provisioned_at: new Date().toISOString() });
|
|
1571
|
+
// The overlay column is DOMAIN-scoped when a domain exists (the sweep
|
|
1572
|
+
// probes https://<domain>; loopback only when there is none), and the card
|
|
1573
|
+
// renders up=true as "Answering at <domain>". This probe is loopback, so:
|
|
1574
|
+
// a FAILURE is a verdict on any shape (a dead process answers nowhere),
|
|
1575
|
+
// but a PASS proves only the process — for a domain-bearing instance the
|
|
1576
|
+
// address claim stays "not checked yet" until the sweep earns it.
|
|
1577
|
+
const probeIsVerdict = healthy === false || (healthy === true && !inst.domain);
|
|
1578
|
+
if (probeIsVerdict && provisioning.recordLivenessProbe) {
|
|
1579
|
+
await provisioning.recordLivenessProbe(db, inst.id, healthy).catch(() => {});
|
|
1580
|
+
}
|
|
1581
|
+
await provisioning.recordEvent(db, {
|
|
1582
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'active',
|
|
1583
|
+
detail: `provisioned (${inst.hosting_shape}, port ${inst.port || '-'})` +
|
|
1584
|
+
(healthy === false ? ' — not answering /healthz yet' : ''),
|
|
1585
|
+
actor: 'runner',
|
|
1586
|
+
});
|
|
1531
1587
|
}
|
|
1532
|
-
log(
|
|
1533
|
-
|
|
1588
|
+
log(healthy === false
|
|
1589
|
+
? ` ⚠ ${inst.slug} provisioned, but it is not answering /healthz yet — its card will read "unreachable"`
|
|
1590
|
+
: ` ✓ ${inst.slug} provisioned`);
|
|
1591
|
+
return { ok: true, port: inst.port, healthy };
|
|
1534
1592
|
}
|
|
1535
1593
|
|
|
1536
1594
|
// The cloud-init user-data for a dedicated droplet: run the co-tenant standup
|
|
@@ -1616,6 +1674,7 @@ async function domainAttachInstance(inst, deps) {
|
|
|
1616
1674
|
|
|
1617
1675
|
// DNS A-record UPSERT — mirrors provisionInstance step 5 (control-plane exec only:
|
|
1618
1676
|
// the hook needs the Cloudflare token, which never leaves the control plane).
|
|
1677
|
+
let dnsSkipped = false;
|
|
1619
1678
|
if (CONFIG.dnsHook) {
|
|
1620
1679
|
if (!recordIp) {
|
|
1621
1680
|
const ipEnvHint = standalone ? 'PROVISION_CONTROL_PLANE_IP (standalone)' : 'PROVISION_PUBLIC_IP (co-tenant)';
|
|
@@ -1627,7 +1686,11 @@ async function domainAttachInstance(inst, deps) {
|
|
|
1627
1686
|
controlExec(CONFIG.dnsHook, { env: dnsEnv });
|
|
1628
1687
|
}
|
|
1629
1688
|
} else {
|
|
1630
|
-
|
|
1689
|
+
// Still ok:true below — an unset hook is unretryable, and ok:false would
|
|
1690
|
+
// burn the intent's retry budget on it. The skip is surfaced to the OWNER
|
|
1691
|
+
// via dns_note (F16), not just this operator log line.
|
|
1692
|
+
dnsSkipped = true;
|
|
1693
|
+
log(` [dns] skipped (BOX_DNS_HOOK unset) — point ${inst.domain}'s A record at ${recordIp || 'this server'} manually`);
|
|
1631
1694
|
}
|
|
1632
1695
|
|
|
1633
1696
|
log(' [caddy] write site snippet + reload');
|
|
@@ -1638,12 +1701,24 @@ async function domainAttachInstance(inst, deps) {
|
|
|
1638
1701
|
boxExec(healthzCmd(inst), { allowFail: true });
|
|
1639
1702
|
|
|
1640
1703
|
if (apply) {
|
|
1704
|
+
// Written BOTH ways so a later successful attach clears a stale note.
|
|
1705
|
+
if (provisioning.recordDnsNote) await provisioning.recordDnsNote(db, inst.id, dnsSkipped
|
|
1706
|
+
? `Automatic DNS isn't set up on this platform — point ${inst.domain}'s A record at ${recordIp || 'your server'}, and this card will show when it answers.`
|
|
1707
|
+
: null).catch(() => {});
|
|
1708
|
+
// Release the F19 latch: this run SUCCEEDED, so a "we stopped trying" note
|
|
1709
|
+
// from an earlier retired attach must not outlive its own recovery.
|
|
1710
|
+
if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
|
|
1641
1711
|
await provisioning.recordEvent(db, {
|
|
1642
1712
|
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'domain-attached',
|
|
1643
|
-
detail:
|
|
1713
|
+
detail: dnsSkipped
|
|
1714
|
+
? `domain ${inst.domain} wired (env + caddy); DNS SKIPPED — no hook configured`
|
|
1715
|
+
: `domain ${inst.domain} wired (env + DNS + caddy re-asserted)`,
|
|
1716
|
+
actor: 'runner',
|
|
1644
1717
|
}).catch(() => {});
|
|
1645
1718
|
}
|
|
1646
|
-
log(
|
|
1719
|
+
log(dnsSkipped
|
|
1720
|
+
? ` ✓ ${inst.slug} configured for ${inst.domain} — DNS still needs your A record`
|
|
1721
|
+
: ` ✓ ${inst.slug} addressed as ${inst.domain}`);
|
|
1647
1722
|
return { ok: true };
|
|
1648
1723
|
}
|
|
1649
1724
|
|
|
@@ -1750,11 +1825,14 @@ async function placeManifestCreds(inst, mrow, deps) {
|
|
|
1750
1825
|
|
|
1751
1826
|
// 2) validate the returned creds BEFORE writing (catch a malformed GitHub response —
|
|
1752
1827
|
// the same length/charset guard oauth-secret.js uses against silent truncation).
|
|
1828
|
+
// From here on the single-use manifest code is SPENT (the conversion POST
|
|
1829
|
+
// consumed it) — every return carries codeSpent:true so the drain never
|
|
1830
|
+
// resets the row for a retry that would re-convert a burned code and 404.
|
|
1753
1831
|
const idV = oauthSecret.validateClientId(creds.clientId);
|
|
1754
1832
|
const secV = oauthSecret.validateClientSecret(creds.clientSecret);
|
|
1755
1833
|
if (!idV.ok || !secV.ok) {
|
|
1756
1834
|
const why = [!idV.ok ? `id: ${idV.reason}` : null, !secV.ok ? `secret: ${secV.reason}` : null].filter(Boolean).join('; ');
|
|
1757
|
-
return { ok: false, error: `GitHub returned unusable creds (${why})` };
|
|
1835
|
+
return { ok: false, codeSpent: apply, error: `GitHub returned unusable creds (${why})` };
|
|
1758
1836
|
}
|
|
1759
1837
|
|
|
1760
1838
|
// 3) rewrite web.env with the creds + restart the unit (never logs the secret — the
|
|
@@ -1764,21 +1842,24 @@ async function placeManifestCreds(inst, mrow, deps) {
|
|
|
1764
1842
|
boxExec(`${sudoP}systemctl restart ${inst.slug}`);
|
|
1765
1843
|
|
|
1766
1844
|
// 4) verify /healthz reports auth_configured=true (only meaningful under --apply).
|
|
1845
|
+
// PATIENT (F23): the child must boot before it can answer; losing this race
|
|
1846
|
+
// used to record a failure for a setup that had succeeded.
|
|
1767
1847
|
if (apply) {
|
|
1848
|
+
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1768
1849
|
let authConfigured = false;
|
|
1769
|
-
for (let i = 0; i <
|
|
1850
|
+
for (let i = 0; i < MANIFEST_VERIFY_TRIES; i++) {
|
|
1770
1851
|
const out = boxExec(healthzCmd(inst), { allowFail: true });
|
|
1771
1852
|
if (oauthSecret.parseHealthzAuth(out && out.stdout).authConfigured) { authConfigured = true; break; }
|
|
1772
|
-
await
|
|
1853
|
+
await sleep(MANIFEST_VERIFY_INTERVAL_MS);
|
|
1773
1854
|
}
|
|
1774
1855
|
if (!authConfigured) {
|
|
1775
|
-
return { ok: false, error: 'placed
|
|
1856
|
+
return { ok: false, codeSpent: true, error: 'the credentials are placed, but the project did not report sign-in ready in time' };
|
|
1776
1857
|
}
|
|
1777
1858
|
log(' [oauth-manifest] ✓ /healthz auth_configured=true — sign-in is live');
|
|
1778
1859
|
} else {
|
|
1779
1860
|
log(' [oauth-manifest] would probe /healthz for auth_configured after restart');
|
|
1780
1861
|
}
|
|
1781
|
-
return { ok: true, appId: creds.appId, appSlug: creds.appSlug };
|
|
1862
|
+
return { ok: true, codeSpent: apply, appId: creds.appId, appSlug: creds.appSlug };
|
|
1782
1863
|
}
|
|
1783
1864
|
|
|
1784
1865
|
// Drain provisioning_oauth_manifests rows awaiting conversion (status 'code_received').
|
|
@@ -1820,11 +1901,17 @@ async function drainOAuthManifests(deps) {
|
|
|
1820
1901
|
if (!inst) { await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: 'instance row vanished' }); errors++; continue; }
|
|
1821
1902
|
const result = await placeManifestCreds(inst, mrow, deps);
|
|
1822
1903
|
if (result && result.ok === false) {
|
|
1823
|
-
|
|
1904
|
+
// A SPENT code is terminal, always (F23): GitHub's manifest code is
|
|
1905
|
+
// single-use, so resetting the row to 'code_received' guarantees the
|
|
1906
|
+
// next tick re-converts a burned code and 404s forever. Only failures
|
|
1907
|
+
// that happened BEFORE the conversion stay retryable.
|
|
1908
|
+
if (result.codeSpent) await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: result.error, github_code: null });
|
|
1909
|
+
else if (mrow.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: result.error });
|
|
1824
1910
|
else await provisioning.resolveOAuthManifest(db, mrow.id, 'code_received', { last_error: result.error });
|
|
1825
1911
|
errors++; log(` ✖ oauth-manifest #${mrow.id}: ${result.error}`);
|
|
1826
1912
|
} else {
|
|
1827
|
-
|
|
1913
|
+
// github_code: null on done too — a finished row holds no re-convertible code.
|
|
1914
|
+
await provisioning.resolveOAuthManifest(db, mrow.id, 'done', { app_id: result.appId, app_slug: result.appSlug, github_code: null });
|
|
1828
1915
|
await provisioning.recordEvent(db, {
|
|
1829
1916
|
instanceId: inst.id, ownerBuilderId: mrow.requested_by, event: 'oauth-manifest-done',
|
|
1830
1917
|
detail: `sign-in wired via GitHub App Manifest (app ${result.appSlug || result.appId || '?'})`, actor: 'runner',
|
|
@@ -1880,8 +1967,17 @@ async function cmdRunIntents(deps) {
|
|
|
1880
1967
|
else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
|
|
1881
1968
|
else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
|
|
1882
1969
|
if (result && result.ok === false) {
|
|
1883
|
-
if (intent.attempts >= MAX_INTENT_ATTEMPTS)
|
|
1884
|
-
|
|
1970
|
+
if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
|
|
1971
|
+
await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
|
|
1972
|
+
// A retired intent stops silently otherwise (F19): record the reason
|
|
1973
|
+
// on the instance WITHOUT changing its lifecycle status — passing the
|
|
1974
|
+
// CURRENT status makes the status write a no-op, so a healthy active
|
|
1975
|
+
// instance is never marked broken over a domain hiccup, but the card
|
|
1976
|
+
// can say why the work stopped.
|
|
1977
|
+
await provisioning.setInstanceStatus(db, intent.instance_id, inst.status, { error_note: result.error || 'op failed' }).catch(() => {});
|
|
1978
|
+
} else {
|
|
1979
|
+
await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
|
|
1980
|
+
}
|
|
1885
1981
|
errors++;
|
|
1886
1982
|
} else { await provisioning.resolveIntent(db, intent.id, 'done', null); ok++; }
|
|
1887
1983
|
} catch (e) {
|
package/src/bongos/db-kernel.js
CHANGED
|
@@ -62,6 +62,20 @@ async function getBuilderById(id) {
|
|
|
62
62
|
return rows[0] ?? null;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// getFoundingBuilder — the instance's first admitted HUMAN builder (task
|
|
66
|
+
// 1002739): the founding owner a newborn instance greets, and the anchor of
|
|
67
|
+
// the builder-needs founding-grace window. system_role IS NULL is load-bearing:
|
|
68
|
+
// core migrations 065/067 seed the BFG + Discord-bot system principals into
|
|
69
|
+
// builders on EVERY instance before anyone can sign in, so the lowest ids on a
|
|
70
|
+
// newborn DB are not the founder. First human row by id, regardless of status —
|
|
71
|
+
// the founder stays the founder even if later deactivated.
|
|
72
|
+
async function getFoundingBuilder() {
|
|
73
|
+
const { rows } = await pool.query(
|
|
74
|
+
`SELECT id, created_at FROM builders WHERE system_role IS NULL ORDER BY id ASC LIMIT 1`
|
|
75
|
+
);
|
|
76
|
+
return rows[0] ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
// listActiveBuilders — the minimal, any-builder-readable roster (task 1877): an
|
|
66
80
|
// invite-picker needs a real name of every builder to invite, not just the
|
|
67
81
|
// leaderboard's top-100-by-credits window. Deliberately narrow columns — no
|
|
@@ -337,6 +351,7 @@ module.exports = {
|
|
|
337
351
|
withTx,
|
|
338
352
|
sessionPermsSelect,
|
|
339
353
|
getBuilderById,
|
|
354
|
+
getFoundingBuilder,
|
|
340
355
|
listActiveBuilders,
|
|
341
356
|
insertAuditLog,
|
|
342
357
|
LIVE_RANK_LADDER,
|
package/src/bongos/db.js
CHANGED
|
@@ -38,7 +38,7 @@ const seams = require('../module-seams');
|
|
|
38
38
|
// published doorway resolves through. Re-exported below so every caller is
|
|
39
39
|
// byte-identical; db-kernel depends only on ./pool, never ./db (one-way).
|
|
40
40
|
const {
|
|
41
|
-
withTx, getBuilderById, listActiveBuilders, insertAuditLog, LIVE_RANK_LADDER,
|
|
41
|
+
withTx, getBuilderById, getFoundingBuilder, listActiveBuilders, insertAuditLog, LIVE_RANK_LADDER,
|
|
42
42
|
// Auth/identity/admission + the rank vocabulary carved to db-kernel (ADR 0102,
|
|
43
43
|
// finishing ADR 0091 §1). Re-exported below so db.js's other importers + its own
|
|
44
44
|
// internal uses (setBuilderRank's RANKS.indexOf, etc.) stay byte-identical.
|
|
@@ -978,6 +978,7 @@ module.exports = {
|
|
|
978
978
|
upsertBuilder,
|
|
979
979
|
seatFirstAdminIfUnclaimed,
|
|
980
980
|
getBuilderById,
|
|
981
|
+
getFoundingBuilder,
|
|
981
982
|
listActiveBuilders,
|
|
982
983
|
linkAnthropicAccount,
|
|
983
984
|
getBuilderByGithubId,
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -83,7 +83,7 @@ module.exports = function buildMeRouter() {
|
|
|
83
83
|
const [
|
|
84
84
|
builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
|
|
85
85
|
recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
|
|
86
|
-
behaviorPrefs, geminiKeyRow, needsRebaseTasks,
|
|
86
|
+
behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder,
|
|
87
87
|
] = await Promise.all([
|
|
88
88
|
db.getBuilderById(req.builder.id),
|
|
89
89
|
lc().getActiveClaimsForBuilder(req.builder.id),
|
|
@@ -144,6 +144,10 @@ module.exports = function buildMeRouter() {
|
|
|
144
144
|
// without a follow-up call. Through the `lifecycle` port; [] on a
|
|
145
145
|
// lifecycle-less instance (guarded — the port is resolveOptional).
|
|
146
146
|
lc() ? lc().needsRebaseTasksForBuilder(req.builder.id) : [],
|
|
147
|
+
// task 1002739: the instance's first admitted builder — anchors the
|
|
148
|
+
// builder-needs founding-grace window (a newborn instance's founder gets a
|
|
149
|
+
// welcome, not a nag). Best-effort: null just means no grace.
|
|
150
|
+
db.getFoundingBuilder().catch(() => null),
|
|
147
151
|
]);
|
|
148
152
|
// V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
|
|
149
153
|
// monthly budget. Surfaced here so the CLI (session-start ping) and the hall
|
|
@@ -274,6 +278,12 @@ module.exports = function buildMeRouter() {
|
|
|
274
278
|
rank: builder?.rank,
|
|
275
279
|
ownKeySet: !!(geminiKeyRow && geminiKeyRow.enc),
|
|
276
280
|
sharedKeyConfigured: artSharedKeyConfigured(),
|
|
281
|
+
// founding grace (task 1002739 / direction §5.6): a newborn instance's
|
|
282
|
+
// founder gets a welcome roster, not a coverage nag. The module owns the
|
|
283
|
+
// policy (window length, copy); we pass the raw facts.
|
|
284
|
+
isFoundingOwner: !!(foundingBuilder && String(foundingBuilder.id) === String(req.builder.id)),
|
|
285
|
+
founderCreatedAt: foundingBuilder ? foundingBuilder.created_at : null,
|
|
286
|
+
projectName: (() => { try { return branding().identity?.productName || null; } catch { return null; } })(),
|
|
277
287
|
}) : [],
|
|
278
288
|
};
|
|
279
289
|
// Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
|
package/src/module-api.js
CHANGED
|
@@ -679,8 +679,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
679
679
|
// landed since 1.19.124 with no explicit bump. run 31849445746. (task 1002620)
|
|
680
680
|
// 1.19.126 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
681
681
|
// landed since 1.19.125 with no explicit bump. run 31853055269. (task 1002620)
|
|
682
|
+
// 1.19.127 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
683
|
+
// landed since 1.19.126 with no explicit bump. run 31854730552. (task 1002620)
|
|
684
|
+
// 1.19.128 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
685
|
+
// landed since 1.19.127 with no explicit bump. run 31857842473. (task 1002620)
|
|
682
686
|
// ---------------------------------------------------------------------------
|
|
683
|
-
const CORE_VERSION = '1.19.
|
|
687
|
+
const CORE_VERSION = '1.19.128'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
684
688
|
|
|
685
689
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
686
690
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/builder_needs.mjs
CHANGED
|
@@ -95,5 +95,119 @@ t('missing rank fails safe — not action_needed without an explicit rank', () =
|
|
|
95
95
|
assert.ok(['covered', 'action_needed', 'satisfied'].includes(n.state));
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
console.log('founding grace (task 1002739, direction §5.6):');
|
|
99
|
+
|
|
100
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
101
|
+
const bornNow = new Date().toISOString();
|
|
102
|
+
|
|
103
|
+
t('newborn founder: welcome item first, greeting by project name', () => {
|
|
104
|
+
const r = needs.computeNeeds({
|
|
105
|
+
rank: 'archon', ownKeySet: false,
|
|
106
|
+
isFoundingOwner: true, founderCreatedAt: bornNow, projectName: 'Mercury',
|
|
107
|
+
});
|
|
108
|
+
assert.equal(r.items[0].id, 'founding_welcome');
|
|
109
|
+
assert.match(r.items[0].title, /Mercury/);
|
|
110
|
+
assert.equal(r.items[0].state, 'covered');
|
|
111
|
+
assert.equal(r.items[0].action, null);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
t('newborn founder: zero action_needed — every nag is suppressed', () => {
|
|
115
|
+
const r = needs.computeNeeds({
|
|
116
|
+
rank: 'archon', ownKeySet: false, isFoundingOwner: true, founderCreatedAt: bornNow,
|
|
117
|
+
});
|
|
118
|
+
assert.equal(r.has_action_needed, false);
|
|
119
|
+
assert.equal(r.action_needed_count, 0);
|
|
120
|
+
for (const n of r.items) assert.notEqual(n.state, 'action_needed');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
t('a suppressed nag keeps its identity but loses the demand', () => {
|
|
124
|
+
const r = needs.computeNeeds({
|
|
125
|
+
rank: 'archon', ownKeySet: false, isFoundingOwner: true, founderCreatedAt: bornNow,
|
|
126
|
+
});
|
|
127
|
+
const artItem = r.items.find((n) => n.id === 'art_key');
|
|
128
|
+
assert.ok(artItem, 'the art_key item must still be listed, not hidden');
|
|
129
|
+
assert.equal(artItem.state, 'covered');
|
|
130
|
+
assert.equal(artItem.action, null);
|
|
131
|
+
assert.doesNotMatch(artItem.message, /coverage has ended/i);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
t('the grace window is seven days — the policy number is pinned, not self-referential', () => {
|
|
135
|
+
// A fixed injected clock, and 7 written as a literal on both sides: mutating
|
|
136
|
+
// FOUNDING_GRACE_DAYS (a "tune", a bad merge) must fail here, not slide.
|
|
137
|
+
assert.equal(needs.FOUNDING_GRACE_DAYS, 7);
|
|
138
|
+
const born = Date.parse('2026-01-01T00:00:00Z');
|
|
139
|
+
const ctx = { rank: 'archon', ownKeySet: false, isFoundingOwner: true, founderCreatedAt: '2026-01-01T00:00:00Z' };
|
|
140
|
+
const inWindow = needs.computeNeeds({ ...ctx, nowMs: born + 7 * DAY - 60_000 });
|
|
141
|
+
assert.equal(inWindow.has_action_needed, false);
|
|
142
|
+
assert.equal(inWindow.items[0].id, 'founding_welcome');
|
|
143
|
+
const expired = needs.computeNeeds({ ...ctx, nowMs: born + 7 * DAY + 60_000 });
|
|
144
|
+
assert.equal(expired.has_action_needed, true);
|
|
145
|
+
assert.ok(!expired.items.find((n) => n.id === 'founding_welcome'));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
t('grace expires: past FOUNDING_GRACE_DAYS the founder is nagged like anyone (real clock)', () => {
|
|
149
|
+
const old = new Date(Date.now() - (needs.FOUNDING_GRACE_DAYS * DAY + 60_000)).toISOString();
|
|
150
|
+
const r = needs.computeNeeds({
|
|
151
|
+
rank: 'archon', ownKeySet: false, isFoundingOwner: true, founderCreatedAt: old,
|
|
152
|
+
});
|
|
153
|
+
assert.equal(r.has_action_needed, true);
|
|
154
|
+
assert.ok(!r.items.find((n) => n.id === 'founding_welcome'));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
t('a non-founder on a newborn instance is not suppressed', () => {
|
|
158
|
+
const r = needs.computeNeeds({
|
|
159
|
+
rank: 'thetes', ownKeySet: false, isFoundingOwner: false, founderCreatedAt: bornNow,
|
|
160
|
+
});
|
|
161
|
+
assert.equal(r.has_action_needed, true);
|
|
162
|
+
assert.ok(!r.items.find((n) => n.id === 'founding_welcome'));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
t('satisfied stays satisfied during grace — no fake downgrades', () => {
|
|
166
|
+
const r = needs.computeNeeds({
|
|
167
|
+
rank: 'archon', ownKeySet: true, isFoundingOwner: true, founderCreatedAt: bornNow,
|
|
168
|
+
});
|
|
169
|
+
assert.equal(r.items.find((n) => n.id === 'art_key').state, 'satisfied');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
t('no founder facts at all → no grace, no welcome (vanilla ctx unchanged)', () => {
|
|
173
|
+
const r = needs.computeNeeds({ rank: 'thetes', ownKeySet: false });
|
|
174
|
+
assert.equal(r.has_action_needed, true);
|
|
175
|
+
assert.ok(!r.items.find((n) => n.id === 'founding_welcome'));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
console.log('the producing side (DB-free source pins — the policy is only as real as its facts):');
|
|
179
|
+
|
|
180
|
+
// The seven policy cases above hand computeNeeds its ctx. These pin the two
|
|
181
|
+
// places that PRODUCE that ctx, because both were wrong-or-unverified once:
|
|
182
|
+
// the founder query returned the migration-seeded BFG system principal (ids 1-2
|
|
183
|
+
// on every newborn DB — review panel, 2026-08-15), and a key rename in me.js
|
|
184
|
+
// would silently disable the grace while every policy case stayed green. A
|
|
185
|
+
// DB-backed newborn-instance test can't run in the unit gate, so the source is
|
|
186
|
+
// the checkable artifact.
|
|
187
|
+
import { readFileSync } from 'node:fs';
|
|
188
|
+
import { fileURLToPath } from 'node:url';
|
|
189
|
+
import path from 'node:path';
|
|
190
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
191
|
+
|
|
192
|
+
t('getFoundingBuilder skips the seeded system principals (system_role IS NULL)', () => {
|
|
193
|
+
const src = readFileSync(path.join(ROOT, 'src/bongos/db-kernel.js'), 'utf8');
|
|
194
|
+
const fn = src.slice(src.indexOf('function getFoundingBuilder'));
|
|
195
|
+
const sql = fn.slice(0, fn.indexOf('return'));
|
|
196
|
+
assert.match(sql, /FROM builders/);
|
|
197
|
+
assert.match(sql, /system_role IS NULL/,
|
|
198
|
+
'the founder query must exclude system principals — migrations 065/067 seed them at the lowest ids on every instance');
|
|
199
|
+
assert.match(sql, /ORDER BY id ASC/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
t('the /me aggregator feeds computeNeeds the founder facts under their contract names', () => {
|
|
203
|
+
const src = readFileSync(path.join(ROOT, 'src/bongos/routes/me.js'), 'utf8');
|
|
204
|
+
const call = src.slice(src.indexOf('computeNeeds({'), src.indexOf('}) : []'));
|
|
205
|
+
for (const key of ['isFoundingOwner', 'founderCreatedAt', 'projectName']) {
|
|
206
|
+
assert.match(call, new RegExp(`${key}\\s*:`),
|
|
207
|
+
`me.js must pass ${key} into computeNeeds — a rename silently disables the founding grace`);
|
|
208
|
+
}
|
|
209
|
+
assert.match(src, /getFoundingBuilder\(\)/, 'me.js must resolve the founder row');
|
|
210
|
+
});
|
|
211
|
+
|
|
98
212
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
99
213
|
process.exit(failed ? 1 : 0);
|