@bongos/core 1.19.925 → 1.19.927
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 +32 -22
- package/docs/copy-inventory.md +12 -11
- package/docs/copy-registry.json +23 -14
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/idea.css +154 -0
- package/modules/hall-ui/public/idea.js +192 -0
- package/modules/ideas/inbox.js +1 -0
- package/modules/ideas/routes/inbox.js +24 -1
- package/modules/ideas/trail-removal.js +115 -0
- package/modules/ideas/trail.js +56 -21
- package/modules/provisioning/control-plane-guard.js +9 -1
- package/modules/provisioning/core-upgrade.js +17 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_door.mjs +244 -3
- package/tests/helpers.mjs +99 -0
- package/tests/idea_descent_surface.mjs +458 -0
|
@@ -93,10 +93,29 @@ await t('every action not explicitly allowed is refused on the self row', () =>
|
|
|
93
93
|
}
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
await t('the allow-list is exactly the
|
|
96
|
+
await t('the allow-list is exactly the acts that cannot destroy the box', () => {
|
|
97
97
|
// Pinned by NAME so widening it is a deliberate edit someone has to argue for,
|
|
98
|
-
// not a side effect of adding an action to INTENT_ACTIONS.
|
|
99
|
-
|
|
98
|
+
// not a side effect of adding an action to INTENT_ACTIONS. It did its job: adding
|
|
99
|
+
// 'core-upgrade-preflight' to INTENT_ACTIONS in task 1004121 did NOT reach here, and
|
|
100
|
+
// this line is what forced the argument to be made (task 1004125). The argument: a
|
|
101
|
+
// dry run returns at pre-flight having moved no pin, run no npm and restarted
|
|
102
|
+
// nothing, and the control plane is the only instance whose hall HAS an Upgrade
|
|
103
|
+
// page — so refusing the preview there protected nothing and removed everything.
|
|
104
|
+
assert.deepEqual([...G.CONTROL_PLANE_ACTIONS].sort(),
|
|
105
|
+
['core-upgrade', 'core-upgrade-preflight', 'restart']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await t('the preview is permitted on the control plane — the one row that can use it', () => {
|
|
109
|
+
assert.equal(G.intentAllowedForShape('core-upgrade-preflight', 'control-plane'), true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await t('and widening it did NOT let the destructive acts through', () => {
|
|
113
|
+
// The whole risk of arguing one action onto an allow-list is arguing four.
|
|
114
|
+
for (const a of P.INTENT_ACTIONS) {
|
|
115
|
+
if (['core-upgrade', 'core-upgrade-preflight', 'restart'].includes(a)) continue;
|
|
116
|
+
assert.equal(G.intentAllowedForShape(a, 'control-plane'), false,
|
|
117
|
+
`'${a}' must still be refused against the box the platform runs on`);
|
|
118
|
+
}
|
|
100
119
|
});
|
|
101
120
|
|
|
102
121
|
await t('an ordinary instance is untouched by the guard', () => {
|
|
@@ -456,5 +475,227 @@ await t('no verdict at all is null — never an implied pass', () => {
|
|
|
456
475
|
assert.equal(p.behind, true, 'and the rest of the reading is unaffected');
|
|
457
476
|
});
|
|
458
477
|
|
|
478
|
+
|
|
479
|
+
console.log('\nthe body the page actually sends (task 1004124):');
|
|
480
|
+
|
|
481
|
+
// THE REAL VALIDATOR, NOT A STUB. Every other handler test here injects
|
|
482
|
+
// `validateOrRespond: () => false`, which is right for asserting branch behaviour and
|
|
483
|
+
// is exactly why this shipped broken: the route declares `{ strict: true }`, so a field
|
|
484
|
+
// the schema does not NAME is a 400 before the handler runs — and task 1004121 added
|
|
485
|
+
// the `dry_run` read without the declaration. Two correct halves, one contract, and no
|
|
486
|
+
// test across it. `src/bongos/routes/core-update.js` documents this same class in its
|
|
487
|
+
// own comment; this is the provisioning half learning it.
|
|
488
|
+
const HELPERS = require('../src/bongos/routes/_helpers.js');
|
|
489
|
+
|
|
490
|
+
const validatorHarness = () => {
|
|
491
|
+
const calls = [];
|
|
492
|
+
const provisioning = {
|
|
493
|
+
getInstanceById: async () => ACTIVE,
|
|
494
|
+
enqueueIntent: async (_db, id, action, requestedBy, targetVersion) => {
|
|
495
|
+
calls.push({ id, action, requestedBy, targetVersion });
|
|
496
|
+
return { intent: { id: 1, action, state: 'pending', target_version: targetVersion }, created: true };
|
|
497
|
+
},
|
|
498
|
+
recordEvent: async () => {},
|
|
499
|
+
};
|
|
500
|
+
const handler = CU.makeCoreUpgradeHandler({
|
|
501
|
+
provisioning, pool: {}, parseId: () => 7,
|
|
502
|
+
validateOrRespond: HELPERS.validateOrRespond, log: { error() {} },
|
|
503
|
+
});
|
|
504
|
+
let body = null;
|
|
505
|
+
let status = 200;
|
|
506
|
+
const res = {
|
|
507
|
+
json: (b) => { body = b; return res; },
|
|
508
|
+
fail: (code, o) => { body = { code, ...(typeof o === 'object' ? o : { status: o }) }; status = (o && o.status) || o || 400; return res; },
|
|
509
|
+
status: (c) => { status = c; return res; },
|
|
510
|
+
};
|
|
511
|
+
return { calls, handler, res, provisioning, read: () => ({ body, status }) };
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
await t('the preview payload the page posts is ACCEPTED, not refused as an unknown field', async () => {
|
|
515
|
+
// deploy.js:183 sends exactly `{ to, dry_run: true }`. Live, before the fix, this
|
|
516
|
+
// answered 400 unknown_field — so the whole "see what would change" button was dead.
|
|
517
|
+
const h = validatorHarness();
|
|
518
|
+
await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925', dry_run: true } }, h.res);
|
|
519
|
+
const { body } = h.read();
|
|
520
|
+
assert.ok(!body || body.code !== 'validation_failed', `the body must validate — got ${JSON.stringify(body)}`);
|
|
521
|
+
const queued = h.calls.find((c) => c.action === 'core-upgrade-preflight');
|
|
522
|
+
assert.ok(queued, 'and it reaches the queue as a PREVIEW, not as a move');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
await t('a guard refusal reaches the owner as ITS OWN sentence, not "internal error"', async () => {
|
|
526
|
+
// The guard writes `code`, `status: 409` and a publicMessage for exactly this moment.
|
|
527
|
+
// The catch discarded all three, so every control-plane refusal this route ever made
|
|
528
|
+
// was shown as a 500 "internal error" while the real sentence sat in the journal —
|
|
529
|
+
// which is how the preview refusal stayed invisible until it was pressed live.
|
|
530
|
+
// Elsewhere in this file we already assert that sentence is well-formed; nothing
|
|
531
|
+
// asserted it ARRIVED.
|
|
532
|
+
const h = validatorHarness();
|
|
533
|
+
const refusal = Object.assign(new Error('blocked'), {
|
|
534
|
+
code: 'control_plane_protected', status: 409,
|
|
535
|
+
publicMessage: 'That action cannot be aimed at the machine the platform itself runs on.',
|
|
536
|
+
});
|
|
537
|
+
h.provisioning.enqueueIntent = async () => { throw refusal; };
|
|
538
|
+
await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.926' } }, h.res);
|
|
539
|
+
const { body } = h.read();
|
|
540
|
+
assert.equal(body && body.code, 'control_plane_protected', 'the guard\'s own code, not core_upgrade_failed');
|
|
541
|
+
assert.equal(body && body.status, 409, 'a refusal is not a 500');
|
|
542
|
+
assert.match((body && body.message) || '', /cannot be aimed at the machine/);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
await t('an UNEXPECTED throw is still opaque — this is not a blanket error passthrough', async () => {
|
|
546
|
+
// Forwarding every error would leak internals. Only an error that declared itself
|
|
547
|
+
// public (code + status + publicMessage) is forwarded.
|
|
548
|
+
const h = validatorHarness();
|
|
549
|
+
h.provisioning.enqueueIntent = async () => { throw new Error('connection terminated: relation "secrets" does not exist'); };
|
|
550
|
+
await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.926' } }, h.res);
|
|
551
|
+
const { body } = h.read();
|
|
552
|
+
assert.equal(body && body.code, 'core_upgrade_failed');
|
|
553
|
+
assert.equal(body && body.message, 'internal error');
|
|
554
|
+
assert.ok(!/secrets|relation/.test(JSON.stringify(body)), 'no internals reach the client');
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
await t('the move payload still validates, and still queues a move', async () => {
|
|
558
|
+
const h = validatorHarness();
|
|
559
|
+
await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925' } }, h.res);
|
|
560
|
+
const { body } = h.read();
|
|
561
|
+
assert.ok(!body || body.code !== 'validation_failed', `the body must validate — got ${JSON.stringify(body)}`);
|
|
562
|
+
assert.ok(h.calls.find((c) => c.action === 'core-upgrade'), 'no dry_run means a real move');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
await t('strict is still strict — a typo is still refused', async () => {
|
|
566
|
+
// The fix is a declaration, not a loosening. Widening to non-strict would have made
|
|
567
|
+
// the button work and silently swallowed every future typo, which is the trade
|
|
568
|
+
// _helpers.js exists to refuse.
|
|
569
|
+
const h = validatorHarness();
|
|
570
|
+
await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925', dry_runn: true } }, h.res);
|
|
571
|
+
const { body } = h.read();
|
|
572
|
+
assert.equal(body && body.code, 'validation_failed', 'an undeclared field is still a 400');
|
|
573
|
+
assert.equal(h.calls.length, 0, 'and nothing was queued');
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
await t('dry_run must be a boolean — a truthy string does not become a preview', async () => {
|
|
577
|
+
const h = validatorHarness();
|
|
578
|
+
await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925', dry_run: 'yes' } }, h.res);
|
|
579
|
+
const { body } = h.read();
|
|
580
|
+
assert.equal(body && body.code, 'validation_failed',
|
|
581
|
+
'the handler compares === true, so a string would silently queue a MOVE where a preview was asked for');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
// A REAL REQUEST THROUGH THE ASSEMBLED ROUTER. The harness above proves the SCHEMA
|
|
586
|
+
// (it injects the very function object the route injects — api.validateOrRespond IS
|
|
587
|
+
// _helpers.validateOrRespond), but it cannot see the wiring: middleware order, a
|
|
588
|
+
// missing body parser, a mis-shaped handler mount. Those are invisible to any test
|
|
589
|
+
// that calls the factory directly, which is the same lesson
|
|
590
|
+
// src/bongos/routes/core-update.js records about its own asyncHandler bug. So: boot
|
|
591
|
+
// the actual router over express and POST to it.
|
|
592
|
+
await t('a real POST through the assembled router accepts the preview body', async () => {
|
|
593
|
+
const api = require('../src/module-api.js');
|
|
594
|
+
const prov = require('../modules/provisioning/provisioning.js');
|
|
595
|
+
const routePath = require.resolve('../modules/provisioning/routes/core-upgrade.js');
|
|
596
|
+
|
|
597
|
+
const saved = {};
|
|
598
|
+
const stub = (obj, key, value) => {
|
|
599
|
+
saved[key] = Object.getOwnPropertyDescriptor(obj, key);
|
|
600
|
+
Object.defineProperty(obj, key, { value, configurable: true, writable: true });
|
|
601
|
+
};
|
|
602
|
+
// Only the two auth gates and the data layer are replaced. parseId and
|
|
603
|
+
// validateOrRespond stay REAL — they are the contract under test.
|
|
604
|
+
stub(api, 'requireBuilder', (req, _res, next) => { req.builder = { id: 42 }; next(); });
|
|
605
|
+
stub(api, 'requirePermission', () => (_req, _res, next) => next());
|
|
606
|
+
stub(api, 'pool', {});
|
|
607
|
+
const queued = [];
|
|
608
|
+
const savedProv = { get: prov.getInstanceById, enq: prov.enqueueIntent, rec: prov.recordEvent };
|
|
609
|
+
prov.getInstanceById = async () => ACTIVE;
|
|
610
|
+
prov.enqueueIntent = async (_db, id, action, requestedBy, targetVersion) => {
|
|
611
|
+
queued.push({ id, action, requestedBy, targetVersion });
|
|
612
|
+
return { intent: { id: 1, action, state: 'pending', target_version: targetVersion }, created: true };
|
|
613
|
+
};
|
|
614
|
+
prov.recordEvent = async () => {};
|
|
615
|
+
delete require.cache[routePath];
|
|
616
|
+
|
|
617
|
+
const express = require('express');
|
|
618
|
+
const app = express();
|
|
619
|
+
app.use(express.json());
|
|
620
|
+
app.use(require(routePath)());
|
|
621
|
+
const server = app.listen(0);
|
|
622
|
+
await new Promise((r) => server.once('listening', r));
|
|
623
|
+
try {
|
|
624
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}/provisioning/instances/41/core-upgrade`, {
|
|
625
|
+
method: 'POST',
|
|
626
|
+
headers: { 'content-type': 'application/json' },
|
|
627
|
+
body: JSON.stringify({ to: '1.19.925', dry_run: true }),
|
|
628
|
+
});
|
|
629
|
+
const body = await res.json().catch(() => null);
|
|
630
|
+
assert.notEqual(res.status, 400, `the page's own payload must not be refused — got ${res.status} ${JSON.stringify(body)}`);
|
|
631
|
+
const preview = queued.find((q) => q.action === 'core-upgrade-preflight');
|
|
632
|
+
assert.ok(preview, `and it reaches the queue as a preview — queued ${JSON.stringify(queued)}`);
|
|
633
|
+
assert.equal(preview.targetVersion, '1.19.925');
|
|
634
|
+
assert.equal(preview.requestedBy, 'api:archon:42', 'attributed through the real auth seam');
|
|
635
|
+
} finally {
|
|
636
|
+
server.close();
|
|
637
|
+
for (const [k, d] of Object.entries(saved)) Object.defineProperty(api, k, d);
|
|
638
|
+
prov.getInstanceById = savedProv.get;
|
|
639
|
+
prov.enqueueIntent = savedProv.enq;
|
|
640
|
+
prov.recordEvent = savedProv.rec;
|
|
641
|
+
delete require.cache[routePath];
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
// THE AUDIT, AS A GUARD RATHER THAN A CLAIM. The task asked for a sweep of the other
|
|
647
|
+
// provisioning write routes for this same shape. A sweep performed once and reported in
|
|
648
|
+
// prose is indistinguishable, later, from one never performed — and the next route to
|
|
649
|
+
// add a strict schema gets no help from it. So the sweep is a test: it re-runs on every
|
|
650
|
+
// build and fails on the next occurrence rather than on this one.
|
|
651
|
+
await t('every strict body schema in this module declares each field its handler reads', () => {
|
|
652
|
+
const dir = join(repoRoot, 'modules', 'provisioning');
|
|
653
|
+
const files = [];
|
|
654
|
+
const walk = (d) => {
|
|
655
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
656
|
+
if (e.name === 'node_modules' || e.name === 'tests') continue;
|
|
657
|
+
const full = join(d, e.name);
|
|
658
|
+
if (e.isDirectory()) walk(full);
|
|
659
|
+
else if (e.name.endsWith('.js')) files.push(full);
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
walk(dir);
|
|
663
|
+
|
|
664
|
+
const offenders = [];
|
|
665
|
+
let strictSites = 0;
|
|
666
|
+
for (const f of files) {
|
|
667
|
+
const src = readFileSync(f, 'utf8');
|
|
668
|
+
const lines = src.split('\n');
|
|
669
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
670
|
+
if (!lines[i].includes('validateOrRespond(req')) continue;
|
|
671
|
+
// The call spans until its `)) return;`; the handler body follows it.
|
|
672
|
+
let j = i;
|
|
673
|
+
while (j < lines.length && j < i + 40 && !lines[j].includes(')) return;')) j += 1;
|
|
674
|
+
const call = lines.slice(i, j + 1).join('\n');
|
|
675
|
+
if (!/strict:\s*true/.test(call)) continue; // extras are ignored by design
|
|
676
|
+
strictSites += 1;
|
|
677
|
+
// The SCHEMA argument only — brace-matched from the `{` after `res,`. Scanning the
|
|
678
|
+
// whole call would also count the options object's keys as declared fields, which
|
|
679
|
+
// is exactly the kind of over-broad match that turns a guard into decoration.
|
|
680
|
+
const open = call.indexOf('{', call.indexOf('res,'));
|
|
681
|
+
let depth = 0;
|
|
682
|
+
let close = open;
|
|
683
|
+
for (; close < call.length; close += 1) {
|
|
684
|
+
if (call[close] === '{') depth += 1;
|
|
685
|
+
else if (call[close] === '}') { depth -= 1; if (!depth) break; }
|
|
686
|
+
}
|
|
687
|
+
const schemaSrc = call.slice(open, close + 1);
|
|
688
|
+
const declared = new Set([...schemaSrc.matchAll(/(\w+)\s*:\s*\{/g)].map((m) => m[1]));
|
|
689
|
+
const body = lines.slice(j + 1, j + 140).join('\n');
|
|
690
|
+
const read = new Set([...body.matchAll(/req\.body\.(\w+)/g)].map((m) => m[1]));
|
|
691
|
+
const missing = [...read].filter((k) => !declared.has(k));
|
|
692
|
+
if (missing.length) offenders.push(`${f.replace(repoRoot + '/', '')}:${i + 1} reads ${missing.join(', ')} but does not declare it`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
assert.ok(strictSites > 0, 'the scan found no strict schema at all — it has stopped looking');
|
|
696
|
+
assert.deepEqual(offenders, [],
|
|
697
|
+
'under strict, a field the schema does not name is a 400 BEFORE the handler runs, so reading it is unreachable code and a broken endpoint');
|
|
698
|
+
});
|
|
699
|
+
|
|
459
700
|
console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
|
|
460
701
|
if (failed) process.exit(1);
|
package/tests/helpers.mjs
CHANGED
|
@@ -630,5 +630,104 @@ export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
|
|
|
630
630
|
};
|
|
631
631
|
}
|
|
632
632
|
|
|
633
|
+
|
|
634
|
+
// bootIdeaPage — boot the REAL modules/hall-ui/public/idea.js in a vm against a
|
|
635
|
+
// stub DOM, and hand back the HTML it wrote into #idea-body plus every request
|
|
636
|
+
// it made.
|
|
637
|
+
//
|
|
638
|
+
// The sibling of bootProfilePage above, and here for the same reason: the
|
|
639
|
+
// descent surface (task 1004094 / BV2.R10) is asserted at the RENDERED layer,
|
|
640
|
+
// because the first of the three findings inherited from the abandoned task
|
|
641
|
+
// 1004054 is that testing the server-side read alone is exactly how a renderer
|
|
642
|
+
// bug survives. What a reader is handed is HTML, so that is what the suite reads.
|
|
643
|
+
//
|
|
644
|
+
// Unlike profile.js, this page writes straight into an element's innerHTML
|
|
645
|
+
// rather than through OTBKit's ledger, so the capture is the element itself.
|
|
646
|
+
const IDEA_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/idea.js'), 'utf8');
|
|
647
|
+
export function bootIdeaPage({ idea = null, status = 200, template = null, viewer = { id: 1 }, ideaId = '1' } = {}) {
|
|
648
|
+
const els = new Map();
|
|
649
|
+
const makeEl = () => ({
|
|
650
|
+
dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
|
|
651
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
652
|
+
setAttribute() {}, getAttribute() { return null; },
|
|
653
|
+
addEventListener() {},
|
|
654
|
+
querySelector() { return null; }, querySelectorAll() { return []; }, appendChild() {},
|
|
655
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
656
|
+
});
|
|
657
|
+
const documentObj = {
|
|
658
|
+
title: '',
|
|
659
|
+
body: { dataset: { page: 'idea' } },
|
|
660
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
661
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
662
|
+
querySelector(sel) {
|
|
663
|
+
const id = String(sel).replace(/^#/, '');
|
|
664
|
+
// The mark control's button and error slot exist only once the page has
|
|
665
|
+
// rendered one. Returning a live element for every selector would make
|
|
666
|
+
// the boot look as though it had wired a control that is not on the page.
|
|
667
|
+
if (id === 'ig-mark-btn' || id === 'ig-mark-err') {
|
|
668
|
+
return /id="ig-mark-btn"/.test(els.get('idea-body')?.innerHTML || '') ? this.getElementById(id) : null;
|
|
669
|
+
}
|
|
670
|
+
return this.getElementById(id);
|
|
671
|
+
},
|
|
672
|
+
querySelectorAll() { return []; },
|
|
673
|
+
createElement() { return makeEl(); },
|
|
674
|
+
addEventListener() {},
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const requests = [];
|
|
678
|
+
const client = {
|
|
679
|
+
inbox: {
|
|
680
|
+
getInboxId: async ({ id }) => {
|
|
681
|
+
requests.push(`/inbox/${id}`);
|
|
682
|
+
return { status, ok: status === 200, data: status === 200 ? { idea } : null };
|
|
683
|
+
},
|
|
684
|
+
getInboxTemplate: async () => {
|
|
685
|
+
requests.push('/inbox/template');
|
|
686
|
+
return template
|
|
687
|
+
? { status: 200, ok: true, data: { template } }
|
|
688
|
+
: { status: 500, ok: false, data: null };
|
|
689
|
+
},
|
|
690
|
+
postInboxIdSpark: async () => ({ status: 200, ok: true, data: { idea } }),
|
|
691
|
+
deleteInboxIdSpark: async () => ({ status: 200, ok: true, data: { idea } }),
|
|
692
|
+
},
|
|
693
|
+
me: {
|
|
694
|
+
getMe: async () => {
|
|
695
|
+
requests.push('/me');
|
|
696
|
+
return viewer
|
|
697
|
+
? { status: 200, ok: true, data: { builder: viewer } }
|
|
698
|
+
: { status: 401, ok: false, data: null };
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const windowObj = {
|
|
704
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
705
|
+
BongosClient: { createClient: () => client },
|
|
706
|
+
OTB: {
|
|
707
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
708
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
709
|
+
fmtDate: (iso) => String(iso ?? '').slice(0, 10),
|
|
710
|
+
toast() {},
|
|
711
|
+
},
|
|
712
|
+
location: { hostname: 'builders.cloudbongos.com', search: '', pathname: `/idea/${ideaId}`, assign() {} },
|
|
713
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
714
|
+
addEventListener() {},
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const sandbox = {
|
|
718
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
719
|
+
console, setTimeout, clearTimeout, URLSearchParams,
|
|
720
|
+
};
|
|
721
|
+
sandbox.globalThis = sandbox;
|
|
722
|
+
vm.createContext(sandbox);
|
|
723
|
+
vm.runInContext(IDEA_JS, sandbox, { filename: 'idea.js' });
|
|
724
|
+
|
|
725
|
+
return {
|
|
726
|
+
requests,
|
|
727
|
+
html: () => documentObj.getElementById('idea-body').innerHTML,
|
|
728
|
+
title: () => documentObj.title,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
633
732
|
// One microtask turn — profile.js boots through a chain of awaits.
|
|
634
733
|
export const settle = () => new Promise((r) => setTimeout(r, 0));
|