Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions docker/postgres/init-exapps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
67 changes: 64 additions & 3 deletions hydra-gates/scripts/lib/check_manifest_crossref.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions hydra-gates/scripts/lib/test_check_manifest_crossref.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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')
Expand Down
30 changes: 29 additions & 1 deletion hydra-gates/scripts/lib/test_gate_route_auth.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ _expect_log() { # <snapshot-var-contents> <regex> <description>
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
Expand Down Expand Up @@ -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.
#
Expand Down
39 changes: 39 additions & 0 deletions hydra-gates/scripts/run-hydra-gates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
Original file line number Diff line number Diff line change
@@ -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" }
]
}
Original file line number Diff line number Diff line change
@@ -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: '/' },
],
})
Original file line number Diff line number Diff line change
@@ -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" }
]
}
Original file line number Diff line number Diff line change
@@ -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: '/' },
],
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
//
// Fixture mirroring planix: ADR-040 AppHost adoption WITHOUT the one-call
// `Bootstrap::register()` helper. The app aliases the generics itself in
// lib/AppInfo/Application.php (see the note there for why it must), so the
// dashboard/health/metrics/preferences controller files do NOT exist here.
//
// `gadget#run` is the control: it is NOT an AppHost slug and its controller is
// genuinely absent, so it must still be raised. If adoption ever loosens into
// a blanket "this app is fine" exemption, that assertion goes red here.
return [
'routes' => [
['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'],
],
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php
// SPDX-License-Identifier: EUPL-1.2

declare(strict_types=1);

namespace OCA\Fixture\AppInfo;

use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use Psr\Container\ContainerInterface;

/**
* AppHost adoption WITHOUT Bootstrap::register().
*
* WHY AN APP DOES THIS (planix): 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
* then hands its own SettingsController the engine class and dies with a
* TypeError on the first request. Registering the controllers and NOT the
* services is the only shape that works there — it is adoption, spelled the
* long way.
*/
class Application extends App implements IBootstrap
{
public const APP_ID = 'fixture';

public function __construct()
{
parent::__construct(self::APP_ID);
}

public function register(IRegistrationContext $context): void
{
$appId = self::APP_ID;

$context->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
{
}
}
Loading
Loading