@bongos/core 1.25.21 → 1.25.22

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 (46) hide show
  1. package/.bongos-core.json +84 -44
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +11 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
  8. package/docs/api/openapi.json +198 -3
  9. package/docs/api-reference.md +7 -3
  10. package/docs/copy-inventory.md +412 -407
  11. package/docs/copy-registry.json +548 -503
  12. package/docs/file-map.md +1 -0
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/page-readings.json +349 -343
  15. package/modules/lifecycle/kickoff-checklist.js +1 -1
  16. package/modules/lifecycle/routes/pay-type-gates.js +7 -2
  17. package/modules/onboarding/project-facts.js +318 -0
  18. package/modules/onboarding/routes/genesis-work.js +16 -0
  19. package/modules/provisioning/project-facts.js +55 -0
  20. package/modules/provisioning/provisioning.js +4 -1
  21. package/modules/provisioning/render-sandbox.js +6 -0
  22. package/modules/public-landing/public/projects.html +191 -31
  23. package/package-lock.json +2 -2
  24. package/package.json +2 -1
  25. package/release-notes.json +14 -0
  26. package/scripts/bongos/genesis.js +38 -2
  27. package/scripts/bongos/init.js +13 -0
  28. package/scripts/bongos/package-core.js +2 -0
  29. package/scripts/bongos/provision-render-sandbox.js +5 -1
  30. package/scripts/bongos/render-payload.js +5 -2
  31. package/src/bongos/folder-upload.js +243 -0
  32. package/src/bongos/routes/github.js +109 -16
  33. package/src/bongos/routes.js +3 -0
  34. package/src/branding.js +4 -0
  35. package/src/module-api.js +1 -1
  36. package/src/sandbox/gate.js +164 -0
  37. package/src/sandbox/index.js +144 -0
  38. package/tests/folder_upload_project.mjs +396 -0
  39. package/tests/genesis_work_routes.mjs +25 -1
  40. package/tests/project_facts.mjs +158 -0
  41. package/tests/projects_hub.mjs +1 -1
  42. package/tests/provision_settings_apply.mjs +2 -1
  43. package/tests/provisioning_settings_env.mjs +2 -2
  44. package/tests/public_landing_projects.mjs +27 -5
  45. package/tests/render_sandbox.mjs +7 -4
  46. package/tests/sandbox_gate.mjs +182 -0
@@ -478,6 +478,13 @@ body:has(#view-new:not([hidden])) footer.foot{position:relative;z-index:1;backgr
478
478
  .acctPick .acctTyped{display:flex;align-items:flex-end;gap:14px;}
479
479
  .acctPick .acctTyped .wizField{flex:1;min-width:0;}
480
480
  .acctPick .acctTyped .btn{flex:none;margin:0 0 2px;}
481
+ /* start it from a folder (task 1005282): two buttons that wrap at phone width, then
482
+ the count or each refused file with its reason, one per line */
483
+ .folderPick{max-width:680px;margin-top:18px;padding-top:14px;border-top:1px solid var(--rule);}
484
+ .folderPick .folderBtns{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:8px;}
485
+ .folderPick .folderProblems{margin:6px 0 0;padding:0;list-style:none;max-height:180px;overflow-y:auto;}
486
+ .folderPick .folderProblems li{margin:4px 0;overflow-wrap:anywhere;}
487
+ .folderPick .folderProblems b{font-weight:600;}
481
488
 
482
489
  /* the choice (start fresh / adopt): two cells of one band on hairlines, the
483
490
  chosen one carrying the dot — the dot is the mark, so the icon leaves */
@@ -1798,6 +1805,23 @@ summary{min-height:24px;padding:3px 0;}
1798
1805
  </div>
1799
1806
  <p class="hint" id="raNote" aria-live="polite"></p>
1800
1807
  </div>
1808
+ <!-- start it from a folder on this computer (task 1005282): papers,
1809
+ artwork or scaffolding go into the new private repo as its first
1810
+ commit. The files stay in this tab until Create; the server checks
1811
+ them again and refuses before anything is made. -->
1812
+ <div class="wizField folderPick" id="folderPick">
1813
+ <span>Start it with your files <small>(optional)</small></span>
1814
+ <p class="hint">Papers, artwork or a half-built project: choose the folder and everything in it goes into the new repository. Only you and the people you invite can see it.</p>
1815
+ <div class="folderBtns">
1816
+ <button type="button" class="btn small ghost" id="folderDirBtn">Choose a folder</button>
1817
+ <button type="button" class="btn small ghost" id="folderFilesBtn">Choose files</button>
1818
+ <button type="button" class="btn small ghost" id="folderClear" hidden>Remove</button>
1819
+ <input type="file" id="folderDir" webkitdirectory multiple hidden aria-hidden="true" tabindex="-1">
1820
+ <input type="file" id="folderFilesIn" multiple hidden aria-hidden="true" tabindex="-1">
1821
+ </div>
1822
+ <p class="hint" id="folderNote" aria-live="polite">Up to 50 MB in all, 25 MB a file. No programs, zipped files or password files.</p>
1823
+ <ul class="folderProblems hint err" id="folderProblems" hidden></ul>
1824
+ </div>
1801
1825
  </div>
1802
1826
  <div class="row2" id="manualHome">
1803
1827
  <label class="wizField"><span>GitHub owner <small>(user or org)</small></span>
@@ -2357,6 +2381,9 @@ summary{min-height:24px;padding:3px 0;}
2357
2381
  line.push('. It sleeps when nobody visits and takes about a minute to wake.');
2358
2382
  box.appendChild(appPara(line));
2359
2383
  if (sb.links) box.appendChild(appPara([appLink(sb.links.service, 'Open the sandbox on Render')]));
2384
+ /* the sandbox's safety (task 1005291): what it refused, and the key to lend */
2385
+ if (sb.safety_page) box.appendChild(appPara([appLink(sb.safety_page, 'See what your sandbox refused')]));
2386
+ box.appendChild(appPara(['It has no real keys unless you add them. If you lend one, lend a read-only key: the sandbox refuses sends, payments and changes it can see, but a read-only key is what makes it safe.'], 'finePrint'));
2360
2387
  }
