@bongos/core 1.19.1014 → 1.19.1016
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 +145 -140
- package/.claude/skills/planning-session/SKILL.md +1 -1
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md +4 -1
- package/docs/adr/0331-the-sky-is-the-ideators-home.md +18 -1
- package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +113 -0
- package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +201 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +4 -0
- package/docs/api-reference.md +1 -1
- package/docs/architecture.md +4 -1
- package/docs/copy-inventory.md +139 -110
- package/docs/copy-registry.json +872 -296
- package/docs/file-map.md +0 -1
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_253_idea_filed_via.sql +59 -0
- package/modules/agents/pollers/event-router.js +2 -0
- package/modules/discord/discord-bugs.js +2 -0
- package/modules/discord/discord-inbound.js +3 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +125 -52
- package/modules/hall-ui/public/goal-map.states.json +29 -5
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +11 -0
- package/modules/hall-ui/public/sky-data.js +41 -9
- package/modules/hall-ui/public/sky-draw.js +3 -2
- package/modules/hall-ui/public/sky-interaction.js +14 -7
- package/modules/hall-ui/public/sky-layout.js +2 -1
- package/modules/hall-ui/public/sky-panel.js +1 -1
- package/modules/hall-ui/public/sky-tour.js +10 -6
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +6 -3
- package/modules/hall-ui/public/thinking.html +16 -10
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/ideas/born-of.js +55 -0
- package/modules/ideas/inbox.js +23 -8
- package/modules/ideas/projection.js +48 -0
- package/modules/ideas/routes/inbox.js +20 -0
- package/modules/lifecycle/sky.js +25 -4
- package/modules/platform-identity/activity-snapshots.js +47 -0
- package/modules/platform-identity/migrations/platform_identity_022_activity_snapshots.sql +46 -0
- package/modules/platform-identity/platform-identity.js +13 -0
- package/modules/platform-identity/tests/activity-snapshots.mjs +68 -0
- package/modules/platform-identity/tests/platform-identity.mjs +21 -0
- package/modules/ui-design/skills/impeccable/SKILL.md +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/capture.js +11 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/serve-internal.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/activity_snapshots_db.mjs +97 -0
- package/tests/discord_bugs.mjs +1 -0
- package/tests/discord_inbound.mjs +1 -0
- package/tests/goal_map_page.mjs +360 -211
- package/tests/guild_namespace_reserved.mjs +76 -0
- package/tests/hall_audit.mjs +1 -0
- package/tests/hall_nav.mjs +6 -1
- package/tests/hall_page_gate_map.mjs +3 -2
- package/tests/hall_sky.mjs +33 -16
- package/tests/helpers.mjs +21 -5
- package/tests/idea_filed_via.mjs +185 -0
- package/tests/landing_page.mjs +8 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/profile_rollup_consent.mjs +5 -1
- package/tests/project_admin_console_model.mjs +105 -0
- package/tests/render_check.mjs +6 -4
- package/tests/sky_read.mjs +19 -2
- package/tests/ui_design_skills.mjs +9 -0
- package/modules/hall-ui/public/goal-map-lib.js +0 -349
- package/modules/hall-ui/public/goal-map.css +0 -117
- package/modules/hall-ui/public/goal-map.js +0 -232
- package/modules/public-landing/public/world/assets/clouds/cloud-island-large.png +0 -0
- package/modules/public-landing/public/world/assets/clouds/cloud-puff.png +0 -0
- package/modules/public-landing/public/world/assets/creatures/creature-whale-cargo.png +0 -0
- package/modules/public-landing/public/world/assets/creatures/creature-whale-glide.png +0 -0
- package/modules/public-landing/public/world/assets/plates/plate-clouds-near.png +0 -0
- package/modules/public-landing/public/world/assets/plates/scene-establishing.png +0 -0
- package/tests/goal_map_lib.mjs +0 -214
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// tests/guild_namespace_reserved.mjs — the guild URL namespace is RESERVED where
|
|
2
|
+
// project slugs are decided (task 1002299, R23 guild design; task 1002912's rule).
|
|
3
|
+
//
|
|
4
|
+
// Project profiles live at the top level (`cloudbongos.com/<slug>`), so every
|
|
5
|
+
// top-level segment the hub uses for another kind of page must be on the reserved
|
|
6
|
+
// project-slug list in the SEO spec, or a project can register the word and shadow
|
|
7
|
+
// the namespace. `u` guards `/u/<handle>`; the guild design names its own segment,
|
|
8
|
+
// and task 1002912 asked for the word to be reserved there, not a route squatted.
|
|
9
|
+
//
|
|
10
|
+
// No slug validator exists yet (the SEO goal's R02 builds it from that list), so the
|
|
11
|
+
// spec IS the contract today. This pins the one property that can drift silently:
|
|
12
|
+
// the segment the guild ADR declares and the segment the spec reserves are the same.
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/guild_namespace_reserved.mjs
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { makeRunner } from './helpers.mjs';
|
|
20
|
+
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const ADR_DIR = path.join(ROOT, 'docs', 'adr');
|
|
23
|
+
const SEO_SPEC = path.join(ROOT, 'docs', 'specs', 'bongos-v1-seo-public-discoverability.md');
|
|
24
|
+
const GUILD_TASK = '1002299';
|
|
25
|
+
|
|
26
|
+
const { test, summary } = makeRunner();
|
|
27
|
+
|
|
28
|
+
// The guild design ADR, found by the task it records rather than by number: ADR
|
|
29
|
+
// numbers get renumbered when two sessions collide, the task id never changes.
|
|
30
|
+
function guildAdr() {
|
|
31
|
+
const hits = fs.readdirSync(ADR_DIR)
|
|
32
|
+
.filter((f) => /^\d{4}-.*\.md$/.test(f))
|
|
33
|
+
.filter((f) => {
|
|
34
|
+
const head = fs.readFileSync(path.join(ADR_DIR, f), 'utf8').replace(/\r\n/g, '\n').split('\n').slice(0, 12).join('\n');
|
|
35
|
+
return new RegExp(`^- \\*\\*Task:\\*\\*.*task ${GUILD_TASK}\\b`, 'm').test(head);
|
|
36
|
+
});
|
|
37
|
+
assert.equal(hits.length, 1, `exactly one ADR records task ${GUILD_TASK}; found ${JSON.stringify(hits)}`);
|
|
38
|
+
return fs.readFileSync(path.join(ADR_DIR, hits[0]), 'utf8').replace(/\r\n/g, '\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The segment the ADR declares on its machine-readable `Namespace:` line.
|
|
42
|
+
function declaredSegment(adr) {
|
|
43
|
+
const m = adr.match(/^- \*\*Namespace:\*\* `\/([a-z0-9-]+)\/<slug>`/m);
|
|
44
|
+
assert.ok(m, 'the guild ADR carries a `- **Namespace:** `/<segment>/<slug>`` header line');
|
|
45
|
+
return m[1];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The reserved words in the SEO spec's Routing bullet: the backticked list after "Reserved:".
|
|
49
|
+
function reservedWords() {
|
|
50
|
+
const spec = fs.readFileSync(SEO_SPEC, 'utf8').replace(/\r\n/g, '\n');
|
|
51
|
+
const routing = spec.split('\n').find((l) => l.startsWith('- **Routing:**'));
|
|
52
|
+
assert.ok(routing, 'the SEO spec still has its Routing bullet');
|
|
53
|
+
const m = routing.match(/Reserved: `([^`]+)`/);
|
|
54
|
+
assert.ok(m, 'the Routing bullet still names its reserved list as `Reserved: `…``');
|
|
55
|
+
return m[1].split(',').map((w) => w.trim()).filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await test('the guild ADR declares one top-level namespace segment', () => {
|
|
59
|
+
const seg = declaredSegment(guildAdr());
|
|
60
|
+
assert.match(seg, /^[a-z0-9-]+$/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await test('the SEO spec reserves the segment the guild ADR declares', () => {
|
|
64
|
+
const seg = declaredSegment(guildAdr());
|
|
65
|
+
const reserved = reservedWords();
|
|
66
|
+
assert.ok(reserved.includes(seg),
|
|
67
|
+
`a project could register "${seg}" and shadow /${seg}/<slug>; reserved list is ${JSON.stringify(reserved)}`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('the builder-profile segment it was modelled on is still reserved', () => {
|
|
71
|
+
// The precedent the task names. If `u` ever leaves the list, the guild reservation
|
|
72
|
+
// above has lost the rule it was copied from, and both need a second look.
|
|
73
|
+
assert.ok(reservedWords().includes('u'));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
summary();
|
package/tests/hall_audit.mjs
CHANGED
|
@@ -364,6 +364,7 @@ test('the module doc still records where the 2179 density pass landed', () => {
|
|
|
364
364
|
// and the pack's font links like every hall page (the server injects them).
|
|
365
365
|
const STANDALONE_EXEMPT = {
|
|
366
366
|
'thinking.html': 'the sky (task 1004231, ADR 0331) — the design of record is its own surface, not the hall chrome',
|
|
367
|
+
'goal-map.html': 'the project sky (task 1004232) — the same surface as thinking.html, opened in its whole-project lens',
|
|
367
368
|
};
|
|
368
369
|
|
|
369
370
|
test('every hall page loads style.css and shell.js', () => {
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -258,7 +258,12 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
258
258
|
// task 1003455 (goal 1000089): 'blockers' joins the same group, after Tasks —
|
|
259
259
|
// its subject is the project (what's stuck on the owner), not a person or a
|
|
260
260
|
// craft, and its one data source (GET /blockers) is public like Roadmap's.
|
|
261
|
-
|
|
261
|
+
// task 1004232 (sky part 4, goal 1000095): 'goal-map' — THE PROJECT SKY — joins
|
|
262
|
+
// directly under 'goals'. The goal map that held the URL took no nav slot on
|
|
263
|
+
// purpose (task 1003479, and tests/goal_map_page.mjs pinned it); the owner
|
|
264
|
+
// granted the sky that slot on 2026-09-24 (ADR 0227 §11), under Goals because
|
|
265
|
+
// it is the goals seen as one picture.
|
|
266
|
+
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'goal-map', 'work', 'blockers', 'board-room', 'ideas', 'thinking', 'copy-desk', 'studio'],
|
|
262
267
|
'the unlabeled group is the project itself (task 1002425 order)');
|
|
263
268
|
assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
|
|
264
269
|
// People (task 1002577): the /scouting directory graduated into the hall.
|
|
@@ -48,8 +48,9 @@ const ORACLE = {
|
|
|
48
48
|
'/builders/atlas': 'requireBuilderPage',
|
|
49
49
|
'/builders/modules': 'requireBuilderPage',
|
|
50
50
|
'/builders/goals': 'requireBuilderPage',
|
|
51
|
-
// task 1003479: the goal map
|
|
52
|
-
//
|
|
51
|
+
// task 1003479: the goal map, on the goal board's tier. task 1004232 (sky part 4):
|
|
52
|
+
// the URL now serves THE PROJECT SKY (the same page as /thinking, in its whole-project
|
|
53
|
+
// lens) on the same tier, and holds the nav slot under Goals the map was denied.
|
|
53
54
|
'/builders/goal-map': 'requireBuilderPage',
|
|
54
55
|
'/builders/roadmap': 'requireBuilderPage',
|
|
55
56
|
// Gated at this tier all along, but with no row until task 1003448 walked the
|
package/tests/hall_sky.mjs
CHANGED
|
@@ -303,27 +303,44 @@ await test('the claude layer is HIDDEN until filed_via exists, and every thought
|
|
|
303
303
|
assert.ok(p.S().ideas.every((i) => i.origin === 'human'));
|
|
304
304
|
});
|
|
305
305
|
|
|
306
|
-
await test('
|
|
307
|
-
const p = await boot({ mine: mine([star(), idea({ id: 2, title: 'A session filed this', filed_via: '
|
|
306
|
+
await test('filed_via IS A LEDGER FACT (part 5): cli / api / discord wait behind the claude layer, hall / planning always show, and a row from before the column shows and says so', async () => {
|
|
307
|
+
const p = await boot({ mine: mine([star(), idea({ id: 2, title: 'A session filed this', filed_via: 'cli' }), idea({ id: 3, title: 'A person filed this', filed_via: 'hall' }), idea({ id: 4, title: 'Planned', filed_via: 'planning' }), idea({ id: 5, title: 'A poller filed this', filed_via: 'api' }), idea({ id: 6, title: 'From Discord', filed_via: 'discord' })]), sky: sky([well()]) });
|
|
308
308
|
assert.equal(p.S().hasFiledVia, true);
|
|
309
309
|
assert.equal(p.el('claudeBtn').hidden, false);
|
|
310
310
|
const SKY = p.SKY();
|
|
311
|
-
|
|
312
|
-
assert.equal(SKY.inLayer(findIdea(p,
|
|
313
|
-
assert.equal(SKY.inLayer(findIdea(p,
|
|
311
|
+
same(SKY.SESSION_VIAS, ['cli', 'api', 'discord'], 'the doors a session files through');
|
|
312
|
+
for (const id of [2, 5, 6]) assert.equal(SKY.inLayer(findIdea(p, id)), false, `${id} waits behind the pill — off by default`);
|
|
313
|
+
for (const id of [10, 3, 4]) assert.equal(SKY.inLayer(findIdea(p, id)), true, `${id} shows`);
|
|
314
|
+
assert.equal(findIdea(p, 10).filedVia, null, 'a historic row carries NULL, not a guess');
|
|
314
315
|
p.click('claudeBtn');
|
|
315
|
-
assert.equal(SKY.inLayer(findIdea(p,
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
assert.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
316
|
+
for (const id of [2, 5, 6]) assert.equal(SKY.inLayer(findIdea(p, id)), true, 'the pill reveals them');
|
|
317
|
+
SKY.pick({ kind: 'idea', ref: findIdea(p, 10) }, false);
|
|
318
|
+
assert.match(p.panel(), /filed before the ledger recorded where/, 'and the panel says so, in those words');
|
|
319
|
+
SKY.pick({ kind: 'idea', ref: findIdea(p, 3) }, false);
|
|
320
|
+
assert.match(p.panel(), /from the hall/);
|
|
321
|
+
SKY.pick({ kind: 'idea', ref: findIdea(p, 2) }, false);
|
|
322
|
+
assert.match(p.panel(), /from the command line · by a session, under your name/);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await test('BORN OF A THOUGHT IS READ FROM GET /sky (part 5), never guessed: the goal wears the nucleus, the ring the notch, and the thought sits where it became', async () => {
|
|
326
|
+
const sk = sky([well()]); sk.born = { goals: { 7: 10 }, criteria: { 70: 12 } };
|
|
327
|
+
const p = await boot({ mine: mine([star(), coldBody({ id: 12, title: 'It became a ring' })]), sky: sk });
|
|
328
|
+
const S = p.S(); const SKY = p.SKY(); const g = S.goalById.get('7');
|
|
329
|
+
same(S.BORN, { goals: { 7: '10' }, criteria: { 70: '12' } }, 'ids are strings whatever the wire sent');
|
|
330
|
+
assert.equal(g.bornOf, '10'); assert.equal(g.crit[0].bornOf, '12');
|
|
331
|
+
assert.equal(S.becameGoal.get('10'), '7'); assert.equal(S.becameCrit.get('12'), g.crit[0]);
|
|
332
|
+
const shaped = findIdea(p, 10); assert.equal(shaped.shaped.kind, 'goal'); assert.equal(shaped.x, g.x); assert.equal(shaped.y, g.y);
|
|
333
|
+
const ring = findIdea(p, 12); assert.equal(ring.shaped.kind, 'ring');
|
|
334
|
+
assert.ok(Math.abs(Math.hypot(ring.x - g.x, ring.y - g.y) - SKY.ringR(g, 0, 2)) < 1e-6, 'on the first ring');
|
|
335
|
+
assert.equal(SKY.became(shaped), 'a goal — it shaped the space');
|
|
336
|
+
assert.match(SKY.became(ring), /^a criterion — a ring in /);
|
|
325
337
|
p.key('l');
|
|
326
|
-
assert.
|
|
338
|
+
assert.match(p.rows(), /shaped the space/);
|
|
339
|
+
SKY.pick({ kind: 'goal', ref: g }, false);
|
|
340
|
+
assert.match(p.panel(), /born of a thought/);
|
|
341
|
+
const none = await boot({ mine: mine(FIVE()), sky: sky([well()]) });
|
|
342
|
+
assert.equal(none.S().becameGoal.size, 0, 'no born read, no mark — nothing is guessed');
|
|
343
|
+
assert.ok(none.S().goals.every((x) => x.bornOf === null && x.crit.every((c) => c.bornOf === null)));
|
|
327
344
|
});
|
|
328
345
|
|
|
329
346
|
// ═══ the bands — derived, never named by hand ════════════════════════════════
|
package/tests/helpers.mjs
CHANGED
|
@@ -759,15 +759,27 @@ export const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
|
759
759
|
// page keeps on `window.OTBSky.S`. Every request the page made is on
|
|
760
760
|
// `requests` with its body, so a filing can be checked byte for byte against
|
|
761
761
|
// what the hall form sends.
|
|
762
|
+
//
|
|
763
|
+
// Two doors on one page (part 4, task 1004232): thinking.html and goal-map.html
|
|
764
|
+
// carry the same markup and the same scripts and differ in the body's
|
|
765
|
+
// `data-sky-lens` (which lens is home). A test names the `page`; each is parsed
|
|
766
|
+
// from its OWN file, so a button lost from either one fails.
|
|
762
767
|
const SKY_FILES = ['idea-objects.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js'];
|
|
763
768
|
const SKY_SRC = SKY_FILES.map((f) => [f, fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', f), 'utf8')]);
|
|
764
|
-
const
|
|
765
|
-
|
|
769
|
+
const SKY_PAGES = {};
|
|
770
|
+
function skyPage(page) {
|
|
771
|
+
if (!SKY_PAGES[page]) {
|
|
772
|
+
const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', `${page}.html`), 'utf8');
|
|
773
|
+
const m = /<body([^>]*)>([\s\S]*)<\/body>/.exec(html) || [, '', ''];
|
|
774
|
+
SKY_PAGES[page] = { attrs: parseAttrs(m[1]), body: m[2].replace(/<script[\s\S]*?<\/script>/g, '') };
|
|
775
|
+
}
|
|
776
|
+
return SKY_PAGES[page];
|
|
777
|
+
}
|
|
766
778
|
|
|
767
779
|
export function bootSkyPage({
|
|
768
780
|
mine = null, sky = null, versions = null, me = null, template = undefined,
|
|
769
781
|
viewer = { id: 95, permissions: {} }, status = {}, tourSeen = true, storage = {},
|
|
770
|
-
innerWidth = 1280, innerHeight = 800, reduce = true,
|
|
782
|
+
innerWidth = 1280, innerHeight = 800, reduce = true, page = 'thinking',
|
|
771
783
|
} = {}) {
|
|
772
784
|
// ── the DOM ──────────────────────────────────────────────────────────────
|
|
773
785
|
const windowListeners = new Map();
|
|
@@ -826,14 +838,18 @@ export function bootSkyPage({
|
|
|
826
838
|
}
|
|
827
839
|
// innerHTML on any element parses into children, so querySelectorAll('.row')
|
|
828
840
|
// finds what the page just wrote and the page's own listeners get wired.
|
|
841
|
+
const { attrs: bodyAttrs, body: skyBody } = skyPage(page);
|
|
829
842
|
const root = node('body', {});
|
|
843
|
+
// The body's data-* attributes are the page's dataset (data-sky-lens is what
|
|
844
|
+
// sky-data.js reads to know which lens is home).
|
|
845
|
+
for (const [k, v] of Object.entries(bodyAttrs)) if (k.startsWith('data-')) root.dataset[k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = v;
|
|
830
846
|
const patchInnerHtml = (el) => Object.defineProperty(el, 'innerHTML', {
|
|
831
847
|
get() { return this._html === null ? '' : this._html; },
|
|
832
848
|
set(v) { this._html = String(v); this._value = null; parse(this, this._html); walk(this, (c) => { patchInnerHtml(c); return null; }); },
|
|
833
849
|
configurable: true,
|
|
834
850
|
});
|
|
835
851
|
patchInnerHtml(root);
|
|
836
|
-
parse(root,
|
|
852
|
+
parse(root, skyBody);
|
|
837
853
|
walk(root, (c) => { patchInnerHtml(c); return null; });
|
|
838
854
|
const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
|
|
839
855
|
// '.brand h1' and '.jump' are the two descendant selectors the page uses.
|
|
@@ -893,7 +909,7 @@ export function bootSkyPage({
|
|
|
893
909
|
innerWidth, innerHeight, devicePixelRatio: 1,
|
|
894
910
|
matchMedia: () => ({ matches: reduce }),
|
|
895
911
|
localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
|
|
896
|
-
location: { hostname: 'builders.cloudbongos.com', search: '', pathname:
|
|
912
|
+
location: { hostname: 'builders.cloudbongos.com', search: '', pathname: `/${page}`, hash: '', assign: (u) => { out.navigated = u; }, replace: (u) => { out.navigated = u; } },
|
|
897
913
|
addEventListener: (type, fn) => { windowListeners.set(type, fn); },
|
|
898
914
|
requestAnimationFrame: (fn) => { raf = fn; return 1; },
|
|
899
915
|
performance: { now: () => Date.now() },
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// tests/idea_filed_via.mjs — WHERE AN IDEA WAS FILED FROM, and WHICH THOUGHTS
|
|
2
|
+
// BECAME THE STRUCTURE (task 1004233 / sky part 5 of 5, goal 1000095; ADR 0331 §4).
|
|
3
|
+
//
|
|
4
|
+
// The sky's prototype guessed two things from text: whether a session or a
|
|
5
|
+
// person filed a thought, and which thoughts became goals or rings. This suite
|
|
6
|
+
// pins the two ledger facts that replaced those guesses:
|
|
7
|
+
// - the column: additive, nullable, no default, NO BACKFILL — a row older
|
|
8
|
+
// than the column stays NULL, because NULL is not 'cli';
|
|
9
|
+
// - the vocabulary: one closed set, spelled in projection.js and mirrored by
|
|
10
|
+
// the migration's CHECK, so a bad value is a named 400 and never a 500;
|
|
11
|
+
// - each writer's value: the route reads the door off the SESSION (web is the
|
|
12
|
+
// hall, cli is the CLI, anything else the API) and lets the body add only
|
|
13
|
+
// 'planning'; capture.js names 'planning' and nothing else; the Discord
|
|
14
|
+
// paths and the agents' sink name their own on the port; a new row always
|
|
15
|
+
// records a door;
|
|
16
|
+
// - the born read: first ratification wins, ids as strings, through the
|
|
17
|
+
// ideas.bornOf port so lifecycle never reads an ideas table itself.
|
|
18
|
+
//
|
|
19
|
+
// DB-free: captureIdea runs against a recording pool (the idea_quick_guard
|
|
20
|
+
// harness), readBornOf against a fake pool, the rest is pure or read from source.
|
|
21
|
+
//
|
|
22
|
+
// Run: node tests/idea_filed_via.mjs
|
|
23
|
+
process.env.NODE_ENV = 'test';
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { makeRunner } from './helpers.mjs';
|
|
30
|
+
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
|
|
34
|
+
const api = require(path.join(ROOT, 'src/module-api.js'));
|
|
35
|
+
let queries = [];
|
|
36
|
+
let nextRows = [];
|
|
37
|
+
api.pool.query = async (sql, params = []) => { queries.push({ sql, params }); return { rows: nextRows }; };
|
|
38
|
+
const inbox = require(path.join(ROOT, 'modules/ideas/inbox.js'));
|
|
39
|
+
const projection = require(path.join(ROOT, 'modules/ideas/projection.js'));
|
|
40
|
+
const { readBornOf } = require(path.join(ROOT, 'modules/ideas/born-of.js'));
|
|
41
|
+
|
|
42
|
+
const { test, summary } = makeRunner();
|
|
43
|
+
const reset = () => { queries = []; nextRows = [{ id: '41', title: 'an idea', status: 'open', grade: 'quick' }]; };
|
|
44
|
+
const FIVE = ['hall', 'cli', 'discord', 'planning', 'api'];
|
|
45
|
+
const migrationFile = fs.readdirSync(path.join(ROOT, 'migrations')).find((f) => /^core_\d+_idea_filed_via\.sql$/.test(f));
|
|
46
|
+
|
|
47
|
+
// ═══ the migration ═══════════════════════════════════════════════════════════
|
|
48
|
+
|
|
49
|
+
await test('the column is a CORE migration: additive, nullable, no default, and the CHECK names exactly the five doors', () => {
|
|
50
|
+
assert.ok(migrationFile, 'migrations/core_NNN_idea_filed_via.sql exists (idea_inbox is a core table — ADR 0083 §Decision #5)');
|
|
51
|
+
const sql = read(path.join('migrations', migrationFile));
|
|
52
|
+
assert.match(sql, /ALTER TABLE idea_inbox\s+ADD COLUMN IF NOT EXISTS filed_via text;/);
|
|
53
|
+
assert.equal(/filed_via text\s+(NOT NULL|DEFAULT)/i.test(sql), false, 'nullable, no default');
|
|
54
|
+
const check = /CHECK \(filed_via IS NULL OR filed_via IN \(([^)]*)\)\)/.exec(sql);
|
|
55
|
+
assert.ok(check, 'a CHECK closes the set');
|
|
56
|
+
assert.deepEqual(check[1].split(',').map((s) => s.trim().replace(/'/g, '')), FIVE);
|
|
57
|
+
assert.match(sql, /IF NOT EXISTS \(\s*SELECT 1 FROM pg_constraint/, 'the CHECK add is guarded — replay-safe');
|
|
58
|
+
assert.ok(sql.includes(`INSERT INTO schema_migrations (version) VALUES ('${migrationFile.replace(/\.sql$/, '')}')`), 'the stem is the version');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await test('NULL IS NOT \'cli\': the migration backfills nothing, and no writer guesses a door from text', () => {
|
|
62
|
+
const sql = read(path.join('migrations', migrationFile));
|
|
63
|
+
assert.equal(/UPDATE idea_inbox/i.test(sql), false, 'no backfill — a historic row stays NULL');
|
|
64
|
+
assert.equal(/title|body_md|ILIKE|~\*/.test(sql.replace(/^--.*$/gm, '')), false, 'nothing in the SQL reads the idea\'s words');
|
|
65
|
+
const src = read('modules/ideas/inbox.js') + read('modules/ideas/projection.js') + read('modules/ideas/routes/inbox.js');
|
|
66
|
+
assert.equal(/filed_via\s*=\s*CASE|classify\([^)]*\)\s*===?\s*'(cli|api)'/.test(src), false, 'no code derives a door from the text either');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ═══ the vocabulary ══════════════════════════════════════════════════════════
|
|
70
|
+
|
|
71
|
+
await test('ONE closed set, spelled in projection.js, carried by every read', () => {
|
|
72
|
+
assert.deepEqual([...projection.FILED_VIA_VALUES], FIVE);
|
|
73
|
+
assert.ok(Object.isFrozen(projection.FILED_VIA_VALUES));
|
|
74
|
+
assert.ok(projection.IDEA_COL_NAMES.includes('filed_via'), 'on the shared column list — so the trail (and GET /sky/mine, which is the trail) returns it');
|
|
75
|
+
assert.match(projection.IDEA_COLS_Q, /\bi\.filed_via\b/);
|
|
76
|
+
for (const v of FIVE) assert.equal(projection.isValidFiledVia(v), true);
|
|
77
|
+
for (const v of ['session', 'person', '', null, undefined, 'HALL']) assert.equal(projection.isValidFiledVia(v), false, `${v} is not a door`);
|
|
78
|
+
assert.deepEqual([inbox.FILED_VIA_VALUES, inbox.resolveFiledVia], [projection.FILED_VIA_VALUES, projection.resolveFiledVia], 'the facade re-exports the one declaration');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ═══ the door is read off the session ════════════════════════════════════════
|
|
82
|
+
|
|
83
|
+
await test('the session says which door: a web cookie is the hall, a device-flow token is the CLI, anything else is the API', () => {
|
|
84
|
+
const { filedViaForSource } = projection;
|
|
85
|
+
assert.equal(filedViaForSource('web'), 'hall');
|
|
86
|
+
assert.equal(filedViaForSource('cli'), 'cli');
|
|
87
|
+
assert.equal(filedViaForSource('box'), 'api');
|
|
88
|
+
assert.equal(filedViaForSource(null), 'api', 'a legacy sourceless session is the API, not a guess');
|
|
89
|
+
assert.equal(filedViaForSource(undefined), 'api');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await test('the body may ADD only \'planning\'; repeating the session\'s own door is harmless; any other claim is refused with the reason', () => {
|
|
93
|
+
const { resolveFiledVia } = projection;
|
|
94
|
+
assert.deepEqual(resolveFiledVia('cli', undefined), { ok: true, value: 'cli' });
|
|
95
|
+
assert.deepEqual(resolveFiledVia('web', null), { ok: true, value: 'hall' });
|
|
96
|
+
assert.deepEqual(resolveFiledVia('cli', 'planning'), { ok: true, value: 'planning' }, 'the planning session is a CLI filing no session can tell apart');
|
|
97
|
+
assert.deepEqual(resolveFiledVia('web', 'planning'), { ok: true, value: 'planning' });
|
|
98
|
+
assert.deepEqual(resolveFiledVia('web', 'hall'), { ok: true, value: 'hall' }, 'naming your own door changes nothing');
|
|
99
|
+
const spoof = resolveFiledVia('cli', 'hall');
|
|
100
|
+
assert.equal(spoof.ok, false, 'a CLI cannot say it came through the hall');
|
|
101
|
+
assert.deepEqual(spoof.detail.allowed, ['planning', 'cli']);
|
|
102
|
+
assert.match(spoof.detail.reason, /read off your session \(cli\)/);
|
|
103
|
+
const junk = resolveFiledVia('web', 'session');
|
|
104
|
+
assert.equal(junk.ok, false);
|
|
105
|
+
assert.match(junk.detail.reason, /must be one of hall, cli, discord, planning, api/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await test('POST /inbox reads the door off req.gdsSession, refuses a bad claim as a named 400, and hands captureIdea the value', () => {
|
|
109
|
+
const src = read('modules/ideas/routes/inbox.js');
|
|
110
|
+
assert.match(src, /filed_via: \{ type: 'string', maxLength: 16 \}/, 'the body field is declared (strict validation would 400 it otherwise)');
|
|
111
|
+
assert.match(src, /inbox\.resolveFiledVia\(req\.gdsSession && req\.gdsSession\.source, body\.filed_via\)/, 'the SESSION source, never the body alone');
|
|
112
|
+
assert.match(src, /res\.fail\('bad_filed_via', 400, door\.detail\)/);
|
|
113
|
+
assert.match(src, /filedVia: door\.value,/);
|
|
114
|
+
assert.match(src, /err\.code === 'BAD_FILED_VIA' \? 400 :/, 'a value that slipped past the route is still a 400, never a constraint 500');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ═══ every writer ════════════════════════════════════════════════════════════
|
|
118
|
+
|
|
119
|
+
await test('captureIdea WRITES the door on the row, defaults a silent port caller to \'api\', and refuses a value outside the set', async () => {
|
|
120
|
+
reset();
|
|
121
|
+
await inbox.captureIdea({ title: 'from the bot', capturedBy: '7', filedVia: 'discord' });
|
|
122
|
+
assert.equal(queries.length, 1);
|
|
123
|
+
assert.match(queries[0].sql, /INSERT INTO idea_inbox[\s\S]*filed_via\)/, 'the column is in the INSERT');
|
|
124
|
+
assert.equal(queries[0].params.at(-1), 'discord', 'and the value is the last bind');
|
|
125
|
+
reset();
|
|
126
|
+
await inbox.captureIdea({ title: 'a program said nothing', capturedBy: null });
|
|
127
|
+
assert.equal(queries[0].params.at(-1), 'api', 'a NEW row always records a door — NULL is for rows older than the column');
|
|
128
|
+
reset();
|
|
129
|
+
await assert.rejects(inbox.captureIdea({ title: 'x', capturedBy: '7', filedVia: 'session' }), (e) => e.code === 'BAD_FILED_VIA' && e.filedVia === 'session' && Array.isArray(e.allowed));
|
|
130
|
+
assert.equal(queries.length, 0, 'refused before any write');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await test('the ROUTED insert (a filing that becomes a task in the same act) writes the door too', () => {
|
|
134
|
+
const src = read('modules/ideas/inbox.js');
|
|
135
|
+
const inserts = [...src.matchAll(/INSERT INTO idea_inbox[\s\S]*?RETURNING \$\{IDEA_COLS\}/g)].map((m) => m[0]);
|
|
136
|
+
assert.equal(inserts.length, 2, 'the inbox insert and the routed insert');
|
|
137
|
+
for (const ins of inserts) assert.match(ins, /filed_via/, 'both name the column');
|
|
138
|
+
assert.match(src, /capturedBy, goalId, landing, filerRank, touches, filedVia,\s*\}, activePool\)/, 'and the routed path is handed the value');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('capture.js names \'planning\' and nothing else — every other CLI filing\'s door is read off the session', () => {
|
|
142
|
+
const src = read('scripts/gds/capture.js');
|
|
143
|
+
assert.match(src, /const viaFromArg = arg\('--via', args\);/);
|
|
144
|
+
assert.match(src, /if \(viaFromArg !== null && viaFromArg !== 'planning'\)[\s\S]{0,400}process\.exit\(2\)/, 'a door the CLI may not name is refused before the request');
|
|
145
|
+
assert.match(src, /if \(viaFromArg\) payload\.filed_via = viaFromArg;/, 'sent only when given — a pinned core that predates the field never sees it');
|
|
146
|
+
assert.equal(/filed_via: 'cli'/.test(src), false, 'never claims cli — the session already says so');
|
|
147
|
+
assert.equal(/postInbox\(/.test(read('scripts/gds/spark.js')), false, 'spark.js files no idea row of its own (a spark is made from an existing idea), so its door is the row\'s');
|
|
148
|
+
assert.match(read('.claude/skills/planning-session/SKILL.md'), /--via planning/, 'the planning session is told to name its door');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('the Discord doors and the agents\' sink name their own door on the port', () => {
|
|
152
|
+
assert.match(read('modules/discord/discord-inbound.js'), /filedVia: 'discord',/);
|
|
153
|
+
assert.match(read('modules/discord/discord-bugs.js'), /filedVia: 'discord',/);
|
|
154
|
+
assert.match(read('modules/agents/pollers/event-router.js'), /filedVia: 'api',/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ═══ born of a thought ═══════════════════════════════════════════════════════
|
|
158
|
+
|
|
159
|
+
function bornPool({ goals = [], criteria = [] } = {}) {
|
|
160
|
+
const calls = [];
|
|
161
|
+
return { calls, async query(sql) { calls.push(sql); return { rows: /ideas_goal_ratifications/.test(sql) ? goals : criteria }; } };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await test('readBornOf: goals from the goal ratifications, criteria from the proposal audit, the FIRST ratification wins, ids as strings', async () => {
|
|
165
|
+
const pool = bornPool({
|
|
166
|
+
goals: [{ goal_id: '7', idea_id: '10' }, { goal_id: '7', idea_id: '99' }, { goal_id: 8, idea_id: 11 }],
|
|
167
|
+
criteria: [{ criterion_id: '70', idea_id: '12' }, { criterion_id: '71', idea_id: null }],
|
|
168
|
+
});
|
|
169
|
+
const born = await readBornOf({ pool });
|
|
170
|
+
assert.deepEqual(born, { goals: { 7: '10', 8: '11' }, criteria: { 70: '12' } });
|
|
171
|
+
assert.equal(pool.calls.length, 2);
|
|
172
|
+
assert.match(pool.calls[0], /FROM ideas_goal_ratifications\s+ORDER BY ratified_at, id/);
|
|
173
|
+
assert.match(pool.calls[1], /FROM criterion_proposal_audit\s+ORDER BY ratified_at, id/);
|
|
174
|
+
assert.deepEqual(await readBornOf({ pool: bornPool() }), { goals: {}, criteria: {} }, 'nothing ratified is an empty map');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await test('the read is served through the ideas.bornOf port, beside ideas.capture, so lifecycle\'s GET /sky never reads an ideas table', () => {
|
|
178
|
+
assert.match(read('modules/ideas/routes/inbox.js'), /api\.registerProvider\('ideas\.bornOf', bornOf\.readBornOf\)/);
|
|
179
|
+
const sky = read('modules/lifecycle/sky.js');
|
|
180
|
+
assert.match(sky, /resolveOptional\('ideas\.bornOf'\)/);
|
|
181
|
+
assert.equal(/ideas_goal_ratifications|criterion_proposal_audit|require\(['"][^'"]*ideas/.test(sky), false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
console.log('');
|
|
185
|
+
summary();
|
package/tests/landing_page.mjs
CHANGED
|
@@ -600,3 +600,11 @@ test('the chip is the profile trigger, built with createElement; Sign out is the
|
|
|
600
600
|
assert.match(CODE, /var signout = document\.querySelector\('#menuProfile a\.signout'\);\s*if \(signout\) signout\.addEventListener\('click', function \(e\) \{ e\.preventDefault\(\); doLogout\(\); \}\);/, 'the honest href, upgraded to the POST');
|
|
601
601
|
assert.doesNotMatch(CODE, /className = 'pill signout'/, 'no sign-out pill in the bar any more');
|
|
602
602
|
});
|
|
603
|
+
|
|
604
|
+
test('the retired gate assets (world/assets/clouds|creatures|plates) are gone — the chrome landing ships its plates from assets/world/ instead (task 1003650)', () => {
|
|
605
|
+
assert.ok(!fs.existsSync(path.join(LANDING, 'world')), 'modules/public-landing/public/world/ no longer exists');
|
|
606
|
+
assert.doesNotMatch(HTML, /\bworld\/assets\//, 'the page links no path under the retired world/assets/ folder');
|
|
607
|
+
assert.doesNotMatch(WORLD, /\bworld\/assets\//, 'world.css references no path under the retired folder');
|
|
608
|
+
// the chrome landing's own plates (task 1003298) live at assets/world/ and are untouched by this cleanup
|
|
609
|
+
assert.ok(fs.existsSync(path.join(LANDING, 'assets', 'world', 'r2-bongos-chrome-b.jpg')), 'the chrome plate at assets/world/ is untouched (task 1003649 converts it separately)');
|
|
610
|
+
});
|
|
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
87
87
|
// the file and left the query alone, so a returning browser kept the cached copy —
|
|
88
88
|
// a fix that reaches nobody looks identical to a fix that does not work.
|
|
89
89
|
|
|
90
|
-
const SHELL_SHA = '
|
|
90
|
+
const SHELL_SHA = '9cc99bb9b7cce948';
|
|
91
91
|
|
|
92
92
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
93
93
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|
|
@@ -62,6 +62,7 @@ const ALL_ORIGINS = [PUB_ORIGIN, PRIV_ORIGIN, STEALTH_ORIGIN, UNREAD_ORIGIN];
|
|
|
62
62
|
const q = (sql, params) => pool.query(sql, params);
|
|
63
63
|
|
|
64
64
|
async function cleanup() {
|
|
65
|
+
await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
65
66
|
await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
66
67
|
await q(`DELETE FROM platform_identity_project_memberships WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
67
68
|
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
@@ -81,7 +82,9 @@ try {
|
|
|
81
82
|
// error dressed up as a boundary failure — instead of a clean assertion.
|
|
82
83
|
// 011 and 020 carry the columns the naming rule reads (description, visibility,
|
|
83
84
|
// the door-known pair); 012_recent_ships the column the rollup SELECTs; 008 and
|
|
84
|
-
// 009 the handle and display_name_source columns upsertAccount writes
|
|
85
|
+
// 009 the handle and display_name_source columns upsertAccount writes; 022 is
|
|
86
|
+
// the table upsertActivity now also writes on every call (task 1002977) — step
|
|
87
|
+
// 5 below issues that INSERT, and a fresh database without it fails 42P01.
|
|
85
88
|
await q(migrationSql('platform_identity_001_tables.sql'));
|
|
86
89
|
await q(migrationSql('platform_identity_004_projects.sql'));
|
|
87
90
|
await q(migrationSql('platform_identity_005_builder_activity.sql'));
|
|
@@ -93,6 +96,7 @@ try {
|
|
|
93
96
|
await q(migrationSql('platform_identity_012_recent_ships.sql'));
|
|
94
97
|
await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
|
|
95
98
|
await q(migrationSql('platform_identity_020_visibility_pull.sql'));
|
|
99
|
+
await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
|
|
96
100
|
await cleanup();
|
|
97
101
|
|
|
98
102
|
// CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state='active' AND
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// tests/project_admin_console_model.mjs — the project admin console model holds
|
|
2
|
+
// (task 1002813, R1 of the console wave; the ADR whose filename ends in ADR_SLUG).
|
|
3
|
+
//
|
|
4
|
+
// The ADR decides three things and then tells three build tasks what to build on
|
|
5
|
+
// them. Its decisions rest on facts about live code, and a decision whose premises
|
|
6
|
+
// drift quietly is worse than none, so the load-bearing ones are pinned here:
|
|
7
|
+
//
|
|
8
|
+
// D1 the hub relays and notifies; it never writes a project's admission row
|
|
9
|
+
// D2 an invite is a row the existing gate already reads — `kind` and
|
|
10
|
+
// `created_by` are descriptive and never enter the admission read
|
|
11
|
+
// D3 the admission verdict stays `access_request.review` at the archon floor,
|
|
12
|
+
// and `project.recruit`, once minted, takes exactly the shape the ADR fixes
|
|
13
|
+
//
|
|
14
|
+
// The ADR is found by its slug, not its number, because a parallel ADR can take
|
|
15
|
+
// the number first and force a renumber (tests/adr_renumber_integrity.mjs).
|
|
16
|
+
//
|
|
17
|
+
// Run: node --test --test-reporter=tap tests/project_admin_console_model.mjs
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
const ADR_DIR = path.join(ROOT, 'docs', 'adr');
|
|
28
|
+
const ADR_SLUG = 'an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md';
|
|
29
|
+
const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
|
|
30
|
+
const { stripComments } = require(path.join(ROOT, 'scripts', 'gds', 'category-advisory-guard.js'));
|
|
31
|
+
|
|
32
|
+
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
|
|
33
|
+
|
|
34
|
+
function findAdr() {
|
|
35
|
+
const hits = fs.readdirSync(ADR_DIR).filter((f) => /^\d{4}-/.test(f) && f.endsWith(ADR_SLUG));
|
|
36
|
+
assert.equal(hits.length, 1, `exactly one ADR ends in ${ADR_SLUG}; found ${hits.length}`);
|
|
37
|
+
return hits[0];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function jsFilesUnder(dir) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
43
|
+
if (entry.name === 'node_modules' || entry.name === 'tests') continue;
|
|
44
|
+
const full = path.join(dir, entry.name);
|
|
45
|
+
if (entry.isDirectory()) out.push(...jsFilesUnder(full));
|
|
46
|
+
else if (entry.name.endsWith('.js')) out.push(full);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('the ADR exists, is indexed, and ADR 0141 names it as superseding its §5 invitations note', () => {
|
|
52
|
+
const file = findAdr();
|
|
53
|
+
const index = read('docs', 'adr', 'README.md');
|
|
54
|
+
assert.ok(index.includes(`](${file})`), 'docs/adr/README.md carries an index row linking the ADR');
|
|
55
|
+
|
|
56
|
+
const status = read('docs', 'adr', '0141-cross-site-identity-federated-hub-idp.md')
|
|
57
|
+
.split(/\r?\n/)
|
|
58
|
+
.find((l) => l.startsWith('- **Status:**'));
|
|
59
|
+
assert.ok(status, 'ADR 0141 keeps a Status line');
|
|
60
|
+
assert.ok(status.includes(file), 'ADR 0141 Status line links the superseding ADR');
|
|
61
|
+
assert.match(status, /§5/, 'the supersession is partial — it names §5, not the whole of ADR 0141');
|
|
62
|
+
|
|
63
|
+
const specHead = read('docs', 'specs', 'bongos-v1-project-admin-console.md').split(/\r?\n/)[0];
|
|
64
|
+
assert.ok(specHead.includes(file), 'the spike spec sends its reader to the ADR that narrowed it');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Both admission reads: the gate's own (hasInvitedAccessRequest) and the state the
|
|
68
|
+
// "confirming access" waiting page polls (accessStateByLogin), which must agree with it.
|
|
69
|
+
test('D2: both admission reads look at status alone — an invite and an approved application admit identically', () => {
|
|
70
|
+
const src = read('src', 'bongos', 'db-kernel.js');
|
|
71
|
+
for (const fn of ['hasInvitedAccessRequest', 'accessStateByLogin']) {
|
|
72
|
+
const m = new RegExp(`async function ${fn}\\([^)]*\\)\\s*\\{([\\s\\S]*?)\\n\\}`).exec(src);
|
|
73
|
+
assert.ok(m, `${fn} is still an admission read in db-kernel.js`);
|
|
74
|
+
const body = m[1];
|
|
75
|
+
assert.match(body, /status\s*=\s*'invited'/, `${fn}: admission is status = invited`);
|
|
76
|
+
assert.doesNotMatch(body, /\bkind\b/, `${fn}: kind is descriptive; it must never decide admission`);
|
|
77
|
+
assert.doesNotMatch(body, /\bcreated_by\b/, `${fn}: created_by is audit data; it must never decide admission`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('D1: no hub (platform-identity) source writes access_requests — the hub relays, it never admits', () => {
|
|
82
|
+
const writers = [];
|
|
83
|
+
for (const f of jsFilesUnder(path.join(ROOT, 'modules', 'platform-identity'))) {
|
|
84
|
+
const code = stripComments(fs.readFileSync(f, 'utf8'));
|
|
85
|
+
if (/\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+access_requests\b/i.test(code)) {
|
|
86
|
+
writers.push(path.relative(ROOT, f));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
assert.deepEqual(writers, [], 'a project admits on its own instance; the hub reaches it only through a relay');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('D3: the verdict stays access_request.review at archon, and project.recruit takes the fixed shape', () => {
|
|
93
|
+
const review = catalog.byKey('access_request.review');
|
|
94
|
+
assert.ok(review, 'access_request.review exists');
|
|
95
|
+
assert.equal(review.floor, 'archon', 'the admission verdict stays at the archon floor');
|
|
96
|
+
|
|
97
|
+
// R3 (task 1002815) mints the atom. Until then there is nothing to check; once
|
|
98
|
+
// it lands, this is the shape the ADR fixed, so a drifted mint reds here.
|
|
99
|
+
const recruit = catalog.byKey('project.recruit');
|
|
100
|
+
if (!recruit) return;
|
|
101
|
+
assert.equal(recruit.floor, 'archon', 'the per-project invite floor defaults to archon (ADR 0217 D1)');
|
|
102
|
+
assert.equal(recruit.system, false, 'grantable, and composable into a custom rank');
|
|
103
|
+
assert.ok(!recruit.substrate, 'enforced in the app, so a grant is never inert');
|
|
104
|
+
assert.ok(recruit.principal == null, 'held by ranks, never by a machine principal');
|
|
105
|
+
});
|
package/tests/render_check.mjs
CHANGED
|
@@ -15,19 +15,21 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
15
15
|
const names = (paths) => statesFilesFor(paths, ROOT).map((f) => path.basename(f));
|
|
16
16
|
|
|
17
17
|
test('a changed page or its own script reaches the states file beside it', () => {
|
|
18
|
-
assert.deepEqual(names(['modules/hall-ui/public/
|
|
18
|
+
assert.deepEqual(names(['modules/hall-ui/public/goals.js']), ['goals.states.json']);
|
|
19
19
|
assert.deepEqual(names(['modules/hall-ui/public/goal-map.html']), ['goal-map.states.json']);
|
|
20
20
|
assert.deepEqual(names(['modules/public-landing/public/projects.html']), ['projects.states.json']);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
test('a shared asset reaches every page in its dir whose HTML names it', () => {
|
|
24
|
-
|
|
24
|
+
// sky-data.js is shared by the two doors of one page (task 1004232): /thinking and /goal-map.
|
|
25
|
+
assert.deepEqual(names(['modules/hall-ui/public/sky-data.js']), ['goal-map.states.json', 'thinking.states.json']);
|
|
26
|
+
assert.deepEqual(names(['modules/hall-ui/public/text-fit.js']), ['goals.states.json']);
|
|
25
27
|
assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['projects.states.json']);
|
|
26
28
|
});
|
|
27
29
|
|
|
28
30
|
test('the page a builder changed comes before the pages a shared file reaches, so the render cap never drops it', () => {
|
|
29
|
-
assert.deepEqual(names(['modules/hall-ui/public/
|
|
30
|
-
assert.deepEqual(names(['modules/hall-ui/public/
|
|
31
|
+
assert.deepEqual(names(['modules/hall-ui/public/sky-data.js', 'modules/hall-ui/public/work.js']), ['work.states.json', 'goal-map.states.json', 'thinking.states.json']);
|
|
32
|
+
assert.deepEqual(names(['modules/hall-ui/public/sky-data.js', 'modules/hall-ui/public/goal-map.html']), ['goal-map.states.json', 'thinking.states.json'], 'a page reached both ways is listed once, as its own');
|
|
31
33
|
});
|
|
32
34
|
|
|
33
35
|
test('a path that is not a rendered surface, or a page with no states file, reaches nothing', () => {
|