Hustler-Stufe: Modul hustlerStufe.js mit HUSTLER_STUFE (Standard 3), Bauzeit-Wert VITE_HUSTLER_STUFE, Schwellen für Pult-Antworten (4) und Keller (6)

Eine Quelle der Wahrheit dafür, was die Oberfläche schon zeigen darf, solange der Dienst nur P1 und P3 kann. Der Wert aus dem Bau gilt nur als Ziffer 1 bis 9,
sonst die Standardstufe; keine Überschreibung zur Laufzeit. Dazu tabErlaubt (Keller = ?tab=botleads) und linkErlaubt (Links in den Keller, auch über /workers und /leads).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-22 10:36:44 +00:00
1 parent 75ed38e68d
commit 7cd74a23d2
2 files changed
+181

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
// Stufen-Sperre der Hustler-Seite: EINE Quelle der Wahrheit dafür, was die Oberfläche schon zeigen darf. Der Dienst (workers-dienst) wächst Stufe für Stufe
// (P1, P3, P4, P6); die Oberfläche ist fertig und würde sonst Knöpfe und Bildschirme zeigen, deren Endpunkte noch 404 liefern.
// Stufe < 4: im Pult keine Antwort-Knöpfe (paste „Antwort des Kunden einfügen“, reply „Als Mensch antworten“, approve-preview, start-build).
// Stufe < 6: kein Keller mit den Bot-Leads (kein Bildschirm, kein Link, kein Abruf von /api/workers/botleads); die Keller-Etage zoomt wie jede andere.
// Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule). Es gibt keine Überschreibung zur Laufzeit (kein localStorage, keine Adresse): die Stufe ist
// zur Bauzeit festgelegt. Nur der Prüfstand (scripts/workers-harness) baut mit VITE_HUSTLER_STUFE=6, damit seine P4- und Keller-Szenarien weiter laufen.
/** Ab dieser Stufe erscheinen die Antwort-Knöpfe im Pult (Dienst P4: paste, reply, approve-preview, start-build). */
export const STUFE_ANTWORTEN = 4
/** Ab dieser Stufe erscheint der Keller mit den Bot-Leads (Dienst P6: GET /botleads). */
export const STUFE_KELLER = 6
const KLEINSTE = 1
const GROESSTE = 9
/**
* Stufe aus einem rohen Wert: nur eine ganze Zahl 1..9 (als Zahl oder als Ziffer im Text, Leerraum ringsum erlaubt) gilt; alles andere
* (leer, Text, 0, 10, 6.5, 06, Infinity, undefined) ergibt `standard`.
*/
export function stufeAus(roh, standard) {
if (typeof roh === 'number') return Number.isInteger(roh) && roh >= KLEINSTE && roh <= GROESSTE ? roh : standard
if (typeof roh !== 'string') return standard
const text = roh.trim()
return /^[1-9]$/.test(text) ? Number(text) : standard
}
// Der Wert aus dem Bau (Vite ersetzt import.meta.env.VITE_HUSTLER_STUFE beim Bauen, der Prüfstand setzt ihn über define). Unter Node gibt es import.meta.env nicht.
function buildWert() {
try {
return import.meta.env.VITE_HUSTLER_STUFE
} catch {
return undefined
}
}
// 3 = Dienst P3 live; ab 4 (P4) erscheinen die Antwort-Knöpfe im Pult, ab 6 (P6) der Keller mit den Bot-Leads; beim jeweiligen Livegang hier hochsetzen
export const HUSTLER_STUFE = stufeAus(buildWert(), 3)
// Eine übergebene Stufe (Parameter der Funktionen und Bausteine, vor allem für Tests); fehlt sie oder ist sie unbrauchbar, gilt HUSTLER_STUFE
const stufeOder = (stufe) => stufeAus(stufe, HUSTLER_STUFE)
/** Zeigt das Pult die Antwort-Knöpfe (paste, reply, approve-preview, start-build)? Ab Stufe 4. */
export const antwortenOffen = (stufe = HUSTLER_STUFE) => stufeOder(stufe) >= STUFE_ANTWORTEN
/** Gibt es den Keller (Bildschirm Bot-Leads, Abruf von /botleads)? Ab Stufe 6. */
export const kellerOffen = (stufe = HUSTLER_STUFE) => stufeOder(stufe) >= STUFE_KELLER
/** Darf dieser Bildschirm (Wert von ?tab=) auf der Stufe gezeigt werden? Nur der Keller ('botleads') ist gesperrt, alles andere ist frei. */
export const tabErlaubt = (tabId, stufe = HUSTLER_STUFE) => tabId !== 'botleads' || kellerOffen(stufe)
// Adressen, unter denen die Hustler-Seite liegt: /hustler und die alten /workers und /leads (App.jsx leitet sie weiter, die Query bleibt)
const SEITEN_PFADE = ['/hustler', '/workers', '/leads']
/**
* Darf ein interner Link (aus der Spur im Dialog) auf der Stufe ein Link sein? Ein Link in den Keller (…/hustler?tab=botleads, auch über /workers und /leads)
* ist unterhalb der Keller-Stufe nur Text; alles andere bleibt ein Link. Externe Adressen berührt das nicht.
*/
export function linkErlaubt(href, stufe = HUSTLER_STUFE) {
if (kellerOffen(stufe)) return true
if (typeof href !== 'string' || !/^\/(?![/\\])/.test(href)) return true // nur interne Pfade (ein einzelner / am Anfang), nie //host oder eine ganze Adresse
let ziel
try {
ziel = new URL(href, 'https://hustler.invalid')
} catch {
return true
}
const pfad = ziel.pathname.replace(/\/+$/, '').toLowerCase()
return !(SEITEN_PFADE.includes(pfad) && ziel.searchParams.get('tab') === 'botleads')
}
+112
View File
@@ -0,0 +1,112 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
import { loadModule } from './load.mjs'
// Stufen-Sperre der Hustler-Seite (src/lib/hustlerStufe.js): eine Quelle der Wahrheit, was die Oberfläche schon zeigen darf.
// Stufe 3 = Dienst P3 live (Stand der Produktion): kein Keller (Bot-Leads, P6), keine Antwort-Knöpfe im Pult (P4). Ab Stufe 4 kommen die Antwort-Knöpfe, ab 6 der Keller.
// Diese Datei prüft das Modul selbst (Stufe aus dem Bau, Schwellen, Bildschirme, Links). Die Bausteine prüfen ihre Sperre in ihren eigenen Tests, jeweils mit Stufe 3 (gesperrt)
// und Stufe 6 (offen): rowModel in workersPult.test.mjs, der Client in workersApi.test.mjs, Pult in workersPultUi.test.mjs, Haus in hustlerHochhausWelt.test.mjs, Seite in
// hustlerSeite.test.mjs, Spur-Links in hustlerDialog.test.mjs, der Prüfstand in workersHarness.test.mjs.
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8')
const stufe = await loadModule('src/lib/hustlerStufe.js')
const { HUSTLER_STUFE, STUFE_ANTWORTEN, STUFE_KELLER, stufeAus, antwortenOffen, kellerOffen, tabErlaubt, linkErlaubt } = stufe
// ---------- Stufe aus dem Bau ----------
test('stufeAus: nur eine ganze Zahl 1..9 gilt (Zahl oder einzelne Ziffer im Text, Leerraum außen egal), alles andere ergibt den Standard', () => {
for (let n = 1; n <= 9; n += 1) {
assert.equal(stufeAus(n, 3), n); assert.equal(stufeAus(String(n), 3), n); assert.equal(stufeAus(` ${n}\n`, 3), n)
}
const ungueltig = [undefined, null, '', ' ', '0', 0, '10', 10, '12', '06', '6.0', '6.5', 6.5, '-1', -1, '+6', 'sechs', 'abc', '6a', 'a6', '6 7', NaN, Infinity, -Infinity, true, false, {}, [], [6], () => 6, '1', '٦']
for (const roh of ungueltig) assert.equal(stufeAus(roh, 3), 3, `Wert ${String(roh)} (${typeof roh})`)
// der Standard ist der übergebene
assert.equal(stufeAus('x', 5), 5); assert.equal(stufeAus(undefined, 7), 7)
})
test('Standard: ohne Wert aus dem Bau gilt die Stufe aus dem Quelltext, einmal und an einer Stelle; sie liegt in 1..9', () => {
const quelltext = read('src/lib/hustlerStufe.js')
const treffer = [...quelltext.matchAll(/export const HUSTLER_STUFE = stufeAus\(buildWert\(\), (\d)\)/g)]
assert.equal(treffer.length, 1, 'genau eine Zeile legt die Stufe fest')
assert.equal(HUSTLER_STUFE, Number(treffer[0][1]))
assert.ok(Number.isInteger(HUSTLER_STUFE) && HUSTLER_STUFE >= 1 && HUSTLER_STUFE <= 9)
assert.ok(quelltext.includes('beim jeweiligen Livegang hier hochsetzen'))
assert.equal(STUFE_ANTWORTEN, 4); assert.equal(STUFE_KELLER, 6)
})
// Der Wert aus dem Bau (Vite: import.meta.env.VITE_HUSTLER_STUFE, Prüfstand: define von esbuild) wird beim Bauen eingesetzt. Hier derselbe Weg mit esbuild.
async function stufeNachBau(define) {
const out = await esbuild.build({
stdin: { contents: "export { HUSTLER_STUFE, antwortenOffen, kellerOffen } from './src/lib/hustlerStufe.js'", resolveDir: ROOT, loader: 'js' },
bundle: true, format: 'esm', platform: 'neutral', write: false, define,
})
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
}
test('Bau: VITE_HUSTLER_STUFE überschreibt die Stufe zur Bauzeit (Prüfstand); ungültige Werte fallen auf den Standard zurück', async () => {
for (const [roh, erwartet] of [['6', 6], ['4', 4], ['9', 9], ['1', 1], [' 6 ', 6], ['3', 3], ['0', HUSTLER_STUFE], ['10', HUSTLER_STUFE], ['abc', HUSTLER_STUFE], ['', HUSTLER_STUFE], ['6.5', HUSTLER_STUFE], ['-2', HUSTLER_STUFE]]) {
const m = await stufeNachBau({ 'import.meta.env.VITE_HUSTLER_STUFE': JSON.stringify(roh) })
assert.equal(m.HUSTLER_STUFE, erwartet, `VITE_HUSTLER_STUFE=${JSON.stringify(roh)}`)
assert.equal(m.kellerOffen(), erwartet >= 6); assert.equal(m.antwortenOffen(), erwartet >= 4)
}
// nicht gesetzt (Vite: undefined) und ohne import.meta.env (Node): der Standard
assert.equal((await stufeNachBau({ 'import.meta.env.VITE_HUSTLER_STUFE': 'undefined' })).HUSTLER_STUFE, HUSTLER_STUFE)
assert.equal((await stufeNachBau({})).HUSTLER_STUFE, HUSTLER_STUFE)
})
test('keine Überschreibung zur Laufzeit: kein localStorage, keine Adresse, keine Umgebung außer dem Bau; VITE_HUSTLER_STUFE steht nur in hustlerStufe.js', () => {
const quelltext = read('src/lib/hustlerStufe.js').split('\n').filter((z) => !z.trimStart().startsWith('//')).join('\n')
for (const verboten of ['localStorage', 'sessionStorage', 'location', 'URLSearchParams', 'document', 'window', 'process.env', 'cookie']) assert.ok(!quelltext.includes(verboten), verboten)
// genau ein Zugriff auf die Umgebung
assert.equal(quelltext.match(/import\.meta\.env/g).length, 1)
const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
const quellen = walk(path.join(ROOT, 'src')).filter((f) => /\.(jsx?|mjs)$/.test(f) && !f.endsWith(path.join('lib', 'hustlerStufe.js')))
assert.ok(quellen.length > 50)
for (const f of quellen) assert.ok(!fs.readFileSync(f, 'utf8').includes('VITE_HUSTLER_STUFE'), path.relative(ROOT, f))
})
// ---------- Schwellen ----------
test('Schwellen: Antwort-Knöpfe ab Stufe 4, Keller ab Stufe 6; unbrauchbare Stufe gilt als HUSTLER_STUFE', () => {
for (let n = 1; n <= 9; n += 1) {
assert.equal(antwortenOffen(n), n >= 4, `antwortenOffen(${n})`)
assert.equal(kellerOffen(n), n >= 6, `kellerOffen(${n})`)
}
assert.deepEqual([antwortenOffen(3), kellerOffen(3)], [false, false])
assert.deepEqual([antwortenOffen(6), kellerOffen(6)], [true, true])
for (const kaputt of [undefined, null, 'x', 0, 12, NaN]) {
assert.equal(antwortenOffen(kaputt), antwortenOffen(HUSTLER_STUFE)); assert.equal(kellerOffen(kaputt), kellerOffen(HUSTLER_STUFE))
}
assert.equal(antwortenOffen(), antwortenOffen(HUSTLER_STUFE)); assert.equal(kellerOffen(), kellerOffen(HUSTLER_STUFE))
})
// ---------- Bildschirme und Deeplinks ----------
test('tabErlaubt: nur der Keller (botleads) ist gesperrt, und nur unterhalb von Stufe 6', () => {
for (const s of [1, 3, 4, 5]) {
assert.equal(tabErlaubt('botleads', s), false, `Stufe ${s}`)
for (const frei of ['hochhaus', 'freigabe', '', 'unbekannt', undefined]) assert.equal(tabErlaubt(frei, s), true, `${frei} auf Stufe ${s}`)
}
for (const s of [6, 7, 9]) assert.equal(tabErlaubt('botleads', s), true, `Stufe ${s}`)
})
test('linkErlaubt: Links in den Keller sind unterhalb von Stufe 6 kein Link, alles andere bleibt unberührt', () => {
const keller = [
'/hustler?tab=botleads', '/hustler?tab=botleads&bot=abc123', '/hustler/?tab=botleads', '/Hustler?tab=botleads', '/hustler?bot=x&tab=botleads',
'/workers?tab=botleads&bot=x', '/leads?tab=botleads', '/hustler?tab=botleads#oben',
]
for (const href of keller) {
assert.equal(linkErlaubt(href, 3), false, `Stufe 3: ${href}`); assert.equal(linkErlaubt(href, 5), false, `Stufe 5: ${href}`)
assert.equal(linkErlaubt(href, 6), true, `Stufe 6: ${href}`)
}
const frei = [
'/hustler?tab=freigabe', '/hustler', '/hustler?tab=hochhaus', '/hustler?bot=abc123', '/tickets/100001', '/customers/abc', '/other?tab=botleads', '/hustler/anderes?tab=botleads',
'https://beispiel.de/hustler?tab=botleads', 'http://x.example/?tab=botleads', '//evil.example/hustler?tab=botleads', '/\\evil.example/hustler?tab=botleads', '', 'kein-pfad', null, undefined, 42,
]
for (const href of frei) { assert.equal(linkErlaubt(href, 3), true, `Stufe 3: ${String(href)}`); assert.equal(linkErlaubt(href, 6), true, `Stufe 6: ${String(href)}`) }
})