@bongos/core 1.25.21 → 1.25.23
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 +89 -49
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
- package/docs/api/openapi.json +198 -3
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +415 -410
- package/docs/copy-registry.json +551 -506
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +382 -376
- package/modules/hall-ui/public/palette.js +11 -4
- package/modules/hall-ui/records/founding-mode.md +6 -0
- package/modules/lifecycle/kickoff-checklist.js +1 -1
- package/modules/lifecycle/routes/pay-type-gates.js +7 -2
- package/modules/onboarding/project-facts.js +318 -0
- package/modules/onboarding/routes/genesis-work.js +16 -0
- package/modules/provisioning/project-facts.js +55 -0
- package/modules/provisioning/provisioning.js +4 -1
- package/modules/provisioning/render-sandbox.js +6 -0
- package/modules/public-landing/public/projects.html +191 -31
- package/package-lock.json +2 -2
- package/package.json +2 -1
- package/release-notes.json +20 -0
- package/scripts/bongos/genesis.js +38 -2
- package/scripts/bongos/init.js +13 -0
- package/scripts/bongos/package-core.js +2 -0
- package/scripts/bongos/provision-render-sandbox.js +5 -1
- package/scripts/bongos/render-payload.js +5 -2
- package/scripts/hall-preview/README.md +1 -0
- package/scripts/hall-preview/server.js +32 -1
- package/src/bongos/folder-upload.js +243 -0
- package/src/bongos/routes/github.js +109 -16
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +4 -0
- package/src/module-api.js +1 -1
- package/src/sandbox/gate.js +164 -0
- package/src/sandbox/index.js +144 -0
- package/tests/folder_upload_project.mjs +396 -0
- package/tests/genesis_work_routes.mjs +25 -1
- package/tests/hall_palette.mjs +59 -2
- package/tests/project_facts.mjs +158 -0
- package/tests/projects_hub.mjs +1 -1
- package/tests/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_settings_env.mjs +2 -2
- package/tests/public_landing_projects.mjs +27 -5
- package/tests/render_sandbox.mjs +7 -4
- package/tests/sandbox_gate.mjs +182 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// src/sandbox/index.js — what a project's app loads to behave as a sandbox (task 1005291;
|
|
3
|
+
// ADR 0406 §5). Published as `@bongos/core/sandbox`.
|
|
4
|
+
//
|
|
5
|
+
// WHY. A project's sandbox is a free copy of its app that takes every finished change first
|
|
6
|
+
// (ADR 0406). It must be visibly a sandbox, and — when the owner lends a real key so it can
|
|
7
|
+
// show real data — it must only read. One line at the top of a Node app does both:
|
|
8
|
+
//
|
|
9
|
+
// const sandbox = require('@bongos/core/sandbox').install();
|
|
10
|
+
// app.use(sandbox.middleware); // Express-style apps; optional
|
|
11
|
+
//
|
|
12
|
+
// `install()` does nothing at all unless BONGOS_SANDBOX=1 (which only the sandbox service
|
|
13
|
+
// carries), so the same code runs unchanged on the live site. In a sandbox it:
|
|
14
|
+
// - puts the outbound gate (gate.js) in front of fetch, http and https;
|
|
15
|
+
// - gives `middleware`, which adds a "Sandbox" band to every HTML page and serves
|
|
16
|
+
// /__bongos/sandbox: what was refused, which real keys are lent, and what the gate
|
|
17
|
+
// cannot catch;
|
|
18
|
+
// - writes every refusal to the log as `[bongos-sandbox] refused …`, which Render shows.
|
|
19
|
+
//
|
|
20
|
+
// Fake data is the app's own to seed: `isSandbox()` lets it seed only here.
|
|
21
|
+
|
|
22
|
+
const { makeLog, wrapFetch, wrapHttp } = require('./gate');
|
|
23
|
+
|
|
24
|
+
const PAGE = '/__bongos/sandbox';
|
|
25
|
+
let state = null;
|
|
26
|
+
|
|
27
|
+
function isSandbox(env = process.env) { return String(env.BONGOS_SANDBOX || '') === '1'; }
|
|
28
|
+
|
|
29
|
+
// Which REAL keys are lent, by provider and kind, never by value: a live key shape in an
|
|
30
|
+
// env var. A restricted / read-only key is named as such, because it is the safe floor.
|
|
31
|
+
const KEY_SHAPES = [
|
|
32
|
+
{ re: /^sk_live_/, says: 'Stripe — a full live secret key. Prefer a restricted key with read access only.', safe: false },
|
|
33
|
+
{ re: /^rk_live_/, says: 'Stripe — a restricted live key. Good: check it has read access only.', safe: true },
|
|
34
|
+
{ re: /^SG\./, says: 'SendGrid — a live API key. Prefer a key with read access only.', safe: false },
|
|
35
|
+
{ re: /^key-[0-9a-f]{32}$/, says: 'Mailgun — a live API key.', safe: false },
|
|
36
|
+
{ re: /^re_[A-Za-z0-9]{8,}/, says: 'Resend — a live API key.', safe: false },
|
|
37
|
+
{ re: /^xox[bpa]-/, says: 'Slack — a live token.', safe: false },
|
|
38
|
+
{ re: /^gh[pousr]_[A-Za-z0-9]{20,}/, says: 'GitHub — a live token. Prefer a fine-grained token with read access only.', safe: false },
|
|
39
|
+
];
|
|
40
|
+
function lentKeys(env = process.env) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const [name, value] of Object.entries(env)) {
|
|
43
|
+
const v = String(value || '');
|
|
44
|
+
const k = KEY_SHAPES.find((s) => s.re.test(v));
|
|
45
|
+
if (k) out.push({ variable: name, says: k.says, readOnly: k.safe });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { escapeHtml: esc } = require('../html-escape'); // the one server-side escaper (task 1004876)
|
|
51
|
+
|
|
52
|
+
/** The band every page carries: plain, fixed, on top, with a link to what it means. It names
|
|
53
|
+
* no count: what was refused is the owner's to see (pageHtml), not every visitor's. */
|
|
54
|
+
function bandHtml() {
|
|
55
|
+
return `<div id="bongos-sandbox-band" role="note" style="position:sticky;top:0;z-index:2147483647;background:#ffd84d;color:#111;font:600 14px/1.4 system-ui,sans-serif;padding:8px 14px;text-align:center;border-bottom:2px solid #111">`
|
|
56
|
+
+ `Sandbox — a test copy of this site. Nothing here reaches real customers.`
|
|
57
|
+
+ ` <a href="${PAGE}" style="color:#111;text-decoration:underline">What this sandbox does</a></div>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Whether a visitor carries this sandbox's page key (BONGOS_SANDBOX_PAGE_KEY, set by the
|
|
61
|
+
// platform when it made the sandbox; the owner's project page links with it). Without it
|
|
62
|
+
// the page says what a sandbox is and what it cannot catch, and nothing else: which real
|
|
63
|
+
// keys are lent and which calls were refused are the owner's to see, not a passer-by's.
|
|
64
|
+
function carriesKey(given, env = process.env) {
|
|
65
|
+
const want = String(env.BONGOS_SANDBOX_PAGE_KEY || '');
|
|
66
|
+
const got = String(given || '');
|
|
67
|
+
if (want.length < 32 || got.length !== want.length) return false;
|
|
68
|
+
return require('node:crypto').timingSafeEqual(Buffer.from(got), Buffer.from(want));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The sandbox's own page. `owner` adds the refusals and the lent keys. */
|
|
72
|
+
function pageHtml(log, env = process.env, { owner = false } = {}) {
|
|
73
|
+
const keys = lentKeys(env);
|
|
74
|
+
const rows = log.items.map((r) => `<li><b>${esc(r.provider)}</b>: would ${esc({ send: 'send a message', charge: 'take or move a payment', write: 'change a record' }[r.kind] || r.kind)} — ${esc(r.method)} ${esc(r.target)} <small>${esc(r.at)}</small></li>`).join('');
|
|
75
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sandbox</title></head>`
|
|
76
|
+
+ `<body style="font:16px/1.5 system-ui,sans-serif;max-width:720px;margin:0 auto;padding:16px">`
|
|
77
|
+
+ `<h1>This is a sandbox</h1>`
|
|
78
|
+
+ `<p>A test copy of your app. Every finished change reaches it first; your live site changes only when you promote.</p>`
|
|
79
|
+
+ (owner
|
|
80
|
+
? `<h2>Refused calls</h2>`
|
|
81
|
+
+ (rows ? `<ul>${rows}</ul>` : `<p>Nothing has been refused since this copy last started.</p>`)
|
|
82
|
+
+ `<h2>Real keys lent to it</h2>`
|
|
83
|
+
+ (keys.length ? `<ul>${keys.map((k) => `<li><code>${esc(k.variable)}</code>: ${esc(k.says)}</li>`).join('')}</ul>` : `<p>None that the sandbox recognises. With test keys or none, nothing it does is real.</p>`)
|
|
84
|
+
: `<p>The project's owner sees what this copy refused from their project page.</p>`)
|
|
85
|
+
+ `<h2>What this cannot catch</h2>`
|
|
86
|
+
+ `<p>Reads to a known provider go out; sends, charges and writes to it are refused. It only sees calls this app makes through Node's own web requests, and only to the providers it knows. Code that goes around them, or a provider it does not know, is not stopped. A read-only key is the real protection; this is the second wall.</p>`
|
|
87
|
+
+ `<p>The free plan also blocks outgoing email ports, so this copy cannot send email directly.</p>`
|
|
88
|
+
+ `</body></html>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** An Express/Connect-style middleware: the page, and the band on every HTML answer. */
|
|
92
|
+
function makeMiddleware(log, env = process.env) {
|
|
93
|
+
return function bongosSandbox(req, res, next) {
|
|
94
|
+
if (req.method === 'GET' && String(req.url || '').split('?')[0] === PAGE) {
|
|
95
|
+
const given = new URL(String(req.url), 'http://sandbox.invalid').searchParams.get('key');
|
|
96
|
+
res.statusCode = 200;
|
|
97
|
+
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
98
|
+
res.setHeader('cache-control', 'no-store');
|
|
99
|
+
res.setHeader('referrer-policy', 'no-referrer');
|
|
100
|
+
return res.end(pageHtml(log, env, { owner: carriesKey(given, env) }));
|
|
101
|
+
}
|
|
102
|
+
const end = res.end;
|
|
103
|
+
const chunks = [];
|
|
104
|
+
const write = res.write;
|
|
105
|
+
const isHtml = () => /text\/html/i.test(String(res.getHeader && res.getHeader('content-type') || ''));
|
|
106
|
+
res.write = function (chunk, ...rest) {
|
|
107
|
+
if (!isHtml()) return write.call(this, chunk, ...rest);
|
|
108
|
+
if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
109
|
+
return true;
|
|
110
|
+
};
|
|
111
|
+
res.end = function (chunk, ...rest) {
|
|
112
|
+
if (!isHtml()) return end.call(this, chunk, ...rest);
|
|
113
|
+
if (chunk && typeof chunk !== 'function') chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
114
|
+
let html = Buffer.concat(chunks).toString('utf8');
|
|
115
|
+
const band = bandHtml();
|
|
116
|
+
html = /<body[^>]*>/i.test(html) ? html.replace(/<body[^>]*>/i, (m) => m + band) : band + html;
|
|
117
|
+
if (res.removeHeader) res.removeHeader('content-length');
|
|
118
|
+
return end.call(this, html);
|
|
119
|
+
};
|
|
120
|
+
next();
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const passThrough = (req, res, next) => next();
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Turn the app into a sandbox when BONGOS_SANDBOX=1; otherwise change nothing. Safe to call
|
|
128
|
+
* more than once. Returns { active, middleware, refusals, bandHtml, isSandbox }.
|
|
129
|
+
*/
|
|
130
|
+
function install({ env = process.env, fetchHost = globalThis, http = require('node:http'), https = require('node:https'), write } = {}) {
|
|
131
|
+
if (!isSandbox(env)) return { active: false, middleware: passThrough, refusals: [], bandHtml: () => '', isSandbox: false };
|
|
132
|
+
if (!state) {
|
|
133
|
+
const log = makeLog(write ? { write } : {});
|
|
134
|
+
if (typeof fetchHost.fetch === 'function') fetchHost.fetch = wrapFetch(fetchHost.fetch, log);
|
|
135
|
+
wrapHttp(http, log);
|
|
136
|
+
wrapHttp(https, log);
|
|
137
|
+
state = { log, middleware: makeMiddleware(log, env) };
|
|
138
|
+
}
|
|
139
|
+
return { active: true, middleware: state.middleware, refusals: state.log.items, bandHtml, isSandbox: true };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The public surface: install() and isSandbox() for the app; pageHtml and PAGE for the tests
|
|
143
|
+
// and the provisioning module's pinned copy of the path.
|
|
144
|
+
module.exports = { install, isSandbox, pageHtml, PAGE };
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// tests/folder_upload_project.mjs — create a project by uploading a folder (task 1005282).
|
|
2
|
+
//
|
|
3
|
+
// A person with a folder of papers, artwork or scaffolding and no repository creates a
|
|
4
|
+
// project from it in the browser; Bongos makes the private repository and commits the
|
|
5
|
+
// files. What this pins, over the REAL route (POST /github/repos/upload) mounted over HTTP,
|
|
6
|
+
// GitHub faked at the fetch boundary and the files pushed by REAL git into a local bare repo:
|
|
7
|
+
// 1. a folder of non-code files becomes a private repo with every file committed, byte-equal;
|
|
8
|
+
// 2. an over-limit upload (one file too big, the folder too big, too many files) is refused
|
|
9
|
+
// with a plain reason BEFORE GitHub is asked for anything;
|
|
10
|
+
// 3. refused file types (programs, archives, key files) are named with their reason;
|
|
11
|
+
// 4. unsafe paths (traversal, absolute, git internals, workflows, platform files,
|
|
12
|
+
// duplicates) are refused, and nothing is created;
|
|
13
|
+
// 5. a push that fails after the repo was made can be retried into THAT repo only.
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test tests/folder_upload_project.mjs
|
|
16
|
+
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { test, before, after } from 'node:test';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import vm from 'node:vm';
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
process.env.BUILDER_SECRET_KEY = process.env.BUILDER_SECRET_KEY || 'ab'.repeat(32);
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
|
|
32
|
+
// ── fake GitHub, at the fetch boundary ──────────────────────────────────────────────
|
|
33
|
+
const realFetch = globalThis.fetch;
|
|
34
|
+
const ghCalls = [];
|
|
35
|
+
globalThis.fetch = async (url, init = {}) => {
|
|
36
|
+
if (!String(url).startsWith('https://api.github.com/')) return realFetch(url, init);
|
|
37
|
+
const u = new URL(String(url));
|
|
38
|
+
const key = `${(init.method || 'GET').toUpperCase()} ${u.pathname}`;
|
|
39
|
+
const body = init.body ? JSON.parse(init.body) : null;
|
|
40
|
+
ghCalls.push({ key, body });
|
|
41
|
+
if (key === 'POST /user/repos') {
|
|
42
|
+
return new Response(JSON.stringify({ full_name: `founder/${body.name}`, html_url: `https://github.com/founder/${body.name}` }), { status: 201 });
|
|
43
|
+
}
|
|
44
|
+
return new Response('{"message":"Not Found"}', { status: 404 });
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const db = require(path.join(ROOT, 'src/bongos/db.js'));
|
|
48
|
+
db.getGithubToken = async () => ({ token: 'tok', scope: 'read:user,repo' });
|
|
49
|
+
db.deleteGithubToken = async () => {};
|
|
50
|
+
const auth = require(path.join(ROOT, 'src/bongos/auth.js'));
|
|
51
|
+
auth.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'founder' }; next(); };
|
|
52
|
+
|
|
53
|
+
const folderUpload = require(path.join(ROOT, 'src/bongos/folder-upload.js'));
|
|
54
|
+
const { UPLOAD_LIMITS, checkFolderUpload, pushFolderToRepo } = folderUpload;
|
|
55
|
+
|
|
56
|
+
// ── each pushed repo is a local bare repo; the real push runs real git into it ─────
|
|
57
|
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'folder-upload-test-'));
|
|
58
|
+
const bare = {}; // "owner/name" → bare repo dir
|
|
59
|
+
const pushes = [];
|
|
60
|
+
let failNextPush = false;
|
|
61
|
+
let holdPushes = null; // a promise every push waits on, to hold uploads in flight
|
|
62
|
+
function bareFor(owner, repo) {
|
|
63
|
+
const key = `${owner}/${repo}`;
|
|
64
|
+
if (!bare[key]) {
|
|
65
|
+
bare[key] = path.join(TMP, `${owner}-${repo}.git`);
|
|
66
|
+
execFileSync('git', ['init', '-q', '--bare', bare[key]]);
|
|
67
|
+
}
|
|
68
|
+
return bare[key];
|
|
69
|
+
}
|
|
70
|
+
const testPush = async (args) => {
|
|
71
|
+
pushes.push({ owner: args.owner, repo: args.repo, files: args.files.length, author: args.author });
|
|
72
|
+
if (holdPushes) await holdPushes;
|
|
73
|
+
if (failNextPush) { failNextPush = false; throw Object.assign(new Error('git failed'), { code: 'GIT_FAILED', stderr: 'remote: boom' }); }
|
|
74
|
+
return pushFolderToRepo({ ...args, remoteUrl: bareFor(args.owner, args.repo) });
|
|
75
|
+
};
|
|
76
|
+
const git = (dir, ...a) => execFileSync('git', ['--git-dir', dir, ...a], { encoding: 'utf8' });
|
|
77
|
+
|
|
78
|
+
const express = require('express');
|
|
79
|
+
const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
|
|
80
|
+
const buildGithubRouter = require(path.join(ROOT, 'src/bongos/routes/github.js'));
|
|
81
|
+
let server, origin;
|
|
82
|
+
before(async () => {
|
|
83
|
+
const app = express();
|
|
84
|
+
app.use(attachFail);
|
|
85
|
+
app.use('/api/bongos', buildGithubRouter({ pushFolderToRepo: testPush }));
|
|
86
|
+
server = app.listen(0);
|
|
87
|
+
await new Promise((r) => server.once('listening', r));
|
|
88
|
+
origin = `http://127.0.0.1:${server.address().port}`;
|
|
89
|
+
});
|
|
90
|
+
after(() => { server.close(); globalThis.fetch = realFetch; fs.rmSync(TMP, { recursive: true, force: true }); });
|
|
91
|
+
|
|
92
|
+
const b64 = (s) => Buffer.from(s).toString('base64');
|
|
93
|
+
const file = (p, content) => ({ path: p, data: b64(content) });
|
|
94
|
+
async function upload(body) {
|
|
95
|
+
const res = await realFetch(`${origin}/api/bongos/github/repos/upload`, {
|
|
96
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
97
|
+
});
|
|
98
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
99
|
+
}
|
|
100
|
+
function reset() { ghCalls.length = 0; pushes.length = 0; }
|
|
101
|
+
|
|
102
|
+
// a few bytes that are not text: a PNG header and a NUL
|
|
103
|
+
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff, 0x10]);
|
|
104
|
+
|
|
105
|
+
// ── 1. a folder of non-code files becomes a project with its files committed ───────
|
|
106
|
+
test('a folder of papers and artwork becomes a private repo with every file committed', async () => {
|
|
107
|
+
reset();
|
|
108
|
+
const files = [
|
|
109
|
+
file('Proposal.md', '# The plan\n\nA community garden.\n'),
|
|
110
|
+
file('papers/Budget 2026.csv', 'item,cost\nseeds,40\r\n'),
|
|
111
|
+
{ path: 'artwork/logo.png', data: PNG.toString('base64') },
|
|
112
|
+
file('notes/meeting notes.txt', 'Tuesday: soil tests.'),
|
|
113
|
+
file('.DS_Store', 'junk'),
|
|
114
|
+
file('node_modules/x/index.js', 'module.exports=1'),
|
|
115
|
+
];
|
|
116
|
+
const r = await upload({ name: 'garden', files });
|
|
117
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
118
|
+
assert.equal(r.body.repo.full_name, 'founder/garden');
|
|
119
|
+
assert.equal(r.body.uploaded.files, 4);
|
|
120
|
+
assert.deepEqual(r.body.uploaded.skipped.sort(), ['.DS_Store', 'node_modules/x/index.js']);
|
|
121
|
+
|
|
122
|
+
const create = ghCalls.find((c) => c.key === 'POST /user/repos');
|
|
123
|
+
assert.ok(create, 'the repo was made on the person’s own account');
|
|
124
|
+
assert.equal(create.body.private, true, 'a repo made from a folder is private');
|
|
125
|
+
assert.equal(create.body.auto_init, false, 'empty, so the folder is its first commit');
|
|
126
|
+
|
|
127
|
+
const dir = bare['founder/garden'];
|
|
128
|
+
const tree = git(dir, 'ls-tree', '-r', '--name-only', 'main').trim().split('\n').sort();
|
|
129
|
+
assert.deepEqual(tree, ['Proposal.md', 'artwork/logo.png', 'notes/meeting notes.txt', 'papers/Budget 2026.csv']);
|
|
130
|
+
assert.ok(Buffer.from(execFileSync('git', ['--git-dir', dir, 'show', 'main:artwork/logo.png'])).equals(PNG), 'binary artwork arrives byte-equal');
|
|
131
|
+
assert.equal(git(dir, 'show', 'main:papers/Budget 2026.csv'), 'item,cost\nseeds,40\r\n', 'line endings untouched');
|
|
132
|
+
assert.equal(git(dir, 'rev-list', '--count', 'main').trim(), '1', 'one commit on main');
|
|
133
|
+
assert.match(git(dir, 'log', '-1', '--format=%an <%ae>', 'main'), /^founder <founder@users\.noreply\.github\.com>/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── 2. over the limits: refused before anything is created ─────────────────────────
|
|
137
|
+
test('a file over 25 MB is refused with its reason, and GitHub is never asked', async () => {
|
|
138
|
+
reset();
|
|
139
|
+
const big = 'A'.repeat(Math.ceil(((UPLOAD_LIMITS.fileBytes + 1) * 4) / 3 / 4) * 4);
|
|
140
|
+
const r = await upload({ name: 'films', files: [file('ok.txt', 'hi'), { path: 'film.mov', data: big }] });
|
|
141
|
+
assert.equal(r.status, 400);
|
|
142
|
+
assert.equal(r.body.error.code, 'upload_refused');
|
|
143
|
+
assert.match(r.body.error.message, /nothing was created/i);
|
|
144
|
+
assert.deepEqual(r.body.error.details.problems.map((p) => [p.path, p.code]), [['film.mov', 'file_too_big']]);
|
|
145
|
+
assert.match(r.body.error.details.problems[0].reason, /25 MB/);
|
|
146
|
+
assert.equal(ghCalls.length, 0, 'no repo was made');
|
|
147
|
+
assert.equal(pushes.length, 0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a folder over 50 MB is refused with a plain reason before anything is created', async () => {
|
|
151
|
+
reset();
|
|
152
|
+
const each = 'A'.repeat(Math.floor((17 * 1024 * 1024 * 4) / 3 / 4) * 4); // ~17 MB each, 51 MB in all
|
|
153
|
+
const r = await upload({ name: 'archive', files: [{ path: 'a.bin', data: each }, { path: 'b.bin', data: each }, { path: 'c.bin', data: each }] });
|
|
154
|
+
assert.equal(r.status, 400);
|
|
155
|
+
assert.equal(r.body.error.code, 'upload_too_big');
|
|
156
|
+
assert.match(r.body.error.message, /the most a folder can be is 50 MB\. Nothing was created\./);
|
|
157
|
+
assert.equal(ghCalls.length, 0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('more than 1,000 files is refused before anything is created', () => {
|
|
161
|
+
const files = Array.from({ length: UPLOAD_LIMITS.files + 1 }, (_, i) => file(`n/${i}.txt`, 'x'));
|
|
162
|
+
const c = checkFolderUpload(files);
|
|
163
|
+
assert.equal(c.ok, false);
|
|
164
|
+
assert.equal(c.code, 'upload_too_many');
|
|
165
|
+
assert.match(c.message, /1001 files; the most a folder can have is 1000/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── 3. refused file types ───────────────────────────────────────────────────────────
|
|
169
|
+
test('programs, zipped files and key files are refused, each with its reason', async () => {
|
|
170
|
+
reset();
|
|
171
|
+
const r = await upload({ name: 'mixed', files: [
|
|
172
|
+
file('readme.txt', 'fine'),
|
|
173
|
+
file('setup.EXE', 'MZ'),
|
|
174
|
+
file('photos.zip', 'PK'),
|
|
175
|
+
file('.env', 'SECRET=1'),
|
|
176
|
+
file('keys/id_rsa', '-----BEGIN'),
|
|
177
|
+
file('certs/site.pem', '-----BEGIN'),
|
|
178
|
+
file('.env.example', 'SECRET='), // documentation, allowed
|
|
179
|
+
] });
|
|
180
|
+
assert.equal(r.status, 400);
|
|
181
|
+
const got = Object.fromEntries(r.body.error.details.problems.map((p) => [p.path, p.code]));
|
|
182
|
+
assert.deepEqual(got, { 'setup.EXE': 'program', 'photos.zip': 'archive', '.env': 'secret', 'keys/id_rsa': 'secret', 'certs/site.pem': 'secret' });
|
|
183
|
+
assert.match(r.body.error.message, /^5 files can’t go in, so nothing was created/);
|
|
184
|
+
const zip = r.body.error.details.problems.find((p) => p.code === 'archive');
|
|
185
|
+
assert.match(zip.reason, /Unzip it/);
|
|
186
|
+
assert.equal(ghCalls.length, 0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── 4. unsafe paths ─────────────────────────────────────────────────────────────────
|
|
190
|
+
test('unsafe paths are refused and nothing is created', async () => {
|
|
191
|
+
reset();
|
|
192
|
+
const bad = {
|
|
193
|
+
'../escape.txt': 'unsafe_path',
|
|
194
|
+
'/etc/passwd': 'unsafe_path',
|
|
195
|
+
'C:/Windows/x.txt': 'unsafe_path',
|
|
196
|
+
'a\\b.txt': 'unsafe_path',
|
|
197
|
+
'a/./b.txt': 'unsafe_path',
|
|
198
|
+
'a//b.txt': 'unsafe_path',
|
|
199
|
+
'trailing. ': 'unsafe_path',
|
|
200
|
+
'bell\u0007.txt': 'unsafe_path',
|
|
201
|
+
'docs/CON.txt': 'unsafe_path',
|
|
202
|
+
'nul': 'unsafe_path',
|
|
203
|
+
'drafts/com1/a.md': 'unsafe_path',
|
|
204
|
+
'.git/config': 'git_internal',
|
|
205
|
+
'sub/.GIT/hooks/post-checkout': 'git_internal',
|
|
206
|
+
'.gitmodules': 'git_internal',
|
|
207
|
+
'.github/workflows/run.yml': 'workflow',
|
|
208
|
+
'.claude/settings.json': 'platform_file',
|
|
209
|
+
'config/branding.json': 'platform_file',
|
|
210
|
+
};
|
|
211
|
+
const files = [file('ok.md', 'ok'), file('Notes.md', 'one'), file('notes.md', 'two'), ...Object.keys(bad).map((p) => file(p, 'x'))];
|
|
212
|
+
const r = await upload({ name: 'unsafe', files });
|
|
213
|
+
assert.equal(r.status, 400);
|
|
214
|
+
const got = Object.fromEntries(r.body.error.details.problems.map((p) => [p.path, p.code]));
|
|
215
|
+
assert.deepEqual(got, { ...bad, 'notes.md': 'duplicate' });
|
|
216
|
+
assert.equal(ghCalls.length, 0);
|
|
217
|
+
assert.equal(pushes.length, 0);
|
|
218
|
+
// the platform's own files are left to it, and the reason says why
|
|
219
|
+
assert.match(r.body.error.details.problems.find((p) => p.code === 'platform_file').reason, /sets your project up/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('pushFolderToRepo never writes outside its work dir, even for a path that slipped the check', async () => {
|
|
223
|
+
await assert.rejects(
|
|
224
|
+
pushFolderToRepo({ owner: 'o', repo: 'r', files: [{ path: '../../escape.txt', data: b64('x') }], remoteUrl: path.join(TMP, 'none.git'), exec: async () => '' }),
|
|
225
|
+
(e) => e.code === 'UNSAFE_PATH',
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('a body that is not base64, or an empty folder, is refused plainly', async () => {
|
|
230
|
+
reset();
|
|
231
|
+
let r = await upload({ name: 'x1', files: [{ path: 'a.txt', data: 'not base64!' }] });
|
|
232
|
+
assert.equal(r.body.error.details.problems[0].code, 'bad_content');
|
|
233
|
+
r = await upload({ name: 'x2', files: [] });
|
|
234
|
+
assert.equal(r.body.error.code, 'upload_empty');
|
|
235
|
+
r = await upload({ name: 'x3', files: [file('.DS_Store', 'x'), file('Thumbs.db', 'y')] });
|
|
236
|
+
assert.equal(r.body.error.code, 'upload_empty');
|
|
237
|
+
assert.equal(ghCalls.length, 0);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ── 5. a failed push is retried into the same repo only ────────────────────────────
|
|
241
|
+
test('a push that fails after the repo is made retries into that repo, and no other', async () => {
|
|
242
|
+
reset();
|
|
243
|
+
failNextPush = true;
|
|
244
|
+
const files = [file('poem.txt', 'roses')];
|
|
245
|
+
let r = await upload({ name: 'poems', files });
|
|
246
|
+
assert.equal(r.status, 502);
|
|
247
|
+
assert.equal(r.body.error.code, 'upload_failed');
|
|
248
|
+
assert.equal(r.body.error.details.repo.full_name, 'founder/poems');
|
|
249
|
+
assert.doesNotMatch(JSON.stringify(r.body), /boom|tok/, 'git’s text and the token stay on the server');
|
|
250
|
+
|
|
251
|
+
r = await upload({ into: 'founder/elsewhere', files });
|
|
252
|
+
assert.equal(r.status, 409);
|
|
253
|
+
assert.equal(r.body.error.code, 'upload_retry_refused');
|
|
254
|
+
|
|
255
|
+
reset();
|
|
256
|
+
r = await upload({ into: 'founder/poems', files });
|
|
257
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
258
|
+
assert.equal(ghCalls.length, 0, 'the retry makes no second repo');
|
|
259
|
+
assert.equal(git(bare['founder/poems'], 'show', 'main:poem.txt'), 'roses');
|
|
260
|
+
|
|
261
|
+
r = await upload({ into: 'founder/poems', files });
|
|
262
|
+
assert.equal(r.status, 409, 'once filled, the retry door closes');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('a third upload while two are held in memory is turned away plainly, and makes nothing', async () => {
|
|
266
|
+
reset();
|
|
267
|
+
let release;
|
|
268
|
+
holdPushes = new Promise((r) => { release = r; });
|
|
269
|
+
const files = [file('a.txt', 'a')];
|
|
270
|
+
const first = upload({ name: 'busy-one', files });
|
|
271
|
+
const second = upload({ name: 'busy-two', files });
|
|
272
|
+
while (pushes.length < 2) await new Promise((r) => setTimeout(r, 10));
|
|
273
|
+
const third = await upload({ name: 'busy-three', files });
|
|
274
|
+
assert.equal(third.status, 503);
|
|
275
|
+
assert.equal(third.body.error.code, 'upload_busy');
|
|
276
|
+
assert.match(third.body.error.message, /Nothing was created\. Try again in a minute\./);
|
|
277
|
+
assert.ok(!ghCalls.some((c) => c.body && c.body.name === 'busy-three'), 'no repo for the turned-away upload');
|
|
278
|
+
holdPushes = null; release();
|
|
279
|
+
assert.equal((await first).status, 201);
|
|
280
|
+
assert.equal((await second).status, 201);
|
|
281
|
+
assert.equal((await upload({ name: 'busy-four', files })).status, 201, 'the door reopens once they finish');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── 6. the page: the wizard's own code, sliced out of projects.html and run in a vm ──
|
|
285
|
+
// The folder picker and the repo create (ensureFreshRepo) run for real under a small DOM,
|
|
286
|
+
// reading fake Files through a FileReader stand-in and posting to the REAL route above.
|
|
287
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
288
|
+
const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
|
|
289
|
+
function region(startMark, endMark) {
|
|
290
|
+
const s = SCRIPT.indexOf(startMark);
|
|
291
|
+
const e = s < 0 ? -1 : SCRIPT.indexOf(endMark, s);
|
|
292
|
+
return s > 0 && e > s ? SCRIPT.slice(s, e) : null;
|
|
293
|
+
}
|
|
294
|
+
const FOLDER = region(' /* ── start it from a folder (task 1005282) ──', ' /* ── whose GitHub account: mine or an organisation');
|
|
295
|
+
const ENSURE = region(' function ensureFreshRepo() {', " $('doCreate').addEventListener('click'");
|
|
296
|
+
|
|
297
|
+
test('the page regions are still findable, and the folder code builds no markup from strings', () => {
|
|
298
|
+
assert.ok(FOLDER, 'folder region'); assert.ok(ENSURE, 'ensureFreshRepo region');
|
|
299
|
+
assert.doesNotMatch(FOLDER, /innerHTML/);
|
|
300
|
+
assert.match(SCRIPT, /onboard_mode: state\.mode === 'adopt' \|\| state\.folderUploaded \? 'adopt' : 'greenfield'/,
|
|
301
|
+
'a repo made from a folder already has commits, so the scaffold layers onto it');
|
|
302
|
+
// the markup: both pickers inside the new-repo choice
|
|
303
|
+
const fresh = (HUB.match(/<div id="ghFresh"[\s\S]*?<div class="row2" id="manualHome">/) || [''])[0];
|
|
304
|
+
assert.match(fresh, /<input type="file" id="folderDir" webkitdirectory multiple hidden/);
|
|
305
|
+
assert.match(fresh, /<input type="file" id="folderFilesIn" multiple hidden/);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
class El {
|
|
309
|
+
constructor(id, tag = 'div') { this.id = id; this.tagName = tag; this.hidden = false; this.textContent = ''; this.className = ''; this.children = []; this.listeners = {}; this.value = ''; }
|
|
310
|
+
addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); }
|
|
311
|
+
fire(ev) { (this.listeners[ev] || []).forEach((fn) => fn.call(this, {})); }
|
|
312
|
+
click() { this.clicked = true; }
|
|
313
|
+
appendChild(c) { this.children.push(c); return c; }
|
|
314
|
+
removeChild(c) { this.children = this.children.filter((x) => x !== c); }
|
|
315
|
+
get firstChild() { return this.children[0] || null; }
|
|
316
|
+
get text() { return this.children.map((c) => (c.children.length ? c.text : c.textContent)).join(''); }
|
|
317
|
+
}
|
|
318
|
+
// a File stand-in: name, size, the relative path a folder pick gives, and its bytes
|
|
319
|
+
const fakeFile = (rel, content) => { const buf = Buffer.isBuffer(content) ? content : Buffer.from(content); return { name: rel.split('/').pop(), webkitRelativePath: rel, size: buf.length, buf }; };
|
|
320
|
+
function page({ folderPicker = true } = {}) {
|
|
321
|
+
const els = {};
|
|
322
|
+
const $ = (id) => {
|
|
323
|
+
if (!els[id]) { els[id] = new El(id); if (id === 'folderDir' && folderPicker) els[id].webkitdirectory = true; }
|
|
324
|
+
return els[id];
|
|
325
|
+
};
|
|
326
|
+
const posted = [];
|
|
327
|
+
class FileReader { readAsDataURL(f) { setTimeout(() => { this.result = 'data:application/octet-stream;base64,' + f.buf.toString('base64'); this.onload(); }, 0); } }
|
|
328
|
+
const sandbox = {
|
|
329
|
+
API: '/api/bongos', $, JSON, String, Promise, Error, Array, FileReader,
|
|
330
|
+
document: { createElement: (t) => new El(null, t), createTextNode: (t) => { const n = new El(null, '#text'); n.textContent = t; return n; } },
|
|
331
|
+
fetch: (p, init) => { posted.push({ p, body: init && init.body ? JSON.parse(init.body) : null }); return realFetch(origin + p, { method: (init && init.method) || 'GET', headers: init && init.headers, body: init && init.body }); },
|
|
332
|
+
state: { mode: 'greenfield', repo: 'zine', owner: 'founder' },
|
|
333
|
+
repoAccess: true, hopForRepoAccess: () => false, syncHome() {}, saveDraft() {}, paintCreate() {},
|
|
334
|
+
};
|
|
335
|
+
vm.createContext(sandbox);
|
|
336
|
+
vm.runInContext(FOLDER + ENSURE, sandbox);
|
|
337
|
+
const pick = (files) => { $('folderDir').files = files; $('folderDir').fire('change'); };
|
|
338
|
+
return { sb: sandbox, $, posted, pick };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
test('page: a picked folder of artwork goes up without its own folder name, and becomes the repo', async () => {
|
|
342
|
+
reset();
|
|
343
|
+
const p = page();
|
|
344
|
+
assert.equal(p.$('folderDirBtn').hidden, false, 'a browser with a folder picker offers it');
|
|
345
|
+
p.pick([fakeFile('My Zine/cover.png', PNG), fakeFile('My Zine/text/issue-1.md', '# Issue one'), fakeFile('My Zine/.DS_Store', 'x')]);
|
|
346
|
+
assert.equal(p.sb.state.fromFolder, true);
|
|
347
|
+
assert.equal(p.$('folderNote').textContent, '2 files, 1 KB, ready to go into the new repository (1 system files left out).');
|
|
348
|
+
assert.equal(p.sb.folderWhy(), '');
|
|
349
|
+
await p.sb.ensureFreshRepo();
|
|
350
|
+
const sent = p.posted.find((x) => x.p === '/api/bongos/github/repos/upload');
|
|
351
|
+
assert.deepEqual(sent.body.files.map((f) => f.path), ['cover.png', 'text/issue-1.md'], 'paths are inside the folder, never its own name');
|
|
352
|
+
assert.equal(sent.body.name, 'zine');
|
|
353
|
+
assert.equal(p.sb.state.repoAutoCreated, true);
|
|
354
|
+
assert.equal(p.sb.state.folderUploaded, true, 'so the create sends onboard_mode adopt');
|
|
355
|
+
assert.equal(p.sb.state.repoUrl, 'https://github.com/founder/zine');
|
|
356
|
+
assert.ok(Buffer.from(execFileSync('git', ['--git-dir', bare['founder/zine'], 'show', 'main:cover.png'])).equals(PNG));
|
|
357
|
+
assert.equal(p.$('folderNote').textContent, 'Your 2 files are in the repository.');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('page: a file over 25 MB is named before anything is sent, and Create is held', async () => {
|
|
361
|
+
reset();
|
|
362
|
+
const p = page();
|
|
363
|
+
const huge = { name: 'film.mov', webkitRelativePath: 'Clips/film.mov', size: 26 * 1024 * 1024, buf: Buffer.alloc(0) };
|
|
364
|
+
p.pick([fakeFile('Clips/a.txt', 'a'), huge]);
|
|
365
|
+
assert.match(p.$('folderNote').textContent, /^One thing to fix before Create:/);
|
|
366
|
+
assert.equal(p.$('folderProblems').hidden, false);
|
|
367
|
+
assert.equal(p.$('folderProblems').children[0].text, 'film.mov: Each file can be at most 25 MB.');
|
|
368
|
+
await assert.rejects(p.sb.ensureFreshRepo(), (e) => e.gotoHome && /Fix them below/.test(e.homeMsg));
|
|
369
|
+
assert.equal(p.posted.length, 0, 'nothing was sent');
|
|
370
|
+
assert.equal(ghCalls.length, 0, 'nothing was created');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('page: the server’s refusal shows each refused file with its reason, and nothing is created', async () => {
|
|
374
|
+
reset();
|
|
375
|
+
const p = page();
|
|
376
|
+
p.sb.state.repo = 'refused';
|
|
377
|
+
p.pick([fakeFile('Stuff/notes.txt', 'n'), fakeFile('Stuff/install.exe', 'MZ'), fakeFile('Stuff/.git/config', '[core]')]);
|
|
378
|
+
await assert.rejects(p.sb.ensureFreshRepo(), (e) => e.gotoHome && /2 files can’t go in, so nothing was created/.test(e.homeMsg));
|
|
379
|
+
const lines = p.$('folderProblems').children.map((li) => li.text);
|
|
380
|
+
assert.deepEqual(lines, [
|
|
381
|
+
'install.exe: Programs, installers and disk images can’t go in. They can’t be read as project material and can carry harm.',
|
|
382
|
+
'.git/config: Git’s own internal files can’t be uploaded. Choose the folder without its .git folder.',
|
|
383
|
+
]);
|
|
384
|
+
assert.equal(p.sb.state.repoAutoCreated, undefined);
|
|
385
|
+
assert.equal(ghCalls.length, 0);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('page: on a phone (no folder picker) only "Choose files" is offered, and a reload asks for the folder again', () => {
|
|
389
|
+
const p = page({ folderPicker: false });
|
|
390
|
+
assert.equal(p.$('folderDirBtn').hidden, true);
|
|
391
|
+
p.$('folderFilesBtn').fire('click');
|
|
392
|
+
assert.equal(p.$('folderFilesIn').clicked, true);
|
|
393
|
+
// a draft that remembers a folder, but no File objects (they cannot ride a draft)
|
|
394
|
+
p.sb.state.fromFolder = true; p.sb.state.folderCount = 3;
|
|
395
|
+
assert.match(p.sb.folderWhy(), /Choose your folder again/);
|
|
396
|
+
});
|
|
@@ -32,7 +32,15 @@ const store = memStore();
|
|
|
32
32
|
const app = express();
|
|
33
33
|
app.use(express.json());
|
|
34
34
|
const router = express.Router();
|
|
35
|
-
|
|
35
|
+
// The project's facts (task 1005129) live in project_settings: an in-memory one here.
|
|
36
|
+
const settings = new Map();
|
|
37
|
+
mount(router, {
|
|
38
|
+
store,
|
|
39
|
+
getSetting: async (k) => (settings.has(k) ? { key: k, value: settings.get(k) } : null),
|
|
40
|
+
setSetting: async (k, v) => { settings.set(k, String(v)); },
|
|
41
|
+
factsMarker: null,
|
|
42
|
+
payGates: null,
|
|
43
|
+
});
|
|
36
44
|
app.use('/api/bongos', router);
|
|
37
45
|
const server = app.listen(0);
|
|
38
46
|
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
@@ -76,6 +84,22 @@ try {
|
|
|
76
84
|
assert.equal((await call('GET', '/founding/work/nope/document')).status, 404);
|
|
77
85
|
});
|
|
78
86
|
|
|
87
|
+
await test('task 1005129: any builder reads the facts and options; only the owner changes a fact', async () => {
|
|
88
|
+
assert.equal((await call('GET', '/founding/facts', { as: null })).status, 401);
|
|
89
|
+
let r = await call('GET', '/founding/facts', { as: OTHER });
|
|
90
|
+
assert.equal(r.status, 200);
|
|
91
|
+
assert.equal(r.data.unanswered.length, 6, 'a project not made through the hub: every fact not answered');
|
|
92
|
+
assert.equal((await call('PUT', '/founding/facts', { as: OTHER, body: { facts: { builder_geography: 'global' } } })).status, 403);
|
|
93
|
+
assert.equal((await call('PUT', '/founding/facts', { body: { facts: { builder_geography: 'mars' } } })).status, 400);
|
|
94
|
+
const w = await call('PUT', '/founding/facts', { body: { facts: { builder_geography: 'global' } } });
|
|
95
|
+
assert.equal(w.status, 200);
|
|
96
|
+
assert.equal(w.data.facts.find((f) => f.key === 'builder_geography').value, 'global');
|
|
97
|
+
r = await call('GET', '/founding/legalize/options', { as: OTHER });
|
|
98
|
+
assert.equal(r.status, 200);
|
|
99
|
+
assert.equal(r.data.rules_module, false);
|
|
100
|
+
assert.match(r.data.markdown, /Where its builders are: global/);
|
|
101
|
+
});
|
|
102
|
+
|
|
79
103
|
await test('a builder who is not the owner cannot write', async () => {
|
|
80
104
|
for (const [m, p, body] of [
|
|
81
105
|
['PUT', '/founding/work/strategize/answers/risks', { text: 'x' }],
|