diff --git a/docker/postgres/init-exapps.sh b/docker/postgres/init-exapps.sh index 6ada8e64..10f736c8 100644 --- a/docker/postgres/init-exapps.sh +++ b/docker/postgres/init-exapps.sh @@ -16,9 +16,16 @@ EOSQL echo "Database $db ready" } -# Create databases for each ExApp -for db in keycloak; do - create_db_if_not_exists $db +# Create databases for each ExApp. +# +# A list, not a loop over one word: adding the next ExApp's database is meant +# to be an edit to EXAPP_DATABASES and nothing else. ShellCheck flagged the +# original `for db in keycloak` (SC2043 — "this loop will only ever run once"), +# which is exactly right about the shape and exactly wrong about the intent. +EXAPP_DATABASES=(keycloak) + +for db in "${EXAPP_DATABASES[@]}"; do + create_db_if_not_exists "$db" done echo "ExApp databases initialized successfully!" diff --git a/hydra-gates/scripts/lib/check_manifest_crossref.js b/hydra-gates/scripts/lib/check_manifest_crossref.js index be488ed7..1a88189d 100644 --- a/hydra-gates/scripts/lib/check_manifest_crossref.js +++ b/hydra-gates/scripts/lib/check_manifest_crossref.js @@ -648,6 +648,54 @@ function isPathPrefix(p, t) { return t === p || t.startsWith(p + '/') } +// Routes declared by a hand-written vue-router table (src/router/*.js|ts). +// +// WHY THIS EXISTS (ConductionNL/.github — planix was the first app to trip it): +// deepLink correspondence used to read `pages[].route` and nothing else. That +// is the whole route inventory for a manifest-driven app, but it is EMPTY for +// an app that renders a hand-written SPA — planix says so in its own manifest +// ("renders no manifest-driven UI, so there is deliberately no menu/pages"), +// and its five perfectly valid deepLinks each resolved to "no routable page". +// The check was therefore unsatisfiable for that class of app: the only way to +// pass was to declare pages the runtime would then try to render, i.e. to break +// the app to please the gate. +// +// The routes are still DECLARED, just in `src/router/`. This reads that table +// so the inventory is complete. Parsing is deliberately shallow — a `path:` +// string literal in a router file — because anything cleverer would be a JS +// evaluator; a route built dynamically is simply not discovered, which lands +// on the WARN path below rather than a false FAIL. +function discoverRouterRoutes(appDir) { + const out = new Set() + const dirs = [path.join(appDir, 'src', 'router'), path.join(appDir, 'src')] + for (const dir of dirs) { + let entries = [] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch { continue } + for (const e of entries) { + if (!e.isFile() || !/^router.*\.(js|ts)$|^index\.(js|ts)$/.test(e.name)) continue + let src = '' + try { + src = fs.readFileSync(path.join(dir, e.name), 'utf8') + } catch { continue } + // Only treat it as a router table if it actually looks like one. + if (!/createRouter|VueRouter|routes\s*:/.test(src)) continue + const re = /\bpath\s*:\s*['"`]([^'"`]+)['"`]/g + let m + while ((m = re.exec(src)) !== null) { + const p = m[1] + if (typeof p !== 'string' || p === '') continue + // vue-router 4 catch-all — matches everything, proves nothing. + if (p.includes('pathMatch') || p === '*') continue + out.add(p.startsWith('/') ? p : '/' + p) + } + } + if (out.size > 0) break + } + return out +} + // --- main ---------------------------------------------------------------------- function main() { @@ -768,13 +816,26 @@ function main() { } // (d) deepLink route correspondence. - const routePrefixes = [...pageRoutes].map(staticPrefix) + // + // The route inventory is pages[].route for a manifest-driven app and the + // vue-router table for a hand-written SPA. Both are declarations of the same + // thing, so both count; see discoverRouterRoutes() for why reading only the + // first made this check unsatisfiable for the second kind of app. + const routerRoutes = pageRoutes.size > 0 ? new Set() : discoverRouterRoutes(APP_DIR) + const routeSource = pageRoutes.size > 0 ? 'pages[].route' : 'src/router' + const routePrefixes = [...pageRoutes, ...routerRoutes].map(staticPrefix) deepLinks.forEach((d, i) => { if (!d || typeof d !== 'object' || typeof d.urlTemplate !== 'string') return const t = staticPrefix(normalizeDeepLinkTemplate(d.urlTemplate)) - if (!routePrefixes.some((p) => isPathPrefix(p, t))) { - fail('deeplink-route', `/deepLinks/${i}`, `urlTemplate '${d.urlTemplate}' corresponds to no routable page (no pages[].route prefix match)`) + if (routePrefixes.some((p) => isPathPrefix(p, t))) return + if (routePrefixes.length === 0) { + // No inventory of any kind was discoverable (no pages[], no parseable + // router). Absence of evidence is not evidence of a broken link — + // WARN so it is visible without asserting something unproven. + warn('deeplink-route', `/deepLinks/${i}`, `urlTemplate '${d.urlTemplate}' cannot be checked — this app declares no pages[] and no parseable src/router route table`) + return } + fail('deeplink-route', `/deepLinks/${i}`, `urlTemplate '${d.urlTemplate}' corresponds to no routable page (no ${routeSource} prefix match)`) }) // (e) ADR-044 no-functionality-loss removals invariant. Needs the diff --git a/hydra-gates/scripts/lib/test_check_manifest_crossref.js b/hydra-gates/scripts/lib/test_check_manifest_crossref.js index c0cfee41..07fc7f88 100644 --- a/hydra-gates/scripts/lib/test_check_manifest_crossref.js +++ b/hydra-gates/scripts/lib/test_check_manifest_crossref.js @@ -73,6 +73,10 @@ const VALIDATOR = path.join(LIB, 'check_manifest.js') 'templated/src/manifest.json', 'templated/src/manifest.d/00-templates.json', 'templated/src/manifest.d/10-entities.json', + 'router-routes/src/manifest.json', + 'router-routes/src/router/index.js', + 'router-routes-broken/src/manifest.json', + 'router-routes-broken/src/router/index.js', ] const missing = required.filter((rel) => !fs.existsSync(path.join(FIX, rel))) if (missing.length > 0) { @@ -808,6 +812,45 @@ function parseReport(stdout) { fs.rmSync(path.dirname(tmp), { recursive: true, force: true }) } +// --- deepLink correspondence for hand-written vue-router apps ------------------- +// +// An app that renders a hand-written SPA declares its routes in src/router/, +// not in pages[]. Reading only pages[] made this check UNSATISFIABLE for that +// class of app (planix: five valid deepLinks, five FAILs, and the only way to +// pass was to declare pages the runtime would then try to render). Both +// fixtures below carry the SAME router table, so the pass/fail difference is +// the deepLink target alone — the assertion cannot be satisfied by the check +// silently doing nothing. +{ + const okDir = path.join(FIX, 'router-routes') + const okRun = run([CHECKER, '--app-dir', okDir, '--manifest', path.join(okDir, 'src', 'manifest.json')]) + const okRep = parseReport(okRun.stdout) + assert(okRun.status === 0 && !okRep.findings.some((f) => f.check === 'deeplink-route'), + 'router-routes: deepLinks resolving against src/router are accepted (no pages[] required)') + + const badDir = path.join(FIX, 'router-routes-broken') + const badRun = run([CHECKER, '--app-dir', badDir, '--manifest', path.join(badDir, 'src', 'manifest.json')]) + const badRep = parseReport(badRun.stdout) + const badErrs = badRep.findings.filter((f) => f.check === 'deeplink-route' && f.severity === 'error') + assert(badRun.status === 1 && badErrs.length === 1 && badErrs[0].path === '/deepLinks/1', + 'router-routes-broken: a deepLink the router does NOT declare still FAILS (the check can fail)') + assert(/src\/router/.test(badErrs[0] ? badErrs[0].message : ''), + 'router-routes-broken: the failure names src/router as the inventory it checked against') + + // No inventory of any kind → WARN, never a FAIL: absence of evidence is not + // evidence of a broken link. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gate30-noroutes-')) + fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }) + fs.writeFileSync(path.join(tmpDir, 'src', 'manifest.json'), + JSON.stringify({ version: '2.0', deepLinks: [{ urlTemplate: '/apps/x/whatever/{id}', displayName: 'W' }] })) + const noRun = run([CHECKER, '--app-dir', tmpDir, '--manifest', path.join(tmpDir, 'src', 'manifest.json')]) + const noRep = parseReport(noRun.stdout) + assert(noRun.status === 0 + && noRep.findings.some((f) => f.check === 'deeplink-route' && f.severity === 'warn'), + 'no-inventory: neither pages[] nor a parseable router → WARN, not FAIL') + fs.rmSync(tmpDir, { recursive: true, force: true }) +} + console.log('') if (fails === 0) { console.log('ALL gate-30 effective-manifest-crossref assertions PASSED') diff --git a/hydra-gates/scripts/lib/test_gate_route_auth.sh b/hydra-gates/scripts/lib/test_gate_route_auth.sh index be938498..47298d28 100644 --- a/hydra-gates/scripts/lib/test_gate_route_auth.sh +++ b/hydra-gates/scripts/lib/test_gate_route_auth.sh @@ -117,7 +117,7 @@ _expect_log() { # echo "== gate-5 route-auth / gate-14 reachability control pairs ==" echo -for _f in unguarded guarded apphost apphost-unguarded orphan-route di-registered-generic prose-exempt auth-declared; do +for _f in unguarded guarded apphost apphost-unguarded apphost-hand-rolled orphan-route di-registered-generic prose-exempt auth-declared; do if [ ! -d "${FIXTURES}/${_f}" ]; then _bad "fixture ${FIXTURES}/${_f} does not exist — this suite would be green on nothing" fi @@ -260,6 +260,34 @@ if _run "${FIXTURES}/di-registered-generic"; then "di-registered-generic fixture: gate-5 states the generic was NOT JUDGED rather than dropping it" fi +# --------------------------------------------------------------------------- +# APPHOST ADOPTED THE LONG WAY (planix). +# +# `Bootstrap::register()` is the convenience, not the definition of adoption. +# planix wires the SAME generics itself because the one-call helper also +# aliases the leaf's Service\SettingsService to the engine's — fatal for an app +# that ships its own. The detector knew only the one-call spelling, so all of +# planix's AppHost routes came back `controller-class-not-found` while +# resolving perfectly at runtime. +# +# Both directions are asserted from ONE fixture, so the exemption cannot widen +# into "this app registers services, therefore absences are fine": the four +# AppHost slugs are served, `gadget#run` is not an AppHost slug and its +# controller is genuinely absent, and it must STILL be raised. +# --------------------------------------------------------------------------- +if _run "${FIXTURES}/apphost-hand-rolled"; then + _expect_gate 14 FAIL "apphost-hand-rolled fixture: the non-AppHost absent controller is still raised" + _expect_log "${_RRLOG}" "GadgetController.php route='gadget#run' rule=controller-class-not-found" \ + "apphost-hand-rolled fixture: gate-14 names gadget#run" + for _slug in DashboardController HealthController MetricsController PreferencesController; do + if printf '%s' "${_RRLOG}" | grep -q "${_slug}"; then + _bad "apphost-hand-rolled fixture: gate-14 reported hand-rolled AppHost generic ${_slug} as unreachable" + else + _ok "apphost-hand-rolled fixture: ${_slug} is recognised as AppHost-served" + fi + done +fi + # --------------------------------------------------------------------------- # 5c. DEFECT (c) — #196. A COMMENT SATISFIED THE GATE. # diff --git a/hydra-gates/scripts/run-hydra-gates.sh b/hydra-gates/scripts/run-hydra-gates.sh index d7a0f6c9..d6ff1801 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -937,6 +937,45 @@ if [ -d lib ]; then | xargs -0 -r grep -lE 'Bootstrap::register[[:space:]]*\(' 2>/dev/null \ || true) fi +# --------------------------------------------------------------------------- +# DIRECT GENERIC REGISTRATION IS ALSO ADOPTION (planix) +# --------------------------------------------------------------------------- +# `Bootstrap::register()` is the one-call convenience, not the definition of +# adoption. An app may wire the SAME generics itself, and planix does — because +# the one-call helper ALSO runs registerServices(), which aliases the leaf's +# `Service\SettingsService` to the engine's AppHostSettingsService. An app that +# ships its own SettingsService (planix does, with per-user due-reminder logic) +# gets a container that hands its own SettingsController the wrong class and a +# TypeError on the first request. Its hand-rolled closures register the +# controllers and NOT the services, which is the only shape that works there. +# +# Judged the same way as the call above: the file must reference the AppHost +# Controller namespace AND hand it to registerService(), in the SAME file, in +# non-comment code. The generic controller FQCNs are a closed set (the same +# source of truth as _HYDRA_APPHOST_SLUGS below), so this cannot become a +# blanket exemption for any missing controller — an app must name the generic +# it is aliasing. +# +# Verified against the failure it fixes: planix reported all six of its +# AppHost routes as `controller-class-not-found` while the classes were +# resolving correctly at runtime, because the detector only knew one spelling +# of a two-spelling invariant. +if [ "${_HYDRA_APPHOST}" -eq 0 ] && [ -d lib ]; then + while IFS= read -r _ah_f; do + [ -f "${_ah_f}" ] || continue + _ah_code=$(_php_code_only "${_ah_f}") + printf '%s\n' "${_ah_code}" \ + | grep -qE 'AppHost\\+Controller\\+Generic(Dashboard|Preferences|Settings|Health|Metrics)Controller' \ + || continue + printf '%s\n' "${_ah_code}" | grep -qE 'registerService[[:space:]]*\(' || continue + _HYDRA_APPHOST=1 + _HYDRA_APPHOST_SITE="${_ah_f}" + break + done < <(_enum_tracked '\.php$' lib \ + | tr '\n' '\0' \ + | xargs -0 -r grep -lE 'AppHost\\+Controller\\+Generic' 2>/dev/null \ + || true) +fi # The five controller class names Bootstrap::register() aliases, as route # slugs. Source of truth: openregister lib/AppHost/Bootstrap.php # ::registerControllers(). Deliberately an explicit list, not a wildcard — diff --git a/hydra-gates/scripts/test-fixtures/effective-manifest/router-routes-broken/src/manifest.json b/hydra-gates/scripts/test-fixtures/effective-manifest/router-routes-broken/src/manifest.json new file mode 100644 index 00000000..ef3c6280 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/effective-manifest/router-routes-broken/src/manifest.json @@ -0,0 +1,8 @@ +{ + "version": "2.0", + "observability": { "health": [], "metrics": [] }, + "deepLinks": [ + { "urlTemplate": "/apps/fixture/projects/{uuid}", "displayName": "Project" }, + { "urlTemplate": "/apps/fixture/invoices/{uuid}", "displayName": "Invoice" } + ] +} diff --git a/hydra-gates/scripts/test-fixtures/effective-manifest/router-routes-broken/src/router/index.js b/hydra-gates/scripts/test-fixtures/effective-manifest/router-routes-broken/src/router/index.js new file mode 100644 index 00000000..c3fe6526 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/effective-manifest/router-routes-broken/src/router/index.js @@ -0,0 +1,13 @@ +// Hand-written vue-router table — this fixture's app renders no manifest pages. +import { createRouter, createWebHistory } from 'vue-router' +import Dashboard from '../views/Dashboard.vue' + +export default createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'Dashboard', component: Dashboard }, + { path: '/projects', name: 'ProjectList', component: Dashboard }, + { path: '/projects/:id', name: 'ProjectBoard', component: Dashboard }, + { path: '/:pathMatch(.*)*', redirect: '/' }, + ], +}) diff --git a/hydra-gates/scripts/test-fixtures/effective-manifest/router-routes/src/manifest.json b/hydra-gates/scripts/test-fixtures/effective-manifest/router-routes/src/manifest.json new file mode 100644 index 00000000..aa6abbea --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/effective-manifest/router-routes/src/manifest.json @@ -0,0 +1,9 @@ +{ + "version": "2.0", + "observability": { "health": [], "metrics": [] }, + "deepLinks": [ + { "urlTemplate": "/apps/fixture/projects/{project}?task={uuid}", "displayName": "Task" }, + { "urlTemplate": "/apps/fixture/projects/{uuid}", "displayName": "Project" }, + { "urlTemplate": "/apps/fixture/projects", "displayName": "Projects" } + ] +} diff --git a/hydra-gates/scripts/test-fixtures/effective-manifest/router-routes/src/router/index.js b/hydra-gates/scripts/test-fixtures/effective-manifest/router-routes/src/router/index.js new file mode 100644 index 00000000..c3fe6526 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/effective-manifest/router-routes/src/router/index.js @@ -0,0 +1,13 @@ +// Hand-written vue-router table — this fixture's app renders no manifest pages. +import { createRouter, createWebHistory } from 'vue-router' +import Dashboard from '../views/Dashboard.vue' + +export default createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'Dashboard', component: Dashboard }, + { path: '/projects', name: 'ProjectList', component: Dashboard }, + { path: '/projects/:id', name: 'ProjectBoard', component: Dashboard }, + { path: '/:pathMatch(.*)*', redirect: '/' }, + ], +}) diff --git a/hydra-gates/scripts/test-fixtures/route-auth/apphost-hand-rolled/appinfo/routes.php b/hydra-gates/scripts/test-fixtures/route-auth/apphost-hand-rolled/appinfo/routes.php new file mode 100644 index 00000000..6fe2ea0e --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/route-auth/apphost-hand-rolled/appinfo/routes.php @@ -0,0 +1,23 @@ + [ + ['name' => 'widget#show', 'url' => '/api/widgets/{id}', 'verb' => 'GET'], + + ['name' => 'dashboard#page', 'url' => '/', 'verb' => 'GET'], + ['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'], + ['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'], + ['name' => 'preferences#getPreference', 'url' => '/api/preferences/{key}', 'verb' => 'GET'], + + ['name' => 'gadget#run', 'url' => '/api/gadgets/run', 'verb' => 'POST'], + ], +]; diff --git a/hydra-gates/scripts/test-fixtures/route-auth/apphost-hand-rolled/lib/AppInfo/Application.php b/hydra-gates/scripts/test-fixtures/route-auth/apphost-hand-rolled/lib/AppInfo/Application.php new file mode 100644 index 00000000..d4d3c9e9 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/route-auth/apphost-hand-rolled/lib/AppInfo/Application.php @@ -0,0 +1,73 @@ +registerService( + 'OCA\\Fixture\\Controller\\DashboardController', + static function (ContainerInterface $c) use ($appId) { + $class = 'OCA\\OpenRegister\\AppHost\\Controller\\GenericDashboardController'; + return new $class(appName: $appId, request: $c->get('OCP\\IRequest')); + } + ); + + $context->registerService( + 'OCA\\Fixture\\Controller\\HealthController', + static function (ContainerInterface $c) use ($appId) { + $class = 'OCA\\OpenRegister\\AppHost\\Controller\\GenericHealthController'; + return new $class(appName: $appId, request: $c->get('OCP\\IRequest')); + } + ); + + $context->registerService( + 'OCA\\Fixture\\Controller\\MetricsController', + static function (ContainerInterface $c) use ($appId) { + $class = 'OCA\\OpenRegister\\AppHost\\Controller\\GenericMetricsController'; + return new $class(appName: $appId, request: $c->get('OCP\\IRequest')); + } + ); + + $context->registerService( + 'OCA\\Fixture\\Controller\\PreferencesController', + static function (ContainerInterface $c) use ($appId) { + $class = 'OCA\\OpenRegister\\AppHost\\Controller\\GenericPreferencesController'; + return new $class(appName: $appId, request: $c->get('OCP\\IRequest')); + } + ); + } + + public function boot(\OCP\AppFramework\Bootstrap\IBootContext $context): void + { + } +} diff --git a/hydra-gates/scripts/test-fixtures/route-auth/apphost-hand-rolled/lib/Controller/WidgetController.php b/hydra-gates/scripts/test-fixtures/route-auth/apphost-hand-rolled/lib/Controller/WidgetController.php new file mode 100644 index 00000000..64bbd6ce --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/route-auth/apphost-hand-rolled/lib/Controller/WidgetController.php @@ -0,0 +1,20 @@ + $id]); + } +}