@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.
- package/.bongos-core.json +84 -44
- 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 +412 -407
- package/docs/copy-registry.json +548 -503
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +349 -343
- 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 +14 -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/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/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,243 @@
|
|
|
1
|
+
// src/bongos/folder-upload.js — create a project from a folder: check an uploaded folder
|
|
2
|
+
// (papers, artwork, scaffolding) and commit it into the new private repository, so a
|
|
3
|
+
// person with no repository never touches git (task 1005282).
|
|
4
|
+
//
|
|
5
|
+
// Two halves:
|
|
6
|
+
// checkFolderUpload(files) — PURE. Decides, before anything exists, whether the upload
|
|
7
|
+
// can become a repository: the size limits, the refused file types, and path safety.
|
|
8
|
+
// A refusal names each file and the plain reason, so the page can say it.
|
|
9
|
+
// pushFolderToRepo(...) — writes the checked files to a temp dir, makes ONE commit and
|
|
10
|
+
// pushes it to `main` of the empty repo the route just created, acting as the person's
|
|
11
|
+
// OWN GitHub token. One git push, not one API call per file: GitHub's content-creation
|
|
12
|
+
// rate limits (about 80 writes a minute) would stall a few hundred files otherwise.
|
|
13
|
+
//
|
|
14
|
+
// Nothing here reads the files' meaning and nothing is sent to an AI service: the platform
|
|
15
|
+
// does not read people's files on its own AI bill. The bytes go from the request to the
|
|
16
|
+
// repository and the temp copy is deleted in `finally`.
|
|
17
|
+
//
|
|
18
|
+
// The credential never sits in argv or a remote: it rides one push as an http.extraHeader
|
|
19
|
+
// set through GIT_CONFIG_* environment variables, and any error text is scrubbed.
|
|
20
|
+
|
|
21
|
+
const { execFile } = require('node:child_process');
|
|
22
|
+
const fsp = require('node:fs/promises');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const { promisify } = require('node:util');
|
|
26
|
+
|
|
27
|
+
const execFileP = promisify(execFile);
|
|
28
|
+
|
|
29
|
+
const MB = 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
// The limits, and why (stated in the task's ship notes too):
|
|
32
|
+
// • 25 MB a file — GitHub's own "upload files" page takes 25 MB a file, and git pushes
|
|
33
|
+
// warn at 50 MB and refuse at 100 MB, so this keeps every file well clear of both.
|
|
34
|
+
// • 50 MB and 1,000 files an upload — the whole folder passes through our small server's
|
|
35
|
+
// memory once (about 67 MB as it travels), and cost discipline says the hub box is not
|
|
36
|
+
// a file store. A bigger project can be put on GitHub directly and picked as
|
|
37
|
+
// "A repo I already have".
|
|
38
|
+
const UPLOAD_LIMITS = Object.freeze({
|
|
39
|
+
fileBytes: 25 * MB,
|
|
40
|
+
totalBytes: 50 * MB,
|
|
41
|
+
files: 1000,
|
|
42
|
+
pathChars: 300,
|
|
43
|
+
depth: 32,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// The request body is base64 inside JSON: 4/3 of the bytes, plus names and brackets.
|
|
47
|
+
const UPLOAD_BODY_LIMIT = `${Math.ceil((UPLOAD_LIMITS.totalBytes * 4) / 3 / MB) + 4}mb`;
|
|
48
|
+
|
|
49
|
+
// File types refused by extension (lower-cased, without the dot), each with its reason.
|
|
50
|
+
const PROGRAM_EXT = ['exe', 'dll', 'msi', 'msix', 'appx', 'scr', 'com', 'pif', 'cpl', 'sys', 'dmg', 'pkg', 'apk', 'aab', 'ipa', 'deb', 'rpm', 'iso', 'img', 'vhd', 'vhdx', 'vmdk'];
|
|
51
|
+
const ARCHIVE_EXT = ['zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'xz'];
|
|
52
|
+
const SECRET_EXT = ['pem', 'p12', 'pfx', 'jks', 'keystore'];
|
|
53
|
+
const SECRET_NAMES = ['id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519', '.netrc', '.pgpass'];
|
|
54
|
+
// .env.example and friends are documentation, not secrets
|
|
55
|
+
const ENV_DOC_RE = /^\.env\.(example|sample|template|dist)$/i;
|
|
56
|
+
|
|
57
|
+
const REASONS = Object.freeze({
|
|
58
|
+
program: 'Programs, installers and disk images can’t go in. They can’t be read as project material and can carry harm.',
|
|
59
|
+
archive: 'Zipped files can’t go in. Unzip it on your computer and choose the folder instead, so its files can be seen.',
|
|
60
|
+
secret: 'This looks like a password or key file. Keep secrets out of the project; nothing was uploaded.',
|
|
61
|
+
file_too_big: `Each file can be at most ${UPLOAD_LIMITS.fileBytes / MB} MB. Make it smaller or split it, or leave it out and add it on GitHub later.`,
|
|
62
|
+
unsafe_path: 'This file’s name or folder isn’t allowed: it points outside the folder, or uses a name or characters a project can’t hold. Rename it with plain letters, numbers, spaces and dashes.',
|
|
63
|
+
git_internal: 'Git’s own internal files can’t be uploaded. Choose the folder without its .git folder.',
|
|
64
|
+
workflow: 'Files in .github/workflows would start automatic jobs on GitHub, so they can’t be uploaded this way. Leave that folder out; you can add it on GitHub later.',
|
|
65
|
+
platform_file: 'The platform writes this file itself when it sets your project up. Rename or remove yours so neither is lost.',
|
|
66
|
+
duplicate: 'Two files have this same name (apart from capital letters). Rename one.',
|
|
67
|
+
bad_content: 'This file didn’t arrive whole. Choose the folder again.',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Paths the platform's own setup layer writes (scripts/bongos/adopt-preflight.js
|
|
71
|
+
// INCOMING_PATHS). The setup never overwrites a file already in the repo, so an uploaded
|
|
72
|
+
// copy here would silently replace the platform's: refuse it and say so instead.
|
|
73
|
+
const PLATFORM_FILES = ['config/branding.json', 'config/modules.json', 'config/hierarchy.json'];
|
|
74
|
+
const PLATFORM_DIRS = ['.claude/'];
|
|
75
|
+
|
|
76
|
+
// Left out quietly and reported as skipped: OS clutter and installed packages (rebuilt
|
|
77
|
+
// from package.json; never part of anyone's papers).
|
|
78
|
+
const SKIP_NAMES = ['.ds_store', 'thumbs.db', 'desktop.ini', 'ehthumbs.db'];
|
|
79
|
+
const SKIP_DIRS = ['node_modules', '__macosx'];
|
|
80
|
+
|
|
81
|
+
// eslint-disable-next-line no-control-regex
|
|
82
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/;
|
|
83
|
+
// Windows device names (CON, NUL, COM1…), with or without an extension: a clone on Windows
|
|
84
|
+
// cannot hold them, and on a Windows host the temp copy would write to the device.
|
|
85
|
+
const WIN_RESERVED_RE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i;
|
|
86
|
+
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
87
|
+
|
|
88
|
+
function extOf(name) {
|
|
89
|
+
const i = name.lastIndexOf('.');
|
|
90
|
+
return i > 0 ? name.slice(i + 1).toLowerCase() : '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Why this one path is unsafe, or null. Paths are relative, '/'-separated, and stay
|
|
94
|
+
// inside the folder: no leading slash, drive letter, backslash, '.', '..', or empty part.
|
|
95
|
+
function unsafePathReason(p) {
|
|
96
|
+
if (typeof p !== 'string' || !p || p.length > UPLOAD_LIMITS.pathChars) return 'unsafe_path';
|
|
97
|
+
if (CONTROL_RE.test(p) || p.includes('\\') || p.startsWith('/') || /^[A-Za-z]:/.test(p)) return 'unsafe_path';
|
|
98
|
+
const parts = p.split('/');
|
|
99
|
+
if (parts.length > UPLOAD_LIMITS.depth) return 'unsafe_path';
|
|
100
|
+
for (const part of parts) {
|
|
101
|
+
if (!part || part === '.' || part === '..' || part.length > 255) return 'unsafe_path';
|
|
102
|
+
if (/[ .]$/.test(part)) return 'unsafe_path'; // Windows drops these silently: two names could become one
|
|
103
|
+
if (WIN_RESERVED_RE.test(part)) return 'unsafe_path';
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The rule a safe path breaks, or null: git internals, workflows, platform-owned files,
|
|
109
|
+
// and refused file types.
|
|
110
|
+
function pathRuleReason(p) {
|
|
111
|
+
const lower = p.toLowerCase();
|
|
112
|
+
const parts = lower.split('/');
|
|
113
|
+
const name = parts[parts.length - 1];
|
|
114
|
+
if (parts.includes('.git') || name === '.gitmodules') return 'git_internal';
|
|
115
|
+
if (lower.startsWith('.github/workflows/')) return 'workflow';
|
|
116
|
+
if (PLATFORM_FILES.includes(lower) || PLATFORM_DIRS.some((d) => lower.startsWith(d))) return 'platform_file';
|
|
117
|
+
if (name === '.env' || (name.startsWith('.env.') && !ENV_DOC_RE.test(name)) || SECRET_NAMES.includes(name)) return 'secret';
|
|
118
|
+
const ext = extOf(name);
|
|
119
|
+
if (SECRET_EXT.includes(ext)) return 'secret';
|
|
120
|
+
if (PROGRAM_EXT.includes(ext)) return 'program';
|
|
121
|
+
if (ARCHIVE_EXT.includes(ext)) return 'archive';
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isSkipped(p) {
|
|
126
|
+
const parts = p.toLowerCase().split('/');
|
|
127
|
+
return SKIP_NAMES.includes(parts[parts.length - 1]) || parts.slice(0, -1).some((d) => SKIP_DIRS.includes(d));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Decoded size of a base64 string without decoding it.
|
|
131
|
+
function base64Bytes(s) {
|
|
132
|
+
const pad = s.endsWith('==') ? 2 : s.endsWith('=') ? 1 : 0;
|
|
133
|
+
return Math.floor((s.length * 3) / 4) - pad;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The whole upload, checked. `files` is [{ path, data }] with data as base64.
|
|
137
|
+
// Returns { ok, files: [{ path, data }], skipped: [path], problems: [{ path, code, reason }],
|
|
138
|
+
// totals: { files, bytes }, code, message } — `code`/`message` name the headline
|
|
139
|
+
// refusal when !ok. Nothing is decoded or written; this only decides.
|
|
140
|
+
function checkFolderUpload(files) {
|
|
141
|
+
const problems = [];
|
|
142
|
+
const kept = [];
|
|
143
|
+
const skipped = [];
|
|
144
|
+
const seen = new Map();
|
|
145
|
+
let bytes = 0;
|
|
146
|
+
const refuse = (p, code) => problems.push({ path: typeof p === 'string' ? p.slice(0, UPLOAD_LIMITS.pathChars) : '', code, reason: REASONS[code] });
|
|
147
|
+
|
|
148
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
149
|
+
return { ok: false, files: [], skipped, problems, totals: { files: 0, bytes: 0 }, code: 'upload_empty', message: 'The folder had no files in it. Choose a folder with at least one file.' };
|
|
150
|
+
}
|
|
151
|
+
for (const f of files) {
|
|
152
|
+
const p = f && f.path;
|
|
153
|
+
const data = f && f.data;
|
|
154
|
+
const unsafe = unsafePathReason(p);
|
|
155
|
+
if (unsafe) { refuse(p, unsafe); continue; }
|
|
156
|
+
if (isSkipped(p)) { skipped.push(p); continue; }
|
|
157
|
+
if (typeof data !== 'string' || !BASE64_RE.test(data) || data.length % 4 !== 0) { refuse(p, 'bad_content'); continue; }
|
|
158
|
+
const rule = pathRuleReason(p);
|
|
159
|
+
if (rule) { refuse(p, rule); continue; }
|
|
160
|
+
const size = base64Bytes(data);
|
|
161
|
+
if (size > UPLOAD_LIMITS.fileBytes) { refuse(p, 'file_too_big'); continue; }
|
|
162
|
+
const key = p.toLowerCase();
|
|
163
|
+
if (seen.has(key)) { refuse(p, 'duplicate'); continue; }
|
|
164
|
+
seen.set(key, true);
|
|
165
|
+
bytes += size;
|
|
166
|
+
kept.push({ path: p, data });
|
|
167
|
+
}
|
|
168
|
+
const totals = { files: kept.length, bytes };
|
|
169
|
+
if (problems.length) {
|
|
170
|
+
const n = problems.length;
|
|
171
|
+
return { ok: false, files: [], skipped, problems, totals, code: 'upload_refused', message: n === 1 ? 'One file can’t go in, so nothing was created. Remove or fix it, then try again.' : `${n} files can’t go in, so nothing was created. Remove or fix them, then try again.` };
|
|
172
|
+
}
|
|
173
|
+
if (kept.length > UPLOAD_LIMITS.files) {
|
|
174
|
+
return { ok: false, files: [], skipped, problems, totals, code: 'upload_too_many', message: `The folder has ${kept.length} files; the most a folder can have is ${UPLOAD_LIMITS.files}. Nothing was created.` };
|
|
175
|
+
}
|
|
176
|
+
if (bytes > UPLOAD_LIMITS.totalBytes) {
|
|
177
|
+
return { ok: false, files: [], skipped, problems, totals, code: 'upload_too_big', message: `The folder is ${(bytes / MB).toFixed(1)} MB; the most a folder can be is ${UPLOAD_LIMITS.totalBytes / MB} MB. Nothing was created.` };
|
|
178
|
+
}
|
|
179
|
+
if (!kept.length) {
|
|
180
|
+
return { ok: false, files: [], skipped, problems, totals, code: 'upload_empty', message: 'Nothing in the folder could go in (only system clutter or installed packages). Choose a folder with your own files.' };
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, files: kept, skipped, problems, totals, code: null, message: null };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Hide any token in text that may be logged or returned.
|
|
186
|
+
function scrub(s) {
|
|
187
|
+
return String(s || '')
|
|
188
|
+
.replace(/(ghs_|ghp_|gho_|ghu_|github_pat_)[A-Za-z0-9_]+/g, '$1***')
|
|
189
|
+
.replace(/(authorization:\s*basic\s+)[A-Za-z0-9+/=]+/gi, '$1***');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Default git seam: execFile arg-array, shell:false (the injection discipline used by
|
|
193
|
+
// modules/lifecycle/github-push.js). Tests inject their own.
|
|
194
|
+
async function defaultExec(args, opts = {}) {
|
|
195
|
+
try {
|
|
196
|
+
const { stdout } = await execFileP('git', args, { encoding: 'utf8', timeout: 120000, maxBuffer: 16 * MB, ...opts, shell: false });
|
|
197
|
+
return stdout;
|
|
198
|
+
} catch (e) {
|
|
199
|
+
const out = new Error('git failed');
|
|
200
|
+
out.code = 'GIT_FAILED';
|
|
201
|
+
out.stderr = scrub(e && (e.stderr || e.message)).slice(-2000);
|
|
202
|
+
throw out;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Commit the checked files into the EMPTY repo owner/repo as one commit on `main`, and
|
|
207
|
+
// push it with the person's token. `remoteUrl` and `exec` are the test seams (a test pushes
|
|
208
|
+
// to a local bare repo). The temp copy is always removed.
|
|
209
|
+
async function pushFolderToRepo({ token, owner, repo, files, author, exec = defaultExec, remoteUrl } = {}) {
|
|
210
|
+
// async throughout: this runs inside a request, and a sync write of up to 50 MB would
|
|
211
|
+
// stall every other request on the one Node process
|
|
212
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bongos-folder-'));
|
|
213
|
+
try {
|
|
214
|
+
const work = path.join(dir, 'w');
|
|
215
|
+
// the paths were checked by checkFolderUpload; resolve once more so nothing can land
|
|
216
|
+
// outside the work dir whatever slipped past — all of them, before anything is written
|
|
217
|
+
const dests = files.map((f) => {
|
|
218
|
+
const dest = path.resolve(work, ...f.path.split('/'));
|
|
219
|
+
if (!dest.startsWith(work + path.sep)) throw Object.assign(new Error('unsafe path'), { code: 'UNSAFE_PATH' });
|
|
220
|
+
return dest;
|
|
221
|
+
});
|
|
222
|
+
// each folder once, not once per file
|
|
223
|
+
for (const d of new Set([work, ...dests.map((d) => path.dirname(d))])) await fsp.mkdir(d, { recursive: true });
|
|
224
|
+
for (let i = 0; i < files.length; i++) await fsp.writeFile(dests[i], Buffer.from(files[i].data, 'base64'));
|
|
225
|
+
const who = author && author.name ? author : { name: 'Cloud Bongos upload', email: 'upload@users.noreply.github.com' };
|
|
226
|
+
const url = remoteUrl || `https://github.com/${owner}/${repo}.git`;
|
|
227
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
228
|
+
if (token && !remoteUrl) {
|
|
229
|
+
const basic = Buffer.from(`x-access-token:${token}`).toString('base64');
|
|
230
|
+
Object.assign(env, { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basic}` });
|
|
231
|
+
}
|
|
232
|
+
const opts = { cwd: work, env };
|
|
233
|
+
await exec(['init', '-q', '-b', 'main'], opts);
|
|
234
|
+
await exec(['-c', `user.name=${who.name}`, '-c', `user.email=${who.email}`, '-c', 'core.autocrlf=false', 'add', '-A'], opts);
|
|
235
|
+
await exec(['-c', `user.name=${who.name}`, '-c', `user.email=${who.email}`, 'commit', '-q', '-m', 'Add the uploaded folder'], opts);
|
|
236
|
+
await exec(['push', '-q', url, 'HEAD:refs/heads/main'], opts);
|
|
237
|
+
return { files: files.length };
|
|
238
|
+
} finally {
|
|
239
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = { UPLOAD_LIMITS, UPLOAD_BODY_LIMIT, checkFolderUpload, pushFolderToRepo, scrub };
|
|
@@ -26,6 +26,7 @@ const { listUserRepos, createUserRepo, listUserOrgs, getOrgAccess, createOrgRepo
|
|
|
26
26
|
const githubRepos = require('../github-repos');
|
|
27
27
|
const repoInfo = require('../repo-info'); // namespace, so a test can stub loadRepoInfo
|
|
28
28
|
const { validateOrRespond } = require('./_helpers');
|
|
29
|
+
const { UPLOAD_LIMITS, UPLOAD_BODY_LIMIT, checkFolderUpload, pushFolderToRepo, scrub } = require('../folder-upload'); // create a project from an uploaded folder (task 1005282)
|
|
29
30
|
const prelaunch = require('../prelaunch-gate');
|
|
30
31
|
const log = require('../logger').logger.child({ src: 'github' });
|
|
31
32
|
|
|
@@ -66,6 +67,30 @@ async function repoInviteFailure(req, res, err, where) {
|
|
|
66
67
|
return res.fail('github_error', { status: 502, message: 'GitHub could not be reached. Try again in a moment.' });
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
// GitHub's refusals to create a repo, each in the owner's words: shared by the plain create
|
|
71
|
+
// and the create-from-a-folder route. Never a 403 for an org refusal (the page reads a 403
|
|
72
|
+
// as "sign in again").
|
|
73
|
+
async function createRepoFailure(req, res, err, org) {
|
|
74
|
+
if (err && err.code === 'name_taken') {
|
|
75
|
+
return res.fail('name_taken', { status: 409, message: org
|
|
76
|
+
? `${org} already has a repository with that name. Pick another, or adopt the existing one.`
|
|
77
|
+
: 'You already have a repository with that name. Pick another, or adopt the existing one.' });
|
|
78
|
+
}
|
|
79
|
+
if (org && err && err.code === 'org_app_restricted') {
|
|
80
|
+
return res.fail('org_app_restricted', { status: 409, message: `${org} only lets apps its owners have approved act for it. An owner can approve this platform in the organisation's settings (Third-party access), or create the repo yourself.` });
|
|
81
|
+
}
|
|
82
|
+
if (org && err && err.code === 'org_forbidden') {
|
|
83
|
+
return res.fail('org_forbidden', { status: 409, message: `GitHub didn't let your account create a repository in ${org}. Ask one of its owners, or put the project on your own account.` });
|
|
84
|
+
}
|
|
85
|
+
if (org && err && err.code === 'org_not_found') return orgNotFound(res, org);
|
|
86
|
+
if (err && err.status === 401) {
|
|
87
|
+
await db.deleteGithubToken({ builderId: req.builder.id }).catch(() => {});
|
|
88
|
+
return res.fail('repo_scope_required', { status: 403, message: 'Your GitHub authorization expired. Reconnect to create the repo.' });
|
|
89
|
+
}
|
|
90
|
+
console.error('[gds] POST /github/repos', err && err.message);
|
|
91
|
+
return res.fail('github_error', { status: 502, message: 'GitHub could not create the repo. Try again, or create it manually.' });
|
|
92
|
+
}
|
|
93
|
+
|
|
69
94
|
// Parse + validate the owner/name ref, or send a 400 and return null.
|
|
70
95
|
function parseRepoRef(raw, res) {
|
|
71
96
|
const ref = String(raw || '').trim();
|
|
@@ -124,12 +149,28 @@ function parseInviteLogin(raw, res) {
|
|
|
124
149
|
return login;
|
|
125
150
|
}
|
|
126
151
|
|
|
152
|
+
// The folder upload's process-local state (task 1005282): how many uploads are being
|
|
153
|
+
// held in memory at once (each can be ~70 MB, and the hub box is small), and the repo a
|
|
154
|
+
// builder's failed upload left behind, which a retry may fill and nothing else.
|
|
155
|
+
// Process-local ON PURPOSE: the hub runs as one Node process on one box (docs/
|
|
156
|
+
// architecture.md), so a retry reaches the process that made the repo. On a restart the
|
|
157
|
+
// map is empty and a retry is refused plainly ("start again with a new name"), never
|
|
158
|
+
// pointed anywhere else. Entries past the window are swept on every write.
|
|
159
|
+
const MAX_UPLOADS_IN_FLIGHT = 2;
|
|
160
|
+
const RETRY_WINDOW_MS = 60 * 60 * 1000;
|
|
161
|
+
let uploadsInFlight = 0;
|
|
162
|
+
const madeForUpload = new Map();
|
|
163
|
+
function rememberUploadRepo(builderId, repo, now = Date.now()) {
|
|
164
|
+
for (const [id, r] of madeForUpload) if (now - r.at > RETRY_WINDOW_MS) madeForUpload.delete(id);
|
|
165
|
+
madeForUpload.set(builderId, { full_name: repo.full_name, html_url: repo.html_url, at: now });
|
|
166
|
+
}
|
|
167
|
+
|
|
127
168
|
// `deps` is the test seam for the two repo-invite helpers: they are imported directly
|
|
128
169
|
// (so the dead-code check sees them used) and a test hands in stand-ins here, because a
|
|
129
170
|
// destructured import cannot be stubbed on the module object the way the older
|
|
130
171
|
// namespace-called helpers below are.
|
|
131
172
|
module.exports = function buildGithubRouter(overrides = {}) {
|
|
132
|
-
const deps = { inviteRepoCollaborator, repoInviteState, ...overrides };
|
|
173
|
+
const deps = { inviteRepoCollaborator, repoInviteState, pushFolderToRepo: pushFolderToRepo, ...overrides };
|
|
133
174
|
const router = express.Router();
|
|
134
175
|
|
|
135
176
|
// rank: archon (permission access_request.review, the same gate as a hall invite) —
|
|
@@ -261,24 +302,76 @@ module.exports = function buildGithubRouter(overrides = {}) {
|
|
|
261
302
|
const repo = org ? await createOrgRepo(token, { org, name }) : await createUserRepo(token, { name });
|
|
262
303
|
res.status(201).json({ repo });
|
|
263
304
|
} catch (err) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
305
|
+
return createRepoFailure(req, res, err, org);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// rank: any-builder — create a project's repository FROM A FOLDER the person uploaded
|
|
310
|
+
// (task 1005282): papers, artwork or scaffolding, for someone with no repository. The
|
|
311
|
+
// whole upload is checked first (folder-upload.js: sizes, refused types, path safety),
|
|
312
|
+
// and a refusal comes back with each file's plain reason BEFORE the repo exists. Then the
|
|
313
|
+
// private repo is made exactly as POST /github/repos makes it, and the files go in as one
|
|
314
|
+
// commit on main, pushed with the caller's OWN token. The files are never read for
|
|
315
|
+
// meaning and never sent to an AI service. If the push fails after the repo was made,
|
|
316
|
+
// the same builder may retry into THAT repo (`into`), and no other.
|
|
317
|
+
router.post('/github/repos/upload', express.json({ limit: UPLOAD_BODY_LIMIT }), auth.requireBuilder, async (req, res) => {
|
|
318
|
+
if (validateOrRespond(req, res, {
|
|
319
|
+
name: { type: 'string', maxLength: 100 },
|
|
320
|
+
org: { type: 'string', maxLength: 39 },
|
|
321
|
+
into: { type: 'string', maxLength: 140 },
|
|
322
|
+
files: { type: 'array' }, // each entry checked by checkFolderUpload, with its own reason
|
|
323
|
+
})) return;
|
|
324
|
+
const body = req.body || {};
|
|
325
|
+
const into = body.into != null ? String(body.into).trim() : '';
|
|
326
|
+
const name = String(body.name || '').trim();
|
|
327
|
+
const org = body.org != null ? String(body.org).trim() : '';
|
|
328
|
+
if (!into && !REPO_NAME_RE.test(name)) {
|
|
329
|
+
return res.fail('bad_repo_name', { status: 400, message: 'Repository names use letters, numbers, and . _ - (1–100 chars).' });
|
|
330
|
+
}
|
|
331
|
+
if (org && !validGithubLogin(org)) return badOrg(res);
|
|
332
|
+
const checked = checkFolderUpload(body.files);
|
|
333
|
+
if (!checked.ok) {
|
|
334
|
+
return res.fail(checked.code, { status: 400, message: checked.message, details: {
|
|
335
|
+
problems: checked.problems.slice(0, 50), problem_count: checked.problems.length,
|
|
336
|
+
skipped: checked.skipped.length, totals: checked.totals, limits: UPLOAD_LIMITS,
|
|
337
|
+
} });
|
|
338
|
+
}
|
|
339
|
+
let retry = null;
|
|
340
|
+
if (into) {
|
|
341
|
+
retry = madeForUpload.get(req.builder.id);
|
|
342
|
+
if (!retry || retry.full_name !== into || Date.now() - retry.at > RETRY_WINDOW_MS) {
|
|
343
|
+
return res.fail('upload_retry_refused', { status: 409, message: 'Your files can only be retried into the repository this page just made. Start again with a new name.' });
|
|
271
344
|
}
|
|
272
|
-
|
|
273
|
-
|
|
345
|
+
}
|
|
346
|
+
if (uploadsInFlight >= MAX_UPLOADS_IN_FLIGHT) {
|
|
347
|
+
return res.fail('upload_busy', { status: 503, message: 'Too many folders are being uploaded right now. Nothing was created. Try again in a minute.' });
|
|
348
|
+
}
|
|
349
|
+
const token = await resolveRepoToken(req, res);
|
|
350
|
+
if (!token) return;
|
|
351
|
+
uploadsInFlight++;
|
|
352
|
+
try {
|
|
353
|
+
let repo = retry ? { full_name: retry.full_name, html_url: retry.html_url } : null;
|
|
354
|
+
if (!repo) {
|
|
355
|
+
try {
|
|
356
|
+
repo = org ? await createOrgRepo(token, { org, name }) : await createUserRepo(token, { name });
|
|
357
|
+
} catch (err) {
|
|
358
|
+
return createRepoFailure(req, res, err, org);
|
|
359
|
+
}
|
|
360
|
+
rememberUploadRepo(req.builder.id, repo);
|
|
274
361
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
362
|
+
const [owner, repoName] = String(repo.full_name).split('/');
|
|
363
|
+
const login = req.builder.github_login;
|
|
364
|
+
const author = login && validGithubLogin(login) ? { name: login, email: `${login}@users.noreply.github.com` } : null;
|
|
365
|
+
try {
|
|
366
|
+
await deps.pushFolderToRepo({ token, owner, repo: repoName, files: checked.files, author });
|
|
367
|
+
} catch (err) {
|
|
368
|
+
log.error('[gds] POST /github/repos/upload push', scrub(err && (err.stderr || err.message)));
|
|
369
|
+
return res.fail('upload_failed', { status: 502, message: `Your repository ${repo.full_name} was made, but your files didn’t reach it. Try again: they go into the same repository.`, details: { repo } });
|
|
279
370
|
}
|
|
280
|
-
|
|
281
|
-
res.
|
|
371
|
+
madeForUpload.delete(req.builder.id);
|
|
372
|
+
res.status(201).json({ repo, uploaded: { files: checked.totals.files, bytes: checked.totals.bytes, skipped: checked.skipped } });
|
|
373
|
+
} finally {
|
|
374
|
+
uploadsInFlight--;
|
|
282
375
|
}
|
|
283
376
|
});
|
|
284
377
|
|
package/src/bongos/routes.js
CHANGED
|
@@ -131,6 +131,9 @@ function buildGdsRouter() {
|
|
|
131
131
|
// task 1004421: a project's logo — up to 256 KB decoded, ~342 KB as base64.
|
|
132
132
|
// modules/provisioning/routes/look.js mounts its own 400 KB parser.
|
|
133
133
|
/^\/provisioning\/instances\/\d+\/logo$/.test(req.path) ||
|
|
134
|
+
// task 1005282: a project made from an uploaded folder — up to 50 MB of files,
|
|
135
|
+
// ~67 MB as base64. routes/github.js mounts its own parser (folder-upload.js limit).
|
|
136
|
+
req.path === '/github/repos/upload' ||
|
|
134
137
|
// task 1004496: a reference image on a page feedback doc — up to 4 MB decoded,
|
|
135
138
|
// ~5.3 MB as base64. modules/page-notes/routes/page-notes.js mounts its own 8 MB parser.
|
|
136
139
|
/^\/page-notes\/\d+\/images$/.test(req.path) ||
|
package/src/branding.js
CHANGED
|
@@ -116,6 +116,10 @@ const ENV_OVERRIDES = [
|
|
|
116
116
|
// JSON { scope, markdown }, empty when the founder never used the guide. modules/onboarding/guide-handoff.js
|
|
117
117
|
// writes it once: the scope answers as carried over, and the hand-off file. SERVER-ONLY, like the birth.
|
|
118
118
|
['GUIDE_HANDOFF', ['founding', 'guideHandoff']],
|
|
119
|
+
// The project's own facts from the startup flow (task 1005129, modules/provisioning/project-facts.js):
|
|
120
|
+
// base64url JSON { facts, rules }, empty when none were answered. modules/onboarding/project-facts.js
|
|
121
|
+
// copies them once into the project's editable setting. SERVER-ONLY, like the birth.
|
|
122
|
+
['PROJECT_FACTS', ['founding', 'projectFacts']],
|
|
119
123
|
];
|
|
120
124
|
|
|
121
125
|
const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.25.
|
|
84
|
+
const CORE_VERSION = '1.25.22'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// src/sandbox/gate.js — the sandbox's OUTBOUND GATE: reads to a known provider go out,
|
|
3
|
+
// sends, charges and writes are refused and logged (task 1005291; ADR 0406 §5).
|
|
4
|
+
//
|
|
5
|
+
// WHY. A project's sandbox runs the same app as its live site. It starts with no provider
|
|
6
|
+
// keys of its own (the sandbox service is created with only BONGOS_SANDBOX=1 and its own
|
|
7
|
+
// DATABASE_URL), so by default nothing it does can reach a real customer. When the owner
|
|
8
|
+
// LENDS a real key so the sandbox can show real data, this gate is what keeps it to
|
|
9
|
+
// reading: per known provider, a read passes and anything that would send a message, take
|
|
10
|
+
// a payment or change a record is refused, logged, and kept for the owner to see.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT CANNOT DO, said plainly (ADR 0406 §5): it sees only calls made through Node's
|
|
13
|
+
// own `fetch`, `http` and `https` in the process it was installed in. Code that bypasses
|
|
14
|
+
// those (a native binary, a child process, another language), or a provider not in the
|
|
15
|
+
// table below, is NOT caught. That is why the owner should lend a read-only or restricted
|
|
16
|
+
// key where the provider offers one: the key is the floor, the gate is the second wall.
|
|
17
|
+
//
|
|
18
|
+
// `decide()` is pure and is the whole policy; `wrapFetch` / `wrapHttp` only apply it.
|
|
19
|
+
|
|
20
|
+
// Each provider: how to recognise its host, and which calls are reads. Everything that
|
|
21
|
+
// is not a read is refused, named by what it would have done.
|
|
22
|
+
const PROVIDERS = [
|
|
23
|
+
{ name: 'Stripe', host: /(^|\.)stripe\.com$/i, kind: 'charge',
|
|
24
|
+
// A test-mode key cannot move real money, so the gate stays out of its way.
|
|
25
|
+
testKey: /^(sk|rk)_test_/ },
|
|
26
|
+
{ name: 'SendGrid', host: /(^|\.)sendgrid\.com$/i, kind: 'send' },
|
|
27
|
+
{ name: 'Mailgun', host: /(^|\.)mailgun\.net$/i, kind: 'send' },
|
|
28
|
+
{ name: 'Postmark', host: /(^|\.)postmarkapp\.com$/i, kind: 'send' },
|
|
29
|
+
{ name: 'Resend', host: /(^|\.)resend\.com$/i, kind: 'send' },
|
|
30
|
+
{ name: 'Amazon SES', host: /^email(-smtp)?\.[a-z0-9-]+\.amazonaws\.com$/i, kind: 'send' },
|
|
31
|
+
{ name: 'Twilio', host: /(^|\.)twilio\.com$/i, kind: 'send' },
|
|
32
|
+
{ name: 'Slack', host: /(^|\.)slack\.com$/i, kind: 'send' },
|
|
33
|
+
{ name: 'Discord', host: /(^|\.)discord(app)?\.com$/i, kind: 'send' },
|
|
34
|
+
{ name: 'PayPal', host: /(^|\.)paypal\.com$/i, kind: 'charge' },
|
|
35
|
+
{ name: 'Square', host: /(^|\.)squareup\.com$/i, kind: 'charge' },
|
|
36
|
+
{ name: 'Shopify', host: /\.myshopify\.com$/i, kind: 'write' },
|
|
37
|
+
{ name: 'GitHub', host: /^api\.github\.com$/i, kind: 'write' },
|
|
38
|
+
{ name: 'Airtable', host: /^api\.airtable\.com$/i, kind: 'write' },
|
|
39
|
+
// Notion and HubSpot answer some READS by POST (a query, a search), so those paths
|
|
40
|
+
// are named as reads; any other write is refused.
|
|
41
|
+
{ name: 'Notion', host: /^api\.notion\.com$/i, kind: 'write', postReads: [/^\/v1\/databases\/[^/]+\/query$/, /^\/v1\/search$/] },
|
|
42
|
+
{ name: 'HubSpot', host: /^api\.hubapi\.com$/i, kind: 'write', postReads: [/\/search$/] },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
46
|
+
|
|
47
|
+
const WHAT = { send: 'send a message', charge: 'take or move a payment', write: 'change a record' };
|
|
48
|
+
|
|
49
|
+
/** The bearer token or basic user on a request, if any (to spot a test-mode key). */
|
|
50
|
+
function credentialOf(headers) {
|
|
51
|
+
const h = headers || {};
|
|
52
|
+
const raw = typeof h.get === 'function' ? h.get('authorization') : (h.authorization || h.Authorization || '');
|
|
53
|
+
const v = String(raw || '').trim();
|
|
54
|
+
if (/^bearer\s+/i.test(v)) return v.replace(/^bearer\s+/i, '');
|
|
55
|
+
if (/^basic\s+/i.test(v)) { try { return Buffer.from(v.replace(/^basic\s+/i, ''), 'base64').toString('utf8').split(':')[0]; } catch (_) { return ''; } }
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The gate's whole policy. `{ method, host, path, headers }` → `{ allow: true }` or
|
|
61
|
+
* `{ allow: false, provider, kind, reason }`. A host in no provider's row passes: the
|
|
62
|
+
* gate covers the providers it knows, and says so.
|
|
63
|
+
*/
|
|
64
|
+
function decide({ method, host, path, headers }) {
|
|
65
|
+
const m = String(method || 'GET').toUpperCase();
|
|
66
|
+
const h = String(host || '').toLowerCase().replace(/:\d+$/, '');
|
|
67
|
+
const p = PROVIDERS.find((x) => x.host.test(h));
|
|
68
|
+
if (!p) return { allow: true };
|
|
69
|
+
if (READ_METHODS.has(m)) return { allow: true, provider: p.name };
|
|
70
|
+
const pathOnly = String(path || '/').split('?')[0];
|
|
71
|
+
if (m === 'POST' && (p.postReads || []).some((re) => re.test(pathOnly))) return { allow: true, provider: p.name };
|
|
72
|
+
if (p.testKey && p.testKey.test(credentialOf(headers))) return { allow: true, provider: p.name, testMode: true };
|
|
73
|
+
return {
|
|
74
|
+
allow: false, provider: p.name, kind: p.kind,
|
|
75
|
+
reason: `Refused by the sandbox: this ${m} to ${p.name} would ${WHAT[p.kind]} for real.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A small in-memory record of refusals, newest first, shown on the sandbox's own page. */
|
|
80
|
+
// Each refusal is one line on stderr, which Render shows in the sandbox's own log.
|
|
81
|
+
function makeLog({ limit = 100, write = (line) => process.stderr.write(`${line}\n`) } = {}) {
|
|
82
|
+
const items = [];
|
|
83
|
+
return {
|
|
84
|
+
items,
|
|
85
|
+
add(r) {
|
|
86
|
+
const entry = { at: new Date().toISOString(), provider: r.provider, kind: r.kind, method: r.method, target: `${r.host}${r.path.split('?')[0]}` };
|
|
87
|
+
items.unshift(entry);
|
|
88
|
+
if (items.length > limit) items.length = limit;
|
|
89
|
+
write(`[bongos-sandbox] refused ${entry.kind} to ${entry.provider}: ${entry.method} ${entry.target}`);
|
|
90
|
+
return entry;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class SandboxRefused extends Error {
|
|
96
|
+
constructor(d) { super(d.reason); this.name = 'SandboxRefused'; this.code = 'BONGOS_SANDBOX_REFUSED'; this.provider = d.provider; this.kind = d.kind; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Wrap a fetch: a refused call answers 403 with a body that says why, and never leaves. */
|
|
100
|
+
function wrapFetch(fetchImpl, log) {
|
|
101
|
+
if (!fetchImpl || fetchImpl.__bongosSandbox) return fetchImpl;
|
|
102
|
+
const wrapped = async function sandboxFetch(input, init = {}) {
|
|
103
|
+
const url = new URL(typeof input === 'string' || input instanceof URL ? String(input) : input.url);
|
|
104
|
+
const method = (init.method || (input && input.method) || 'GET');
|
|
105
|
+
const headers = init.headers || (input && input.headers) || {};
|
|
106
|
+
const d = decide({ method, host: url.host, path: url.pathname + url.search, headers: typeof headers.get === 'function' ? headers : lower(headers) });
|
|
107
|
+
if (d.allow) return fetchImpl(input, init);
|
|
108
|
+
log.add({ ...d, method: String(method).toUpperCase(), host: url.host, path: url.pathname });
|
|
109
|
+
return new Response(JSON.stringify({ error: { type: 'bongos_sandbox_refused', provider: d.provider, kind: d.kind, message: d.reason } }),
|
|
110
|
+
{ status: 403, headers: { 'content-type': 'application/json', 'x-bongos-sandbox': 'refused' } });
|
|
111
|
+
};
|
|
112
|
+
wrapped.__bongosSandbox = true;
|
|
113
|
+
return wrapped;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const lower = (o) => Object.fromEntries(Object.entries(o || {}).map(([k, v]) => [String(k).toLowerCase(), v]));
|
|
117
|
+
|
|
118
|
+
// What a request made through http(s).request is aimed at, from any of its call shapes.
|
|
119
|
+
function targetOf(args) {
|
|
120
|
+
let url = null, opts = {};
|
|
121
|
+
if (typeof args[0] === 'string' || args[0] instanceof URL) { url = new URL(String(args[0])); if (args[1] && typeof args[1] === 'object') opts = args[1]; }
|
|
122
|
+
else if (args[0] && typeof args[0] === 'object') opts = args[0];
|
|
123
|
+
const host = opts.hostname || opts.host || (url && url.hostname) || 'localhost';
|
|
124
|
+
const path = opts.path || (url ? url.pathname + url.search : '/');
|
|
125
|
+
return { method: opts.method || 'GET', host, path, headers: lower(opts.headers) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A request object that never connects and fails with the refusal, as a refused socket would. */
|
|
129
|
+
function refusedRequest(d, cb) {
|
|
130
|
+
const { EventEmitter } = require('node:events');
|
|
131
|
+
const req = new EventEmitter();
|
|
132
|
+
let fired = false;
|
|
133
|
+
const fire = () => { if (fired) return; fired = true; process.nextTick(() => req.emit('error', new SandboxRefused(d))); };
|
|
134
|
+
Object.assign(req, {
|
|
135
|
+
write: () => true, setHeader() {}, getHeader() {}, removeHeader() {}, setTimeout() { return req; }, setNoDelay() {}, setSocketKeepAlive() {},
|
|
136
|
+
end() { fire(); return req; }, abort() { fired = true; }, destroy() { fired = true; return req; }, flushHeaders() {},
|
|
137
|
+
});
|
|
138
|
+
void cb;
|
|
139
|
+
return req;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Patch a node `http` / `https` module's request + get through the gate. */
|
|
143
|
+
function wrapHttp(mod, log) {
|
|
144
|
+
if (!mod || mod.request.__bongosSandbox) return;
|
|
145
|
+
const realRequest = mod.request;
|
|
146
|
+
const realGet = mod.get;
|
|
147
|
+
const guarded = (real, isGet) => {
|
|
148
|
+
const fn = function sandboxRequest(...args) {
|
|
149
|
+
const t = targetOf(args);
|
|
150
|
+
const d = decide(t);
|
|
151
|
+
if (d.allow) return real.apply(mod, args);
|
|
152
|
+
log.add({ ...d, method: String(t.method).toUpperCase(), host: t.host, path: t.path });
|
|
153
|
+
const req = refusedRequest(d, args[args.length - 1]);
|
|
154
|
+
if (isGet) req.end();
|
|
155
|
+
return req;
|
|
156
|
+
};
|
|
157
|
+
fn.__bongosSandbox = true;
|
|
158
|
+
return fn;
|
|
159
|
+
};
|
|
160
|
+
mod.request = guarded(realRequest, false);
|
|
161
|
+
mod.get = guarded(realGet, true);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { decide, makeLog, wrapFetch, wrapHttp };
|