@bongos/core 1.19.414 → 1.19.416
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 +173 -23
- package/docs/design/mocks/hub/assets/r3-space-deepfield-half.jpg +0 -0
- package/docs/design/mocks/hub/assets/r3-space-deepfield-half.jpg.json +13 -0
- package/docs/design/mocks/hub/assets/r3-space-dust-half.jpg +0 -0
- package/docs/design/mocks/hub/assets/r3-space-dust-half.jpg.json +13 -0
- package/docs/design/mocks/hub/assets/r3-space-nebula-half.jpg +0 -0
- package/docs/design/mocks/hub/assets/r3-space-nebula-half.jpg.json +13 -0
- package/docs/design/mocks/hub/assets/r3-space-starfield-half.jpg +0 -0
- package/docs/design/mocks/hub/assets/r3-space-starfield-half.jpg.json +13 -0
- package/docs/design/mocks/hub/planets.html +127 -3
- package/docs/design/projects-hub-direction-v2.md +10 -0
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/migrations/platform_identity_013_project_template_materials.sql +31 -0
- package/modules/platform-identity/platform-identity.js +18 -5
- package/modules/platform-identity/routes/projects.js +3 -2
- package/modules/platform-identity/tests/platform-identity.mjs +23 -12
- package/modules/ui-design/kit/fixtures/featured.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +47 -1
- package/public-landing/assets/world/r3-frost-bead-cut.webp +0 -0
- package/public-landing/assets/world/r3-frost-bead-cut.webp.json +15 -0
- package/public-landing/assets/world/r3-frost-blob-cut.webp +0 -0
- package/public-landing/assets/world/r3-frost-blob-cut.webp.json +15 -0
- package/public-landing/assets/world/r3-frost-pair-cut.webp +0 -0
- package/public-landing/assets/world/r3-frost-pair-cut.webp.json +15 -0
- package/public-landing/assets/world/r3-gold-bead-cut.webp +0 -0
- package/public-landing/assets/world/r3-gold-bead-cut.webp.json +15 -0
- package/public-landing/assets/world/r3-gold-blob-cut.webp +0 -0
- package/public-landing/assets/world/r3-gold-blob-cut.webp.json +15 -0
- package/public-landing/assets/world/r3-gold-pair-cut.webp +0 -0
- package/public-landing/assets/world/r3-gold-pair-cut.webp.json +15 -0
- package/public-landing/assets/world/r3-obsidian-bead-cut.webp +0 -0
- package/public-landing/assets/world/r3-obsidian-bead-cut.webp.json +15 -0
- package/public-landing/assets/world/r3-obsidian-blob-cut.webp +0 -0
- package/public-landing/assets/world/r3-obsidian-blob-cut.webp.json +15 -0
- package/public-landing/assets/world/r3-obsidian-pair-cut.webp +0 -0
- package/public-landing/assets/world/r3-obsidian-pair-cut.webp.json +15 -0
- package/public-landing/assets/world/r3-space-nebula.jpg +0 -0
- package/public-landing/assets/world/r3-space-nebula.jpg.json +13 -0
- package/public-landing/projects.html +23 -14
- package/public-landing/projects.states.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/memory_isolation.mjs +4 -0
- package/tests/own_scope_guard.mjs +472 -0
- package/tests/projects_hub.mjs +68 -18
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// tests/own_scope_guard.mjs
|
|
2
|
+
//
|
|
3
|
+
// THE OWN-SCOPE STRUCTURAL GUARD — "an own-scoped route takes its subject from
|
|
4
|
+
// the authenticated session, never from the request" (goal 1000045, criterion
|
|
5
|
+
// own-scope-held-by-a-test; task 1003343).
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS EXISTS. The 2026-08-28 account-security audit found the hole CLOSED:
|
|
8
|
+
// no route on main derives acting identity from a URL, query, body or header
|
|
9
|
+
// parameter. But it was closed BY CONSTRUCTION — held by convention and code
|
|
10
|
+
// review, with nothing in CI to stop a future edit reopening it. One module
|
|
11
|
+
// already held the line structurally (modules/memory, tests/memory_isolation.mjs)
|
|
12
|
+
// and that guard covered exactly one file — and sits in the INTEGRATION set, so
|
|
13
|
+
// it never runs in the DB-free PR lane. This file generalises it to every
|
|
14
|
+
// own-scoped surface and lives in the lane that actually executes (task 1002282:
|
|
15
|
+
// a proof that never runs is not a gate).
|
|
16
|
+
//
|
|
17
|
+
// THE MECHANISM — how an own-scoped route is told apart from a legitimate
|
|
18
|
+
// cross-owner one, which takes an identifier DELIBERATELY and is rank-gated
|
|
19
|
+
// instead (PATCH /builders/:id/rank, the /boxes/:builderId fleet routes,
|
|
20
|
+
// POST /government/assignments, the goals membership routes, release-on-behalf):
|
|
21
|
+
//
|
|
22
|
+
// 1. An explicit INVENTORY of own-scoped surface FILES, one stated reason
|
|
23
|
+
// each. A blanket grep across all routes would break the cross-owner ones;
|
|
24
|
+
// the inventory is what keeps the guard off them entirely.
|
|
25
|
+
// 2. Inside an inventoried file, THE ROUTE'S OWN DECLARED PATH decides. A
|
|
26
|
+
// route that means to act on somebody else NAMES them in its path
|
|
27
|
+
// (`:builderId`). A route whose path names no builder is own-scoped, full
|
|
28
|
+
// stop — that is the route's own public promise to its callers, it cannot
|
|
29
|
+
// be forgotten (you cannot declare a route without a path), and it is
|
|
30
|
+
// exactly the promise this guard enforces.
|
|
31
|
+
// 3. A path that DOES name a builder is a cross-owner route, and is allowed
|
|
32
|
+
// only via a per-route exemption carrying a stated reason AND a required
|
|
33
|
+
// GATE PATTERN THE GUARD VERIFIES IS PRESENT. The exemption does not
|
|
34
|
+
// exempt — it substitutes a different, checked obligation. Drop the gate
|
|
35
|
+
// and the route fails here even though it is on the exemption list.
|
|
36
|
+
// 4. A DISCOVERY BACKSTOP over every route file in the repo: any file that
|
|
37
|
+
// declares a `/me`-mounted route must be in the inventory. `/me` is
|
|
38
|
+
// unambiguous — nothing cross-owner is ever mounted there — so a future
|
|
39
|
+
// own-scoped surface cannot appear without either joining the inventory or
|
|
40
|
+
// failing this test.
|
|
41
|
+
//
|
|
42
|
+
// Rejected alternatives, and why: a marker/annotation convention (`@own-scoped`)
|
|
43
|
+
// is invisible to the guard exactly when the author forgets it, which is the
|
|
44
|
+
// failure mode we exist to catch. An allowlist of exempt ROUTES with no gate
|
|
45
|
+
// check is a free pass — the greenest fix for a red build becomes "add a line to
|
|
46
|
+
// the allowlist". Detecting the adjacent rank gate ALONE cannot work either:
|
|
47
|
+
// rank is a PRIVILEGE dimension, not an OWNERSHIP one (a cross-owner route can
|
|
48
|
+
// be metic-gated and a self route archon-gated), which is precisely why
|
|
49
|
+
// src/bongos/route-rank-check.js cannot catch an own-scoped route growing an
|
|
50
|
+
// override parameter. Here the gate check is used only where ownership has
|
|
51
|
+
// already been established from the path.
|
|
52
|
+
//
|
|
53
|
+
// DB-free by construction: this reads source text. No Postgres, no server.
|
|
54
|
+
//
|
|
55
|
+
// Run: node tests/own_scope_guard.mjs
|
|
56
|
+
|
|
57
|
+
import { strict as assert } from 'node:assert';
|
|
58
|
+
import fs from 'node:fs';
|
|
59
|
+
import path from 'node:path';
|
|
60
|
+
import url from 'node:url';
|
|
61
|
+
|
|
62
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
63
|
+
const repo = path.join(here, '..');
|
|
64
|
+
|
|
65
|
+
let passed = 0;
|
|
66
|
+
let failed = 0;
|
|
67
|
+
function t(name, fn) {
|
|
68
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
69
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ===========================================================================
|
|
73
|
+
// The inventory — own-scoped surfaces, one stated reason each.
|
|
74
|
+
// ===========================================================================
|
|
75
|
+
|
|
76
|
+
const OWN_SCOPED_SURFACES = [
|
|
77
|
+
{
|
|
78
|
+
file: 'src/bongos/routes/me.js',
|
|
79
|
+
why: 'the /me aggregator: identity, rank, credits, streak, claims and prefs for the CALLER. Every slice keys on req.builder.id.',
|
|
80
|
+
crossOwner: [],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
file: 'modules/builder-settings/routes/settings.js',
|
|
84
|
+
why: 'the per-builder preference domain (wandering, skill, sound, render, model-allocation, display name) carved out of /me — read and write, all keyed on req.builder.id.',
|
|
85
|
+
crossOwner: [],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
file: 'modules/platform-identity/routes/profile.js',
|
|
89
|
+
why: "the builder's OWN cross-project profile controls (hide flag, handle, bio/links) — all keyed on req.builder.github_id.",
|
|
90
|
+
crossOwner: [],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
file: 'modules/memory/routes/memory.js',
|
|
94
|
+
why: 'per-builder memory (ADR 0024 §4). The self endpoints are own-scoped; the five cross-builder endpoints name their target in the path and are gate-verified below.',
|
|
95
|
+
crossOwner: [
|
|
96
|
+
{
|
|
97
|
+
path: '/memory/builders/:builderId/files',
|
|
98
|
+
gate: /requirePermission\(\s*['"]memory\.read\.any['"]\s*\)/,
|
|
99
|
+
why: 'forensic LIST of another builder\'s memory metadata — permission-gated on the memory.read.any atom and audit-logged.',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
path: '/memory/builders/:builderId/file',
|
|
103
|
+
gate: /requirePermission\(\s*['"]memory\.read\.any['"]\s*\)/,
|
|
104
|
+
why: 'forensic READ of another builder\'s memory file — permission-gated on the memory.read.any atom and audit-logged.',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
path: '/memory/bfg/builders/:builderId/files',
|
|
108
|
+
gate: /requireBfgPrincipal/,
|
|
109
|
+
why: 'the BFG session-analysis LIST — gated on the BFG principal identity, stricter than any rank (ADR 0026 §6C).',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
path: '/memory/bfg/builders/:builderId/file',
|
|
113
|
+
gate: /requireBfgPrincipal/,
|
|
114
|
+
why: 'the BFG session-analysis READ — gated on the BFG principal identity.',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
path: '/memory/builders/:builderId/bfg-write',
|
|
118
|
+
gate: /requireBfgPrincipal/,
|
|
119
|
+
why: 'the BFG cross-builder WRITE — gated on the BFG principal identity plus a kill switch.',
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
// Floors that keep this guard from going quietly vacuous (a regex that stops
|
|
126
|
+
// matching would otherwise "pass" by checking nothing).
|
|
127
|
+
const MIN_OWN_SCOPED_ROUTES_TOTAL = 28;
|
|
128
|
+
const MIN_OWN_SCOPED_ROUTES_PER_FILE = 3;
|
|
129
|
+
|
|
130
|
+
// ===========================================================================
|
|
131
|
+
// Machinery
|
|
132
|
+
// ===========================================================================
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Blank out comments while preserving every byte offset and newline, so route
|
|
136
|
+
* indices and line numbers stay true. Comment prose talks ABOUT req.body.builder
|
|
137
|
+
* constantly; only real code may be judged.
|
|
138
|
+
*/
|
|
139
|
+
function blankComments(src) {
|
|
140
|
+
const out = src.split('');
|
|
141
|
+
let i = 0;
|
|
142
|
+
let state = 'code'; // code | line | block | sq | dq | tpl
|
|
143
|
+
while (i < src.length) {
|
|
144
|
+
const c = src[i];
|
|
145
|
+
const d = src[i + 1];
|
|
146
|
+
if (state === 'code') {
|
|
147
|
+
if (c === '/' && d === '/') { out[i] = ' '; out[i + 1] = ' '; i += 2; state = 'line'; continue; }
|
|
148
|
+
if (c === '/' && d === '*') { out[i] = ' '; out[i + 1] = ' '; i += 2; state = 'block'; continue; }
|
|
149
|
+
if (c === "'") { state = 'sq'; i++; continue; }
|
|
150
|
+
if (c === '"') { state = 'dq'; i++; continue; }
|
|
151
|
+
if (c === '`') { state = 'tpl'; i++; continue; }
|
|
152
|
+
i++; continue;
|
|
153
|
+
}
|
|
154
|
+
if (state === 'line') {
|
|
155
|
+
if (c === '\n') { state = 'code'; i++; continue; }
|
|
156
|
+
out[i] = ' '; i++; continue;
|
|
157
|
+
}
|
|
158
|
+
if (state === 'block') {
|
|
159
|
+
if (c === '*' && d === '/') { out[i] = ' '; out[i + 1] = ' '; i += 2; state = 'code'; continue; }
|
|
160
|
+
if (c !== '\n') out[i] = ' ';
|
|
161
|
+
i++; continue;
|
|
162
|
+
}
|
|
163
|
+
// inside a string literal — kept verbatim (literals are evidence, see below)
|
|
164
|
+
if (c === '\\') { i += 2; continue; }
|
|
165
|
+
if ((state === 'sq' && c === "'") || (state === 'dq' && c === '"') || (state === 'tpl' && c === '`')) { state = 'code'; i++; continue; }
|
|
166
|
+
i++;
|
|
167
|
+
}
|
|
168
|
+
return out.join('');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Does this identifier NAME A BUILDER — i.e. is it a subject selector rather
|
|
173
|
+
* than a payload field? This is the guard's contract, so it is a written list,
|
|
174
|
+
* not a vibe: anything containing "builder" or "owner", plus the explicit set of
|
|
175
|
+
* other ways one account points at another. Deliberately NOT here: `handle`,
|
|
176
|
+
* `display_name`, `anthropic_user_id` — payload the caller sets on THEMSELVES.
|
|
177
|
+
*/
|
|
178
|
+
const SUBJECT_NAMES = new Set([
|
|
179
|
+
'githubid', 'githublogin', 'githubuser', 'githubuserid', 'login',
|
|
180
|
+
'actas', 'asbuilder', 'onbehalf', 'onbehalfof', 'impersonate', 'impersonateas',
|
|
181
|
+
'targetid', 'targetlogin', 'targetbuilder', 'subject', 'subjectid',
|
|
182
|
+
'accountid', 'memberid', 'principal', 'principalid',
|
|
183
|
+
]);
|
|
184
|
+
function namesABuilder(name) {
|
|
185
|
+
const k = String(name).toLowerCase().replace(/[_-]/g, '');
|
|
186
|
+
if (k.includes('builder') || k.includes('owner')) return true;
|
|
187
|
+
return SUBJECT_NAMES.has(k);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const REQUEST_CONTAINERS = 'body|query|params|headers';
|
|
191
|
+
const ROUTE_DECL = /\brouter\.(get|post|put|patch|delete|all)\(\s*(['"`])([^'"`\n]+)\2/g;
|
|
192
|
+
const HANDLER_START = /(?:async\s*)?\(\s*_?req\s*,/;
|
|
193
|
+
|
|
194
|
+
/** Split a route file into { verb, routePath, decl, body, line } records. */
|
|
195
|
+
function parseRoutes(code) {
|
|
196
|
+
const decls = [...code.matchAll(ROUTE_DECL)];
|
|
197
|
+
return decls.map((m, i) => {
|
|
198
|
+
const start = m.index;
|
|
199
|
+
const end = i + 1 < decls.length ? decls[i + 1].index : code.length;
|
|
200
|
+
const window = code.slice(start, end);
|
|
201
|
+
const hIdx = window.search(HANDLER_START);
|
|
202
|
+
return {
|
|
203
|
+
verb: m[1],
|
|
204
|
+
routePath: m[3],
|
|
205
|
+
line: code.slice(0, start).split('\n').length,
|
|
206
|
+
// the middleware chain (where the gates live) vs the handler (where the
|
|
207
|
+
// subject is read). When the handler can't be located, both halves fall
|
|
208
|
+
// back to the whole window — conservative, never silently narrower.
|
|
209
|
+
decl: hIdx >= 0 ? window.slice(0, hIdx) : window,
|
|
210
|
+
body: hIdx >= 0 ? window.slice(hIdx) : window,
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Every way this route pulls a named thing out of the request, as a flat list of
|
|
217
|
+
* { how, name } — direct reads, optional chaining, destructuring, aliases of a
|
|
218
|
+
* container, rest elements, literal bracket access, computed access (judged by
|
|
219
|
+
* the string literals in the route's array literals, which is where a copy-these
|
|
220
|
+
* -fields loop keeps its key list), and header lookups via req.get().
|
|
221
|
+
*/
|
|
222
|
+
function requestReads(routeBody) {
|
|
223
|
+
const reads = [];
|
|
224
|
+
const containerRe = String.raw`req\s*\??\.\s*(?:${REQUEST_CONTAINERS})`;
|
|
225
|
+
|
|
226
|
+
// Aliases: `const body = req.body` / `const { a, ...rest } = req.query`.
|
|
227
|
+
const aliases = new Set();
|
|
228
|
+
for (const m of routeBody.matchAll(new RegExp(String.raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${containerRe}\b`, 'g'))) {
|
|
229
|
+
aliases.add(m[1]);
|
|
230
|
+
}
|
|
231
|
+
// Re-derived on every use: the destructuring pass below can discover MORE
|
|
232
|
+
// aliases (rest elements), and each later pass must see them.
|
|
233
|
+
const containers = () => `(?:${containerRe}${aliases.size ? `|\\b(?:${[...aliases].join('|')})` : ''})`;
|
|
234
|
+
|
|
235
|
+
// Destructuring — `const { builder_id } = req.body`. A rest element becomes an
|
|
236
|
+
// alias itself: an object spread off the request IS the request.
|
|
237
|
+
for (const m of routeBody.matchAll(new RegExp(String.raw`(?:const|let|var)\s*\{([^}]*)\}\s*=\s*${containers()}\b`, 'g'))) {
|
|
238
|
+
for (const part of m[1].split(',')) {
|
|
239
|
+
const rest = part.match(/\.\.\.\s*([A-Za-z_$][\w$]*)/);
|
|
240
|
+
if (rest) { aliases.add(rest[1]); continue; }
|
|
241
|
+
const key = part.split(':')[0].split('=')[0].trim();
|
|
242
|
+
if (key) reads.push({ how: 'destructured from the request', name: key });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Direct member reads — `req.query.builder_id`, `body?.owner`.
|
|
247
|
+
for (const m of routeBody.matchAll(new RegExp(String.raw`${containers()}\s*\??\.\s*([A-Za-z_$][\w$]*)`, 'g'))) {
|
|
248
|
+
reads.push({ how: 'read from the request', name: m[1] });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Literal bracket access — `req.body['builder_id']`. Exact, so judged exactly.
|
|
252
|
+
for (const m of routeBody.matchAll(new RegExp(String.raw`${containers()}\s*\[\s*(['"])([^'"]+)\1`, 'g'))) {
|
|
253
|
+
reads.push({ how: 'read from the request', name: m[2] });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Variable bracket access — `req.body[k]`. The key is unknowable statically, so
|
|
257
|
+
// the string literals in the route's ARRAY literals are the candidate key set:
|
|
258
|
+
// that is exactly how the copy-these-fields loops in these files are written.
|
|
259
|
+
// Scoped to array literals so ordinary message/error strings can't false-positive.
|
|
260
|
+
if (new RegExp(String.raw`${containers()}\s*\[\s*[A-Za-z_$]`).test(routeBody)) {
|
|
261
|
+
for (const arr of routeBody.matchAll(/\[([^\][]*)\]/g)) {
|
|
262
|
+
for (const m of arr[1].matchAll(/(['"])([A-Za-z_][\w-]*)\1/g)) {
|
|
263
|
+
reads.push({ how: 'a candidate computed request key', name: m[2] });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Header lookups by name.
|
|
269
|
+
for (const m of routeBody.matchAll(/\breq\s*\.\s*get\(\s*(['"])([^'"]+)\1/g)) {
|
|
270
|
+
reads.push({ how: 'read from a request header', name: m[2] });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return reads;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The `:name` segments a route declares — the route saying out loud what it takes. */
|
|
277
|
+
function pathParams(routePath) {
|
|
278
|
+
return [...routePath.matchAll(/:([A-Za-z_][\w]*)/g)].map((m) => m[1]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ===========================================================================
|
|
282
|
+
// Self-tests — the machinery above is the guard, so it gets proved too.
|
|
283
|
+
// ===========================================================================
|
|
284
|
+
|
|
285
|
+
console.log('guard machinery:');
|
|
286
|
+
|
|
287
|
+
t('blankComments erases comment prose but keeps offsets and string literals', () => {
|
|
288
|
+
const src = "const a = 1; // req.body.builder_id\n/* req.query.owner */ const s = 'builder_id';\n";
|
|
289
|
+
const out = blankComments(src);
|
|
290
|
+
assert.equal(out.length, src.length, 'offsets must be preserved');
|
|
291
|
+
assert.ok(!/req\.body\.builder_id/.test(out), 'line comments must be erased');
|
|
292
|
+
assert.ok(!/req\.query\.owner/.test(out), 'block comments must be erased');
|
|
293
|
+
assert.ok(/'builder_id'/.test(out), 'string literals are evidence and must survive');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
t('namesABuilder separates subject selectors from self-payload fields', () => {
|
|
297
|
+
for (const n of ['builder_id', 'builderId', 'owner', 'target_builder', 'github_id', 'githubLogin', 'as_builder', 'login']) {
|
|
298
|
+
assert.equal(namesABuilder(n), true, `${n} names a builder`);
|
|
299
|
+
}
|
|
300
|
+
for (const n of ['handle', 'display_name', 'bio', 'links', 'limit', 'anthropic_user_id', 'anthropic_email', 'files', 'path', 'hide_stats', 'preferred_disciplines', 'user-agent']) {
|
|
301
|
+
assert.equal(namesABuilder(n), false, `${n} is self-payload, not a subject`);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
t('requestReads sees direct, optional-chained, destructured and aliased reads', () => {
|
|
306
|
+
const reads = (s) => requestReads(s).map((r) => r.name);
|
|
307
|
+
assert.ok(reads('const x = req.query.builder_id;').includes('builder_id'));
|
|
308
|
+
assert.ok(reads('const x = req.body?.owner;').includes('owner'));
|
|
309
|
+
assert.ok(reads('const { builder_id, y } = req.body;').includes('builder_id'));
|
|
310
|
+
assert.ok(reads('const b = req.body;\nconst v = b.builder_id;').includes('builder_id'));
|
|
311
|
+
assert.ok(reads('const { a, ...rest } = req.body;\nconst v = rest.owner_id;').includes('owner_id'));
|
|
312
|
+
assert.ok(reads("const v = req.body['builder_id'];").includes('builder_id'));
|
|
313
|
+
assert.ok(reads("for (const k of ['builder_id']) f[k] = req.body[k];").includes('builder_id'));
|
|
314
|
+
assert.ok(reads("const v = req.get('x-builder-id');").includes('x-builder-id'));
|
|
315
|
+
// ...but an ordinary message string is not a candidate key: no bracket read, no scan.
|
|
316
|
+
assert.ok(!reads("return res.fail('builder_not_found', 404);").includes('builder_not_found'));
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ===========================================================================
|
|
320
|
+
// The guard
|
|
321
|
+
// ===========================================================================
|
|
322
|
+
|
|
323
|
+
console.log('\nown-scope guard (every own-scoped route keys on the session):');
|
|
324
|
+
|
|
325
|
+
let totalOwnScoped = 0;
|
|
326
|
+
let totalCrossOwner = 0;
|
|
327
|
+
|
|
328
|
+
for (const surface of OWN_SCOPED_SURFACES) {
|
|
329
|
+
const abs = path.join(repo, surface.file);
|
|
330
|
+
const raw = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
|
|
331
|
+
|
|
332
|
+
t(`${surface.file} — inventoried own-scoped surface exists and declares routes`, () => {
|
|
333
|
+
assert.ok(raw !== null, `${surface.file} is in the own-scoped inventory but does not exist — update the inventory or restore the file`);
|
|
334
|
+
const rawDecls = (raw.match(/\brouter\.(?:get|post|put|patch|delete|all)\(/g) || []).length;
|
|
335
|
+
const parsed = parseRoutes(blankComments(raw)).length;
|
|
336
|
+
assert.ok(parsed > 0, 'no routes parsed — the route-declaration regex has rotted');
|
|
337
|
+
assert.equal(parsed, rawDecls, `parsed ${parsed} routes but the file declares ${rawDecls} — the guard is not seeing every route`);
|
|
338
|
+
});
|
|
339
|
+
if (raw === null) continue;
|
|
340
|
+
|
|
341
|
+
const code = blankComments(raw);
|
|
342
|
+
const routes = parseRoutes(code);
|
|
343
|
+
const exemptions = surface.crossOwner || [];
|
|
344
|
+
const usedExemptions = new Set();
|
|
345
|
+
let ownScopedHere = 0;
|
|
346
|
+
|
|
347
|
+
for (const r of routes) {
|
|
348
|
+
const id = `${surface.file}:${r.line} ${r.verb.toUpperCase()} ${r.routePath}`;
|
|
349
|
+
const subjectParams = pathParams(r.routePath).filter(namesABuilder);
|
|
350
|
+
|
|
351
|
+
if (subjectParams.length > 0) {
|
|
352
|
+
// Cross-owner: names its target in the path. Allowed only with a stated
|
|
353
|
+
// reason AND a gate the guard confirms is really there.
|
|
354
|
+
totalCrossOwner++;
|
|
355
|
+
t(`${id} — cross-owner route is exempted with a reason AND carries its gate`, () => {
|
|
356
|
+
const ex = exemptions.find((e) => e.path === r.routePath);
|
|
357
|
+
assert.ok(
|
|
358
|
+
ex,
|
|
359
|
+
`takes a builder from the path (:${subjectParams.join(', :')}) but is not on the cross-owner exemption list for this file. `
|
|
360
|
+
+ 'If it is deliberately cross-owner, add it with a stated reason and the gate that protects it; otherwise it must not name a builder.'
|
|
361
|
+
);
|
|
362
|
+
usedExemptions.add(ex.path);
|
|
363
|
+
assert.ok(ex.why && ex.why.length > 20, 'a cross-owner exemption must carry a stated reason');
|
|
364
|
+
// The exemption is only as strong as what it demands, so the demand
|
|
365
|
+
// itself is constrained: it must name a real privilege atom, not a
|
|
366
|
+
// regex that matches anything the author happens to have written.
|
|
367
|
+
assert.match(
|
|
368
|
+
ex.gate.source, /require(?:Permission|Rank|BfgPrincipal|Archon)/,
|
|
369
|
+
'a cross-owner exemption must require a real gate (requirePermission / requireRank / requireBfgPrincipal / requireArchon)'
|
|
370
|
+
);
|
|
371
|
+
assert.ok(
|
|
372
|
+
ex.gate.test(r.decl),
|
|
373
|
+
`the exemption requires ${ex.gate} on the middleware chain and it is NOT there — a cross-owner route without its gate is the hole this guard exists to catch`
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Own-scoped: the path names no builder, so the subject can only be the session.
|
|
380
|
+
ownScopedHere++;
|
|
381
|
+
totalOwnScoped++;
|
|
382
|
+
|
|
383
|
+
t(`${id} — own-scoped route never takes its subject from the request`, () => {
|
|
384
|
+
const offenders = requestReads(r.body).filter((x) => namesABuilder(x.name));
|
|
385
|
+
assert.equal(
|
|
386
|
+
offenders.length, 0,
|
|
387
|
+
`${offenders.map((o) => `"${o.name}" (${o.how})`).join(', ')} — this route's path names no builder, so its subject MUST come from req.builder. `
|
|
388
|
+
+ 'If it is genuinely meant to act on someone else, name them in the path, gate it, and add a cross-owner exemption.'
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
t(`${id} — own-scoped route derives its subject from the authenticated session`, () => {
|
|
393
|
+
assert.ok(/\brequireBuilder\b/.test(r.decl), 'an own-scoped route must sit behind requireBuilder');
|
|
394
|
+
const readsSession = /\breq\s*\.\s*builder\b/.test(r.body) || /\bassertSelf\s*\(\s*req\b/.test(r.body);
|
|
395
|
+
const ignoresRequest = /\(\s*_req\s*,/.test(r.body);
|
|
396
|
+
assert.ok(
|
|
397
|
+
readsSession || ignoresRequest,
|
|
398
|
+
'neither reads req.builder nor declares its request unused (_req) — it is scoped to nobody, which is not own-scope'
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
t(`${surface.file} — contributes a real number of own-scoped routes (not vacuous)`, () => {
|
|
404
|
+
assert.ok(
|
|
405
|
+
ownScopedHere >= MIN_OWN_SCOPED_ROUTES_PER_FILE,
|
|
406
|
+
`only ${ownScopedHere} own-scoped routes found (floor ${MIN_OWN_SCOPED_ROUTES_PER_FILE}) — either routes moved out or the parser stopped seeing them`
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
t(`${surface.file} — every listed cross-owner exemption still names a real route`, () => {
|
|
411
|
+
const stale = exemptions.filter((e) => !usedExemptions.has(e.path)).map((e) => e.path);
|
|
412
|
+
assert.equal(stale.length, 0, `stale exemption(s) for route(s) that no longer exist: ${stale.join(', ')} — an exemption for a deleted route silently widens the guard`);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ===========================================================================
|
|
417
|
+
// Discovery backstop + lane proof
|
|
418
|
+
// ===========================================================================
|
|
419
|
+
|
|
420
|
+
console.log('\ncoverage (the guard cannot be dodged by adding a new file, or parked out of CI):');
|
|
421
|
+
|
|
422
|
+
function routeFiles() {
|
|
423
|
+
const out = [];
|
|
424
|
+
const walk = (dir) => {
|
|
425
|
+
let entries;
|
|
426
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
427
|
+
for (const e of entries) {
|
|
428
|
+
const p = path.join(dir, e.name);
|
|
429
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); }
|
|
430
|
+
else if (e.name.endsWith('.js')) out.push(p);
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
walk(path.join(repo, 'src'));
|
|
434
|
+
walk(path.join(repo, 'modules'));
|
|
435
|
+
return out;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
t('every file in the repo that mounts a /me route is in the own-scoped inventory', () => {
|
|
439
|
+
const inventory = new Set(OWN_SCOPED_SURFACES.map((s) => s.file.split('/').join(path.sep)));
|
|
440
|
+
const missing = [];
|
|
441
|
+
for (const abs of routeFiles()) {
|
|
442
|
+
const code = blankComments(fs.readFileSync(abs, 'utf8'));
|
|
443
|
+
const declaresMe = parseRoutes(code).some((r) => r.routePath === '/me' || r.routePath.startsWith('/me/'));
|
|
444
|
+
if (!declaresMe) continue;
|
|
445
|
+
const rel = path.relative(repo, abs);
|
|
446
|
+
if (!inventory.has(rel)) missing.push(rel.split(path.sep).join('/'));
|
|
447
|
+
}
|
|
448
|
+
assert.equal(
|
|
449
|
+
missing.length, 0,
|
|
450
|
+
`these files mount /me routes but are not in OWN_SCOPED_SURFACES: ${missing.join(', ')}. `
|
|
451
|
+
+ 'Nothing cross-owner is ever mounted at /me, so add them to the inventory.'
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
t('the guard checks a real number of own-scoped routes overall', () => {
|
|
456
|
+
assert.ok(
|
|
457
|
+
totalOwnScoped >= MIN_OWN_SCOPED_ROUTES_TOTAL,
|
|
458
|
+
`only ${totalOwnScoped} own-scoped routes checked (floor ${MIN_OWN_SCOPED_ROUTES_TOTAL})`
|
|
459
|
+
);
|
|
460
|
+
assert.ok(totalCrossOwner > 0, 'no cross-owner route was exercised — the gate-verification path is untested');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
t('this guard runs in the DB-free unit lane (it is not parked as integration or known-failing)', () => {
|
|
464
|
+
const runner = fs.readFileSync(path.join(repo, 'scripts', 'gds', 'run-unit-tests.js'), 'utf8');
|
|
465
|
+
assert.ok(
|
|
466
|
+
!/own_scope_guard/.test(runner),
|
|
467
|
+
'own_scope_guard is named in run-unit-tests.js — if it landed in INTEGRATION or KNOWN_FAILING it would stop gating anything (task 1002282)'
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
472
|
+
process.exit(failed === 0 ? 0 : 1);
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import assert from 'node:assert/strict';
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
19
20
|
|
|
20
21
|
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
22
|
const HUB = fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.html'), 'utf8');
|
|
@@ -1403,31 +1404,41 @@ test('the joinability in-flight line shares the visibility section’s push pred
|
|
|
1403
1404
|
/* ── the planets and the field (task 1003319) — the map's orbs as the landing's
|
|
1404
1405
|
chrome objects in the project's accent, a per-project template with a
|
|
1405
1406
|
deterministic default walk, and the field brought to the world. ── */
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1407
|
+
const TEMPLATE_RE = /^(blob|pair|bead)-(tint|wash|chrome|(frost|gold|obsidian)-(tint|wash|plain))$/;
|
|
1408
|
+
const partsOf = (t) => { const p = t.split('-'); return p.length === 3 ? p : [p[0], 'chrome', p[1]]; };
|
|
1409
|
+
|
|
1410
|
+
test('the planet templates: shape × material × style; the walk changes all three each step; the catalog and the migration agree (tasks 1003319, 1003337)', () => {
|
|
1411
|
+
const walkList = (SCRIPT.match(/var ORB_WALK = \[([^\]]*)\]/) || [, ''])[1];
|
|
1412
|
+
const walk = [...walkList.matchAll(/'([a-z-]+)'/g)].map((m) => m[1]);
|
|
1413
|
+
assert.equal(walk.length, 12);
|
|
1414
|
+
assert.equal(new Set(walk).size, 12, 'twelve distinct walk positions');
|
|
1415
|
+
for (const t of walk) assert.match(t, TEMPLATE_RE);
|
|
1416
|
+
for (let i = 0; i < walk.length; i++) {
|
|
1417
|
+
const [s0, m0, y0] = partsOf(walk[(i + walk.length - 1) % walk.length]);
|
|
1418
|
+
const [s1, m1, y1] = partsOf(walk[i]);
|
|
1419
|
+
assert.ok(s0 !== s1 && m0 !== m1 && y0 !== y1, `walk step ${i} changes shape, material and style (wrapping)`);
|
|
1416
1420
|
}
|
|
1417
|
-
const
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1421
|
+
const P = createRequire(import.meta.url)('../modules/platform-identity/platform-identity.js');
|
|
1422
|
+
assert.deepEqual(P.PROJECT_TEMPLATE_WALK, walk, 'the hub and the catalog walk the same list in the same order');
|
|
1423
|
+
// the client's full set is built from the same grammar the server builds it from
|
|
1424
|
+
assert.match(SCRIPT, /var ORB_SHAPES = \['blob', 'pair', 'bead'\], ORB_MATERIALS = \['frost', 'gold', 'obsidian'\];/);
|
|
1425
|
+
assert.match(SCRIPT, /ORB_SHAPES\.forEach\(function \(s\) \{ \['tint', 'wash', 'chrome'\]\.forEach\(function \(y\) \{ ORB_TEMPLATES\.push\(s \+ '-' \+ y\); \}\); \}\);/);
|
|
1426
|
+
assert.match(SCRIPT, /ORB_SHAPES\.forEach\(function \(s\) \{ ORB_MATERIALS\.forEach\(function \(m\) \{ \['tint', 'wash', 'plain'\]\.forEach\(function \(y\) \{ ORB_TEMPLATES\.push\(s \+ '-' \+ m \+ '-' \+ y\); \}\); \}\); \}\);/);
|
|
1427
|
+
assert.equal(P.PROJECT_TEMPLATES.length, 36);
|
|
1428
|
+
for (const t of P.PROJECT_TEMPLATES) assert.match(t, TEMPLATE_RE);
|
|
1429
|
+
for (const t of walk) assert.ok(P.PROJECT_TEMPLATES.includes(t), `the walk's ${t} is a valid name`);
|
|
1430
|
+
const mig = fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'migrations', 'platform_identity_013_project_template_materials.sql'), 'utf8');
|
|
1431
|
+
for (const t of P.PROJECT_TEMPLATES) assert.ok(mig.includes(`'${t}'`), `migration 013 names ${t}`);
|
|
1422
1432
|
});
|
|
1423
1433
|
|
|
1424
1434
|
test('the walk assigns the template by canonical position beside the accent; a curated template wins; the platform is the pair in chrome', () => {
|
|
1425
|
-
assert.match(SCRIPT, /function assignAccents[\s\S]*?p\._acc = ORB_ACCENTS\[i % ORB_ACCENTS\.length\];\s*p\._tpl =
|
|
1435
|
+
assert.match(SCRIPT, /function assignAccents[\s\S]*?p\._acc = ORB_ACCENTS\[i % ORB_ACCENTS\.length\];\s*p\._tpl = ORB_WALK\[i % ORB_WALK\.length\];/,
|
|
1426
1436
|
'one walk, two assignments');
|
|
1427
|
-
assert.match(SCRIPT, /function templateOf\(p\) \{\s*return ORB_TEMPLATES\.indexOf\(p\.template\) !== -1 \? p\.template : \(p\._tpl \|\|
|
|
1437
|
+
assert.match(SCRIPT, /function templateOf\(p\) \{\s*return ORB_TEMPLATES\.indexOf\(p\.template\) !== -1 \? p\.template : \(p\._tpl \|\| ORB_WALK\[0\]\);/,
|
|
1428
1438
|
'a curated name wins; an unknown name is not a choice');
|
|
1429
1439
|
assert.match(SCRIPT, /_acc: ORB_ACCENTS\[slugAccent\(inst\.slug\)\], _tpl: slugTemplate\(inst\.slug\)/, 'an owned-only entry walks by its slug');
|
|
1430
|
-
assert.match(SCRIPT, /
|
|
1440
|
+
assert.match(SCRIPT, /function slugTemplate\(slug\) \{ return ORB_WALK\[slugHash\(slug\) % ORB_WALK\.length\]; \}/, 'by the walk, not the whole set');
|
|
1441
|
+
assert.match(SCRIPT, /var tpl = templateOf\(p\), tplParts = tpl\.split\('-'\);\s*a\.setAttribute\('data-template', tpl\);\s*a\.setAttribute\('data-shape', tplParts\[0\]\);\s*a\.setAttribute\('data-material', tplParts\.length === 3 \? tplParts\[1\] : 'chrome'\);\s*a\.setAttribute\('data-style', tplParts\[tplParts\.length - 1\]\);/,
|
|
1431
1442
|
'the orb carries its template as data attributes; the sheet does the rest');
|
|
1432
1443
|
assert.match(HUB, /<a class="worldOrb" id="selfOrb" href="\/" data-shape="pair" data-style="chrome" data-template="pair-chrome"/, 'the platform is its own mark');
|
|
1433
1444
|
});
|
|
@@ -1455,6 +1466,43 @@ test("the planet is the landing's object on the landing's crop maths: the three
|
|
|
1455
1466
|
assert.match(COSMOS, /--plate-bongos-cut:url\(\/assets\/world\/r2-bongos-chrome-a-cut\.webp\);/);
|
|
1456
1467
|
});
|
|
1457
1468
|
|
|
1469
|
+
test('the materials (task 1003337): a whole object per plate, the crop recomputed from the cutout\'s alpha box; every plate ships with alpha and a sidecar', () => {
|
|
1470
|
+
// the boxes measured from the shipped cutouts (alpha >= 16 on the 2048² plate); the sheet
|
|
1471
|
+
// pads each side by 1.5% and derives zoom = 1/width, px/py = -u0/-v0 × zoom (square plates)
|
|
1472
|
+
const BBOX = { 'frost-blob': { u0:0.168, u1:0.8223, v0:0.2495, v1:0.7642 }, 'frost-pair': { u0:0.0591, u1:0.9409, v0:0.2764, v1:0.7231 }, 'frost-bead': { u0:0.1685, u1:0.8066, v0:0.1738, v1:0.8198 }, 'gold-blob': { u0:0.1274, u1:0.8584, v0:0.1035, v1:0.9019 }, 'gold-pair': { u0:0.0552, u1:0.9458, v0:0.2739, v1:0.7383 }, 'gold-bead': { u0:0.0542, u1:0.9478, v0:0.0542, v1:0.9434 }, 'obsidian-blob': { u0:0.1885, u1:0.8018, v0:0.1177, v1:0.8979 }, 'obsidian-pair': { u0:0.0249, u1:0.9707, v0:0.3096, v1:0.7158 }, 'obsidian-bead': { u0:0.1416, u1:0.854, v0:0.1431, v1:0.8535 } };
|
|
1473
|
+
const MARGIN = 0.015;
|
|
1474
|
+
const f5 = (n) => { const s = n.toFixed(5); return s.replace(/^(-?)0\./, '$1.').replace(/\.?0+$/, '') || '0'; };
|
|
1475
|
+
for (const [key, b] of Object.entries(BBOX)) {
|
|
1476
|
+
const [material, shape] = key.split('-');
|
|
1477
|
+
const U0 = Math.max(0, b.u0 - MARGIN), U1 = Math.min(1, b.u1 + MARGIN), V0 = Math.max(0, b.v0 - MARGIN), V1 = Math.min(1, b.v1 + MARGIN);
|
|
1478
|
+
const w = U1 - U0, h = V1 - V0, zoom = 1 / w;
|
|
1479
|
+
const rule = `.worldOrb[data-material="${material}"][data-shape="${shape}"]{--tpl-src:var(--plate-${material}-${shape}-cut);--tpl-ar:${f5(w / h)};--tpl-zoom:${f5(zoom)};--tpl-px:${f5(-U0 * zoom)};--tpl-py:${f5(-V0 * zoom)};}`;
|
|
1480
|
+
assert.ok(COSMOS.includes(rule), `the ${key} crop follows from its box: ${rule}`);
|
|
1481
|
+
assert.ok(COSMOS.includes(`--plate-${material}-${shape}-cut:url(/assets/world/r3-${material}-${shape}-cut.webp);`), `the ${key} plate token`);
|
|
1482
|
+
const file = path.join(ROOT, 'public-landing', 'assets', 'world', `r3-${material}-${shape}-cut.webp`);
|
|
1483
|
+
assert.ok(fs.existsSync(file), `${key}: the cutout ships`);
|
|
1484
|
+
assert.ok(fs.existsSync(file + '.json'), `${key}: with its sidecar`);
|
|
1485
|
+
const head = fs.readFileSync(file).subarray(0, 40).toString('latin1');
|
|
1486
|
+
assert.ok(head.startsWith('RIFF') && head.includes('WEBP') && /ALPH|VP8X/.test(head), `${key}: WebP with an alpha chunk`);
|
|
1487
|
+
}
|
|
1488
|
+
assert.match(COSMOS, /\.worldOrb\[data-style="plain"\]\{--tpl-ink:transparent;--tpl-blend:normal;\}/, 'plain is the material as rendered');
|
|
1489
|
+
// the material rules carry two attributes so they outrank the one-attribute chrome crops
|
|
1490
|
+
assert.ok(COSMOS.indexOf('.worldOrb[data-material="frost"][data-shape="blob"]') > COSMOS.indexOf('.worldOrb[data-shape="bead"]{'), 'the materials follow the chrome crops');
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
test('the field carries a rendered backdrop (task 1003337): one token, cover, a slow transform drift, the plate and its sidecar on disk', () => {
|
|
1494
|
+
assert.match(COSMOS, /--field-plate:url\(\/assets\/world\/r3-space-nebula\.jpg\);/, 'the pick is one token');
|
|
1495
|
+
assert.match(COSMOS, /\.field \.ground\{position:absolute;inset:-4%;background:var\(--sky-bg\) var\(--field-plate\) center\/cover no-repeat;\}/);
|
|
1496
|
+
assert.match(COSMOS, /@media \(prefers-reduced-motion:no-preference\)\{\s*\.field \.ground\{animation:groundDrift 90s ease-in-out infinite alternate;\}/, 'the drift only runs where motion is welcome');
|
|
1497
|
+
assert.match(COSMOS, /@keyframes groundDrift\{from\{transform:translate3d\([^)]*\) scale\(1\.02\);\}to\{transform:translate3d\([^)]*\) scale\(1\.06\);\}\}/, 'transform only');
|
|
1498
|
+
const plate = path.join(ROOT, 'public-landing', 'assets', 'world', 'r3-space-nebula.jpg');
|
|
1499
|
+
assert.ok(fs.existsSync(plate) && fs.existsSync(plate + '.json'), 'the plate and its sidecar ship');
|
|
1500
|
+
const side = JSON.parse(fs.readFileSync(plate + '.json', 'utf8'));
|
|
1501
|
+
assert.equal(side.kind, 'backdrop');
|
|
1502
|
+
assert.match(side.prompt, /pure black/i, 'a text-free plate on black');
|
|
1503
|
+
assert.doesNotMatch(COSMOS, /\.field \.ground::after\{content:""/, 'nothing drawn over the plate');
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1458
1506
|
test('the style is a blend on a masked colour layer; the accent never paints the box; the ring and the spotlight follow the silhouette', () => {
|
|
1459
1507
|
assert.match(COSMOS, /\.worldOrb \.disc::before\{content:"";position:absolute;inset:0;\s*background-image:linear-gradient\(var\(--tpl-ink\),var\(--tpl-ink\)\),var\(--tpl-src\);/);
|
|
1460
1508
|
assert.match(COSMOS, /background-blend-mode:var\(--tpl-blend\),normal;\s*-webkit-mask-image:var\(--tpl-src\);mask-image:var\(--tpl-src\);/, 'the mask is the plate itself');
|
|
@@ -1502,9 +1550,11 @@ test('the kit proves the field path: the fixture carries a curated template and
|
|
|
1502
1550
|
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'featured.json'), 'utf8'));
|
|
1503
1551
|
const otb = fixture.projects.find((p) => p.origin === 'https://amazonprimea.com');
|
|
1504
1552
|
assert.equal(otb.template, 'bead-wash');
|
|
1553
|
+
assert.equal(fixture.projects.find((p) => p.origin === 'https://demo.cloudbongos.com').template, 'pair-gold-wash', 'a material template rides the wire (task 1003337)');
|
|
1505
1554
|
assert.ok(fixture.projects.every((p) => 'template' in p), 'the wire never drops the key');
|
|
1506
1555
|
const states = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.states.json'), 'utf8'));
|
|
1507
1556
|
const attrs = states.states['in-map'].expect.attr.map((a) => a.join(' '));
|
|
1508
1557
|
assert.ok(attrs.includes('#selfOrb data-template pair-chrome'));
|
|
1509
1558
|
assert.ok(attrs.some((a) => a.includes('amazonprimea.com') && a.endsWith('data-template bead-wash')), 'the curated template reaches the orb');
|
|
1559
|
+
assert.ok(attrs.some((a) => a.includes('demo.cloudbongos.com') && a.endsWith('data-material gold')), 'the material reaches the orb as its own attribute');
|
|
1510
1560
|
});
|