2361
2388
  if (sb.database && sb.database.note) box.appendChild(appPara([String(sb.database.note)], 'finePrint'));
2362
2389
  if (sb.can_add) {
@@ -5703,6 +5730,84 @@ summary{min-height:24px;padding:3px 0;}
5703
5730
  }
5704
5731
  $('ghNewName').addEventListener('input', function () { state.repo = this.value.trim(); updateFreshPreview(); saveDraft(); });
5705
5732
 
5733
+ /* ── start it from a folder (task 1005282) ──
5734
+ A person with papers, artwork or scaffolding and no repository chooses a folder (or
5735
+ files, where the browser has no folder picker — phones) and it becomes the new
5736
+ repo's first commit. The File objects live in this tab only: a draft cannot carry
5737
+ them, so after a reload or a GitHub round-trip the row asks for the folder again.
5738
+ These sizes are a courtesy echo of the server's (src/bongos/folder-upload.js), which
5739
+ checks every file again — type and path included — and refuses before anything is
5740
+ made; its reasons are shown here, one per file. */
5741
+ var FOLDER_MB = 1024 * 1024;
5742
+ var FOLDER_LIMITS = { fileBytes: 25 * FOLDER_MB, totalBytes: 50 * FOLDER_MB, files: 1000 };
5743
+ var FOLDER_SKIP = /(^|\/)(\.ds_store|thumbs\.db|desktop\.ini|ehthumbs\.db)$|(^|\/)(node_modules|__macosx)\//i;
5744
+ var folderFiles = []; /* [{ path, file }] */
5745
+ var folderProblems = []; /* [{ path, reason }], ours or the server's */
5746
+ function folderSize(n) { return n >= FOLDER_MB ? (n / FOLDER_MB).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB'; }
5747
+ /* the problems a size alone can show, before anything is sent */
5748
+ function folderLocalProblems(list) {
5749
+ var out = [], total = 0;
5750
+ list.forEach(function (f) {
5751
+ total += f.file.size;
5752
+ if (f.file.size > FOLDER_LIMITS.fileBytes) out.push({ path: f.path, reason: 'Each file can be at most 25 MB.' });
5753
+ });
5754
+ if (list.length > FOLDER_LIMITS.files) out.unshift({ path: '', reason: 'The folder has ' + list.length + ' files; the most a folder can have is ' + FOLDER_LIMITS.files + '.' });
5755
+ if (total > FOLDER_LIMITS.totalBytes) out.unshift({ path: '', reason: 'The folder is ' + folderSize(total) + '; the most a folder can be is 50 MB.' });
5756
+ return out;
5757
+ }
5758
+ function takeFolder(fileList, fromDir) {
5759
+ var list = [], skipped = 0;
5760
+ Array.prototype.forEach.call(fileList || [], function (f) {
5761
+ var p = (fromDir && f.webkitRelativePath) || f.name;
5762
+ /* the chosen folder's own name is not part of the project */
5763
+ if (fromDir && p.indexOf('/') > 0) p = p.slice(p.indexOf('/') + 1);
5764
+ if (FOLDER_SKIP.test(p)) { skipped++; return; }
5765
+ list.push({ path: p, file: f });
5766
+ });
5767
+ folderFiles = list;
5768
+ folderProblems = folderLocalProblems(list);
5769
+ state.fromFolder = list.length > 0;
5770
+ state.folderCount = list.length;
5771
+ state.folderBytes = list.reduce(function (n, f) { return n + f.file.size; }, 0);
5772
+ state.folderSkipped = skipped;
5773
+ state.folderUploaded = false;
5774
+ state.uploadInto = undefined;
5775
+ saveDraft(); paintFolder(); paintCreate();
5776
+ }
5777
+ function paintFolder() {
5778
+ var note = $('folderNote'), ul = $('folderProblems');
5779
+ while (ul.firstChild) ul.removeChild(ul.firstChild);
5780
+ $('folderClear').hidden = !state.fromFolder;
5781
+ ul.hidden = !folderProblems.length;
5782
+ folderProblems.slice(0, 50).forEach(function (pr) {
5783
+ var li = document.createElement('li');
5784
+ if (pr.path) { var b = document.createElement('b'); b.textContent = pr.path; li.appendChild(b); li.appendChild(document.createTextNode(': ')); }
5785
+ li.appendChild(document.createTextNode(pr.reason));
5786
+ ul.appendChild(li);
5787
+ });
5788
+ if (!state.fromFolder) { note.className = 'hint'; note.textContent = 'Up to 50 MB in all, 25 MB a file. No programs, zipped files or password files.'; return; }
5789
+ if (state.folderUploaded) { note.className = 'hint'; note.textContent = 'Your ' + state.folderCount + ' files are in the repository.'; return; }
5790
+ if (!folderFiles.length) { note.className = 'hint err'; note.textContent = 'Choose your folder again: files aren’t kept while you’re away from this page.'; return; }
5791
+ note.className = folderProblems.length ? 'hint err' : 'hint';
5792
+ note.textContent = folderProblems.length
5793
+ ? (folderProblems.length === 1 ? 'One thing to fix before Create:' : folderProblems.length + ' things to fix before Create:')
5794
+ : state.folderCount + (state.folderCount === 1 ? ' file, ' : ' files, ') + folderSize(state.folderBytes) + ', ready to go into the new repository' + (state.folderSkipped ? ' (' + state.folderSkipped + ' system files left out)' : '') + '.';
5795
+ }
5796
+ /* why the folder cannot go in as it stands, or '' */
5797
+ function folderWhy() {
5798
+ if (!state.fromFolder || state.folderUploaded) return '';
5799
+ if (!folderFiles.length) return 'Choose your folder again: files aren’t kept while you’re away from this page.';
5800
+ return folderProblems.length ? 'Some files can’t go in. Fix them below, then create.' : '';
5801
+ }
5802
+ $('folderDirBtn').addEventListener('click', function () { $('folderDir').click(); });
5803
+ $('folderFilesBtn').addEventListener('click', function () { $('folderFilesIn').click(); });
5804
+ $('folderDir').addEventListener('change', function () { takeFolder(this.files, true); this.value = ''; });
5805
+ $('folderFilesIn').addEventListener('change', function () { takeFolder(this.files, false); this.value = ''; });
5806
+ $('folderClear').addEventListener('click', function () { takeFolder([], false); });
5807
+ /* a browser with no folder picker (phones) offers the files one only */
5808
+ if (!('webkitdirectory' in $('folderDir'))) $('folderDirBtn').hidden = true;
5809
+ paintFolder();
5810
+
5706
5811
  /* ── whose GitHub account: mine or an organisation's (task 1004651) ──
5707
5812
  Two pickers share this: the new repo's owner (part 02's Code row) and the app's
5708
5813
  (part 03). The founder's organisations come from GET /github/orgs; GitHub may not
@@ -6590,6 +6695,7 @@ summary{min-height:24px;padding:3px 0;}
6590
6695
  the organisation the founder picked, task 1004651). */
6591
6696
  if (!validRepoName(state.repo)) return homeErr('Name the new repository (letters, numbers, and . _ -).');
6592
6697
  if (state.repoAcct === 'org' && !state.repoOrg) return homeErr('Pick the organisation for the repo, or put it on your own account.');
6698
+ if (folderWhy()) return homeErr(folderWhy());
6593
6699
  } else {
6594
6700
  /* Adopt (picker or manual) and the manual fallback: need a concrete owner + repo. */
6595
6701
  if (!state.owner || !state.repo) {
@@ -6862,7 +6968,8 @@ summary{min-height:24px;padding:3px 0;}
6862
6968
  if (key === 'code') {
6863
6969
  var adopt = state.mode === 'adopt';
6864
6970
  var where = state.owner && state.repo ? 'github.com/' + state.owner + '/' + state.repo : adopt ? 'Pick the repo to adopt.' : '';
6865
- return { v: adopt ? 'A repo I already have' : 'A new GitHub repo', s: where + (a.project_state === 'existing' ? (where ? ' · ' : '') + 'already started' : '') };
6971
+ var folder = !adopt && state.fromFolder ? ' · with your ' + (state.folderCount || '') + ' files' : '';
6972
+ return { v: adopt ? 'A repo I already have' : 'A new GitHub repo', s: where + folder + (a.project_state === 'existing' ? (where || folder ? ' · ' : '') + 'already started' : '') };
6866
6973
  }
6867
6974
  if (key === 'address') {
6868
6975
  var custom = state.domain && looksLikeDomain(state.domain);
@@ -7002,40 +7109,91 @@ summary{min-height:24px;padding:3px 0;}
7002
7109
  /* the founder's own account sends exactly what it always did; an organisation
7003
7110
  rides as `org`, and the server checks the name again (task 1004651) */
7004
7111
  var repoBody = state.repoAcct === 'org' && state.repoOrg ? { name: state.repo, org: state.repoOrg } : { name: state.repo };
7112
+ /* from a folder (task 1005282): the files ride the same commit moment, and the
7113
+ server refuses the lot, with each file's reason, before the repo exists */
7114
+ if (state.fromFolder) {
7115
+ return folderBody(repoBody).then(function (body) {
7116
+ return fetch(API + '/github/repos/upload', {
7117
+ method: 'POST', credentials: 'same-origin',
7118
+ headers: { 'Content-Type': 'application/json' },
7119
+ body: JSON.stringify(body)
7120
+ });
7121
+ }).then(repoMade);
7122
+ }
7005
7123
  return fetch(API + '/github/repos', {
7006
7124
  method: 'POST', credentials: 'same-origin',
7007
7125
  headers: { 'Content-Type': 'application/json' },
7008
7126
  body: JSON.stringify(repoBody)
7009
- }).then(function (res) {
7010
- return res.json().catch(function () { return {}; }).then(function (data) {
7011
- if (res.status === 401 || res.status === 403) {
7012
- /* the token went between the probe and Create: the hop fetches it again and
7013
- brings the founder back to this screen; if it cannot, the manual fields */
7014
- var hopped = hopForRepoAccess();
7015
- if (!hopped) { repoAccess = 'unavailable'; syncHome(); }
7016
- var e0 = new Error('reconnect'); e0.gotoHome = !hopped;
7017
- e0.homeMsg = 'We couldn’t create the repo on GitHub just now — enter one you made yourself below.';
7018
- throw e0;
7019
- }
7020
- if (!res.ok) {
7021
- var e = new Error((data.error && (data.error.message || data.error.code)) || ('HTTP ' + res.status));
7022
- e.data = data;
7023
- if (data.error && data.error.code === 'name_taken') { e.gotoHome = true; e.homeMsg = data.error.message; }
7024
- /* the organisation said no: its reason, where the owner is chosen */
7025
- if (data.error && /^(org_app_restricted|org_forbidden|org_not_found|bad_org)$/.test(data.error.code || '')) { e.gotoHome = true; e.homeMsg = data.error.message; }
7026
- throw e;
7027
- }
7028
- var full = data.repo && data.repo.full_name;
7029
- if (full && full.indexOf('/') > 0) {
7030
- state.owner = full.slice(0, full.indexOf('/'));
7031
- state.repo = full.slice(full.indexOf('/') + 1);
7127
+ }).then(repoMade);
7128
+ }
7129
+ /* the upload's body: every file as base64, or a retry into the repo a failed push left */
7130
+ function readBase64(file) {
7131
+ return new Promise(function (resolve, reject) {
7132
+ var r = new FileReader();
7133
+ r.onload = function () { var u = String(r.result || ''); resolve(u.slice(u.indexOf(',') + 1)); };
7134
+ r.onerror = function () { reject(r.error || new Error('read failed')); };
7135
+ r.readAsDataURL(file);
7136
+ });
7137
+ }
7138
+ function folderBody(repoBody) {
7139
+ var why = folderWhy();
7140
+ if (why) { var e0 = new Error(why); e0.gotoHome = true; e0.homeMsg = why; return Promise.reject(e0); }
7141
+ return Promise.all(folderFiles.map(function (f) {
7142
+ return readBase64(f.file).then(function (data) { return { path: f.path, data: data }; });
7143
+ })).then(function (files) {
7144
+ return state.uploadInto ? { into: state.uploadInto, files: files } : { name: repoBody.name, org: repoBody.org, files: files };
7145
+ }, function () {
7146
+ var e1 = new Error('read'); e1.gotoHome = true;
7147
+ e1.homeMsg = 'A file couldn’t be read from your computer. Choose the folder again.';
7148
+ throw e1;
7149
+ });
7150
+ }
7151
+ function repoMade(res) {
7152
+ return res.json().catch(function () { return {}; }).then(function (data) {
7153
+ if (res.status === 401 || res.status === 403) {
7154
+ /* the token went between the probe and Create: the hop fetches it again and
7155
+ brings the founder back to this screen; if it cannot, the manual fields */
7156
+ var hopped = hopForRepoAccess();
7157
+ if (!hopped) { repoAccess = 'unavailable'; syncHome(); }
7158
+ var e0 = new Error('reconnect'); e0.gotoHome = !hopped;
7159
+ e0.homeMsg = 'We couldn’t create the repo on GitHub just now — enter one you made yourself below.';
7160
+ throw e0;
7161
+ }
7162
+ if (res.status === 413) {
7163
+ var big = new Error('too big'); big.gotoHome = true;
7164
+ big.homeMsg = 'The folder is bigger than 50 MB, so nothing was created. Choose a smaller folder.';
7165
+ throw big;
7166
+ }
7167
+ if (!res.ok) {
7168
+ var e = new Error((data.error && (data.error.message || data.error.code)) || ('HTTP ' + res.status));
7169
+ e.data = data;
7170
+ var code = (data.error && data.error.code) || '';
7171
+ if (code === 'name_taken') { e.gotoHome = true; e.homeMsg = data.error.message; }
7172
+ /* the organisation said no: its reason, where the owner is chosen */
7173
+ if (/^(org_app_restricted|org_forbidden|org_not_found|bad_org)$/.test(code)) { e.gotoHome = true; e.homeMsg = data.error.message; }
7174
+ /* the folder was refused before anything was made: each file and its reason */
7175
+ if (/^upload_/.test(code)) {
7176
+ var det = data.error.details || {};
7177
+ if (Array.isArray(det.problems)) folderProblems = det.problems.map(function (pr) { return { path: String(pr.path || ''), reason: String(pr.reason || '') }; });
7178
+ /* the repo was made but the files did not reach it: the retry fills that one */
7179
+ if (code === 'upload_failed' && det.repo && det.repo.full_name) state.uploadInto = det.repo.full_name;
7180
+ if (code === 'upload_retry_refused') state.uploadInto = undefined;
7181
+ saveDraft(); paintFolder();
7182
+ e.gotoHome = true; e.homeMsg = data.error.message;
7032
7183
  }
7033
- /* Keep GitHub's own html_url so the done panel can link the repo it made
7034
- (task 1002855). It rides saveDraft(), so a resumed draft keeps the link. */
7035
- var made = data.repo && data.repo.html_url;
7036
- if (typeof made === 'string' && made.indexOf('https://github.com/') === 0) state.repoUrl = made;
7037
- state.repoAutoCreated = true; saveDraft();
7038
- });
7184
+ throw e;
7185
+ }
7186
+ var full = data.repo && data.repo.full_name;
7187
+ if (full && full.indexOf('/') > 0) {
7188
+ state.owner = full.slice(0, full.indexOf('/'));
7189
+ state.repo = full.slice(full.indexOf('/') + 1);
7190
+ }
7191
+ /* Keep GitHub's own html_url so the done panel can link the repo it made
7192
+ (task 1002855). It rides saveDraft(), so a resumed draft keeps the link. */
7193
+ var made = data.repo && data.repo.html_url;
7194
+ if (typeof made === 'string' && made.indexOf('https://github.com/') === 0) state.repoUrl = made;
7195
+ if (data.uploaded) { state.folderUploaded = true; state.uploadInto = undefined; folderFiles = []; paintFolder(); }
7196
+ state.repoAutoCreated = true; saveDraft();
7039
7197
  });
7040
7198
  }
7041
7199
 
@@ -7103,7 +7261,9 @@ summary{min-height:24px;padding:3px 0;}
7103
7261
  existing history instead of pushing a fresh tree, which a repo that already
7104
7262
  has commits rejects as unrelated history. state.mode already tracked
7105
7263
  greenfield|adopt for the UI — it has to reach the server too. */
7106
- onboard_mode: state.mode === 'adopt' ? 'adopt' : 'greenfield',
7264
+ /* a repo made from a folder already has commits, so the scaffold LAYERS onto
7265
+ it (and never overwrites a file) instead of pushing a fresh tree (task 1005282) */
7266
+ onboard_mode: state.mode === 'adopt' || state.folderUploaded ? 'adopt' : 'greenfield',
7107
7267
  /* the page filled itself in from the guide's plan (task 1005274): the hub
7108
7268
  carries the scope answers and the hand-off file into the new project */
7109
7269
  from_guide: state.fromGuide ? true : undefined,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.21",
3
+ "version": "1.25.22",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.21",
9
+ "version": "1.25.22",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.21",
3
+ "version": "1.25.22",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -14,6 +14,7 @@
14
14
  "./bongos/api-path-404": "./src/bongos/api-path-404.js",
15
15
  "./module-loader/loader": "./src/module-loader/loader.js",
16
16
  "./modules": "./src/modules.js",
17
+ "./sandbox": "./src/sandbox/index.js",
17
18
  "./module-seams": "./src/module-seams.js"
18
19
  },
19
20
  "engines": {
@@ -11886,5 +11886,19 @@
11886
11886
  "id": "1005290",
11887
11887
  "text": "Finished work now reaches a project's test copy first; the owner sees what is waiting and presses promote to put it on the live site, and every promotion is recorded."
11888
11888
  }
11889
+ ],
11890
+ "1.25.22": [
11891
+ {
11892
+ "id": "1005282",
11893
+ "text": "People can now start a project by uploading a folder from their computer: papers, artwork or a half-built project. Bongos makes the private repository and puts the files in it, so nobody has to learn git. Uploads over 50 MB, f"
11894
+ },
11895
+ {
11896
+ "id": "1005291",
11897
+ "text": "A project's sandbox is now clearly marked on every page, starts with no real keys, and if real keys are lent it can only read: anything that would send, charge or change a record for real is refused and listed for the owner."
11898
+ },
11899
+ {
11900
+ "id": "1005129",
11901
+ "text": "Choosing how contributors are rewarded now shows, for every option, what the platform really requires and what is closed — and the project now remembers where it is and where its builders are, so those notes fit it."
11902
+ }
11889
11903
  ]
11890
11904
  }
@@ -12,6 +12,9 @@
12
12
  // Usage:
13
13
  // npx bongos genesis show [stage] every stage, or one, with its answers
14
14
  // npx bongos genesis document <stage> the stage's document (task 1005115): what it left behind, editable any time
15
+ // npx bongos genesis facts what the project has said about itself (task 1005129)
16
+ // npx bongos genesis facts set <key> <value> | --clear change one (reward_intents: a,b; minors_on_board: true|false)
17
+ // npx bongos genesis options each reward option: what the server enforces, and the facts beside it
15
18
  // npx bongos genesis context what the founder said, as markdown (for planning sessions)
16
19
  // npx bongos genesis assist <stage> the brief for the founder's AI session (the assisted door)
17
20
  // npx bongos genesis answer <stage> <question> "<words>" [--origin founder|assisted]
@@ -29,7 +32,7 @@
29
32
  const fs = require('node:fs');
30
33
  const { apiCall, requireSession, errCode, cliExit } = require('./cli-lib');
31
34
 
32
- const USAGE = 'usage: npx bongos genesis show [stage] | context | document <stage> | assist <stage> | answer <stage> <question> "<words>" [--origin founder|assisted] | answer <stage> <question> --not-sure|--skip ["why"] | clear <stage> <question> | history <stage> <question> | door <stage> guided|open|assisted | page <stage> "<words>"|--file <path>|--remove | link <stage> <url>|--remove';
35
+ const USAGE = 'usage: npx bongos genesis show [stage] | context | facts [set <key> <value>|--clear] | options | document <stage> | assist <stage> | answer <stage> <question> "<words>" [--origin founder|assisted] | answer <stage> <question> --not-sure|--skip ["why"] | clear <stage> <question> | history <stage> <question> | door <stage> guided|open|assisted | page <stage> "<words>"|--file <path>|--remove | link <stage> <url>|--remove';
33
36
 
34
37
  const STATE_LABEL = { answered: '', not_sure: '[not sure yet] ', skipped: '[skipped] ' };
35
38
  const ORIGIN_LABEL = { assisted: ' (with AI help)', carried_over: ' (carried over — confirm or edit)' };
@@ -82,6 +85,15 @@ function formatAdvice(a) {
82
85
  return out;
83
86
  }
84
87
 
88
+ // Pure: a fact's value from the command line. --clear un-answers it.
89
+ function parseFactValue(key, words) {
90
+ const w = words.join(' ').trim();
91
+ if (w === '--clear') return null;
92
+ if (key === 'minors_on_board') return w === 'true' ? true : w === 'false' ? false : w;
93
+ if (key === 'reward_intents') return w.split(',').map((s) => s.trim()).filter(Boolean);
94
+ return w;
95
+ }
96
+
85
97
  // Pure: parse `answer` arguments into the PUT body, or { error }.
86
98
  function parseAnswer(rest) {
87
99
  const args = [...rest];
@@ -140,6 +152,30 @@ async function main(argv) {
140
152
  console.log(d.markdown || 'The founder has not written anything in genesis yet.');
141
153
  return 0;
142
154
  }
155
+ if (cmd === 'facts' && !stage) {
156
+ const d = await call('GET', '/founding/facts', null, 'read the project facts');
157
+ if (!d) return 1;
158
+ for (const f of d.facts) console.log(`${f.label}: ${f.answered ? (Array.isArray(f.value) ? f.value.join(', ') : String(f.value)) + (f.origin === 'carried_over' ? ' (from project creation)' : '') : 'not answered'}`);
159
+ return 0;
160
+ }
161
+ if (cmd === 'facts' && stage === 'set') {
162
+ const [key, ...val] = rest;
163
+ if (!key || !val.length) { console.error(USAGE); return 2; }
164
+ const d = await call('PUT', '/founding/facts', { facts: { [key]: parseFactValue(key, val) } }, 'save the fact');
165
+ if (!d) return 1;
166
+ console.log('Saved.');
167
+ for (const c of d.changed) {
168
+ for (const n of c.new_notes) console.log(` ${c.label}: now — ${n}`);
169
+ for (const n of c.gone_notes) console.log(` ${c.label}: no longer — ${n}`);
170
+ }
171
+ return 0;
172
+ }
173
+ if (cmd === 'options') {
174
+ const d = await call('GET', '/founding/legalize/options', null, 'read the reward options');
175
+ if (!d) return 1;
176
+ console.log(d.markdown);
177
+ return 0;
178
+ }
143
179
  if (!stage) { console.error(USAGE); return 2; }
144
180
  if (cmd === 'document') {
145
181
  const d = await call('GET', `/founding/work/${enc(stage)}/document`, null, 'read the document');
@@ -214,4 +250,4 @@ if (require.main === module) {
214
250
  .catch((err) => { console.error('fatal:', err && err.message ? err.message : err); cliExit(1); });
215
251
  }
216
252
 
217
- module.exports = { main, formatStage, parseAnswer };
253
+ module.exports = { main, formatStage, parseAnswer, parseFactValue };
@@ -545,6 +545,19 @@ function buildReadme(branding = {}) {
545
545
  '',
546
546
  'Requires Node >= 22 and Postgres installed locally (macOS/Linux; on Windows use WSL). If anything looks off, `bongos doctor` checks your setup and prints the fix.',
547
547
  '',
548
+ // The project's sandbox (task 1005291, ADR 0406 §5): the one line that makes the app
549
+ // behave safely when it runs as its sandbox copy, and nothing at all on the live site.
550
+ "## Your app's sandbox",
551
+ '',
552
+ "If your app runs on Render, Bongos also runs a free sandbox copy of it that takes every finished change first. Put this at the top of a Node app so the sandbox only reads from payment, email and messaging providers, and shows a \"Sandbox\" band on every page:",
553
+ '',
554
+ '```js',
555
+ "const sandbox = require('@bongos/core/sandbox').install(); // does nothing on the live site",
556
+ 'app.use(sandbox.middleware); // Express-style apps: the band, and /__bongos/sandbox',
557
+ '```',
558
+ '',
559
+ 'Seed test data when `BONGOS_SANDBOX=1`, and give the sandbox test keys. If you lend a real key, lend a read-only one.',
560
+ '',
548
561
  '## Where to look next',
549
562
  '',
550
563
  '- `CLAUDE.md` — how this project is built (the operating manual agents read first).',
@@ -330,6 +330,8 @@ function buildPackageJson({ version, sourceDeps, sourceOverrides }) {
330
330
  './bongos/api-path-404': './src/bongos/api-path-404.js',
331
331
  './module-loader/loader': './src/module-loader/loader.js',
332
332
  './modules': './src/modules.js',
333
+ // A project's app loads this to behave as its sandbox (task 1005291, ADR 0406 §5).
334
+ './sandbox': './src/sandbox/index.js',
333
335
  './module-seams': './src/module-seams.js',
334
336
  },
335
337
  engines: { node: '>=22' },
@@ -112,10 +112,14 @@ async function standupSandbox(api, inst, host, deps) {
112
112
  let svc = sb.serviceId ? await api.getService(sb.serviceId).catch(() => null) : null;
113
113
  if (!svc) svc = await api.findServiceByName(name);
114
114
  if (!svc) {
115
+ // The sandbox page's key (task 1005291): kept on the row, which only the owner's
116
+ // project page reads, and set on the sandbox, which checks it.
117
+ const pageToken = require('node:crypto').randomBytes(24).toString('hex');
115
118
  const created = await api.createService(payload.sandboxServicePayload(inst, {
116
119
  ownerId: api.ownerId, repoUrl: `https://github.com/${repo.owner}/${repo.name}`, region, build,
117
- dbUrl: dbStage.state === 'ready' ? dbStage.url : null,
120
+ dbUrl: dbStage.state === 'ready' ? dbStage.url : null, pageToken,
118
121
  }));
122
+ await recordSandbox(db, inst.id, { pageKey: pageToken });
119
123
  svc = created.service;
120
124
  log(` [render] created sandbox service ${svc.id} (free, follows main)`);
121
125
  }
@@ -87,11 +87,14 @@ function renderServicePayload(inst, { ownerId, repoUrl, branch, webPlan, region,
87
87
  // with BONGOS_SANDBOX=1 so the app (and the starter's outbound gate, task 1005291) knows
88
88
  // it is not the live site. `dbUrl` is the sandbox's OWN database, never the live one.
89
89
  const SANDBOX_SUFFIX = 'sandbox';
90
- function sandboxServicePayload(inst, { ownerId, repoUrl, region, build, dbUrl }) {
90
+ // `pageToken` is the sandbox's own page key: its /__bongos/sandbox shows what was refused
91
+ // and which real keys are lent only to a visitor who carries it (the owner, via their
92
+ // owner-only project page). Not a credential to anything else (task 1005291).
93
+ function sandboxServicePayload(inst, { ownerId, repoUrl, region, build, dbUrl, pageToken }) {
91
94
  const p = renderServicePayload(inst, { ownerId, repoUrl, branch: 'main', webPlan: 'free', region, build, dbUrl });
92
95
  p.name = resourceName(inst, SANDBOX_SUFFIX);
93
96
  p.autoDeploy = 'yes';
94
- p.envVars = [{ key: 'BONGOS_SANDBOX', value: '1' }, ...p.envVars];
97
+ p.envVars = [{ key: 'BONGOS_SANDBOX', value: '1' }, ...(pageToken ? [{ key: 'BONGOS_SANDBOX_PAGE_KEY', value: pageToken }] : []), ...p.envVars];
95
98
  return p;
96
99
  }
97
100