hub v0.152.0: operator access tokens with scopes (09 §3 decision 251, operator 2026-10-15 11:38)
gates / gates (push) Successful in 6m44s
gates / gates (push) Successful in 6m44s
- Configuration → Access tokens: named, scoped (group read/write), expiring (≤365 d), shown once, stored SHA-256 only, every use logged by name. Bearer fhp_… on the web surface only; /api/v1 refuses it. - Route table = the code's single source (web/routes.go), pinned to ServeHTTP's switch; session-only: secrets shown/minted, deletes/resets, password, tokens. - R-952 closed: /api/v1 route table names each caller, pinned to the switch; no-credential refusal test. - Fail-open fixed: an empty operator password served the UI open (main skipped RequireAuth). Live hub never open. - 05 §17, 04 §6.2, 13, 09 §3 251; register 155→157 (+R-1054, R-1055, R-1056, −R-952); STATUS. - website: prowlarr-logo.svg (byte copy from felhom.eu-media) — the site gate refused every push. Not deployed — waits for the operator's yes. Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
@@ -176,6 +176,14 @@ the deletion is the hub's), `run_job` (`fill-watch`, `offsite-integrity`, `offsi
|
||||
Anything else is refused at the hub's POST and again on the box. No action deletes data, starts a countdown or
|
||||
shortens one (`TestOpActions_ClosedList`, `TestOperatorActions_ClosedList`). Unanswered after 24 h: expired.
|
||||
|
||||
### 6.2 Who may press the operator's buttons: session, Basic, or a scoped token [hub v0.152.0, `09` §3 decision 251]
|
||||
|
||||
The hub's operator surface has three ways in: a browser session, Basic auth with the operator password (scripts,
|
||||
`05` §16.1), and — new — an **access token** with ticked scopes (`05` §17). A token is NOT a signing key and changes
|
||||
nothing in this document's model: a destructive op still needs the operator's signature, which no hub credential can
|
||||
make. What a token narrows is the hub side: the operator actions above are `hosts:write`; the reveal of a break-glass
|
||||
credential, every delete and reset, the password and the tokens are session only.
|
||||
|
||||
## 7. Hardware readiness (Viktor's "build the foundation now")
|
||||
|
||||
Software `ssh-ed25519` now; a FIDO2 `sk-ssh-ed25519@openssh.com` key later is a **no-op on the
|
||||
|
||||
@@ -504,3 +504,171 @@ newest snapshot up at 02:30, checks it (`PRAGMA integrity_check`), encrypts it a
|
||||
namespace (`runbooks/RUNBOOK-hub-db-offsite-backup.md`). Pinned by `internal/dbsnap/dbsnap_test.go` and
|
||||
`cmd/hub/r173_wiring_test.go`.
|
||||
|
||||
|
||||
## 17. Operator access tokens [DESIGN — hub v0.152.0, operator ruling 2026-10-15 11:38 (`09` §3 decision 251)]
|
||||
|
||||
**The problem.** Scripts — Claude Code's floor step first — drove the hub with the operator's own password (HTTP Basic,
|
||||
`05` §16.1). That password does everything: reveals every box's break-glass root password, deletes customers, changes
|
||||
the floor and the password itself. A leaked copy opens the whole hub, and the log cannot tell a script from the
|
||||
operator.
|
||||
|
||||
**The answer.** A token: `fhp_` + 32 random bytes (base32, lower case, 56 characters in all), named, with ticked
|
||||
scopes and an expiry (default 90 days, at most 365, „never" not offered). Created and revoked only from a browser
|
||||
session on Configuration → Access tokens; shown **once** (in that one response, `Cache-Control: no-store`, never in a
|
||||
redirect URL or a log). The hub stores only its **unkeyed SHA-256** (`api_tokens.token_hash`): the token is 32 random
|
||||
bytes, so there is nothing a dictionary or a slow search can find, and a slow hash would buy nothing; the hash stops a
|
||||
leaked database copy from being replayed. Pinned by `TestAPIToken_StoredRowHasNoPlainToken` (the DB file and its WAL
|
||||
are scanned for the plain token, with the hash as the positive control). The row keeps name, scopes, created,
|
||||
created-by, expires, last used, last used from (first `X-Forwarded-For` hop — informational, never an auth input),
|
||||
revoked.
|
||||
|
||||
### 17.1 Use, and why it needs no CSRF token
|
||||
|
||||
`Authorization: Bearer fhp_…` on the operator web surface. `RequireAuth` checks it **before** the session and Basic
|
||||
paths and never falls through to them: a token request is decided by its scopes alone, so it is never full operator.
|
||||
Unknown, revoked or expired → **401**; a scope it lacks → **403 naming the scope** (`missing scope floor:write`); a
|
||||
session-only route → **403 „session only"**. Every use is logged with the token's NAME, the method, the path and the
|
||||
result; a refusal too.
|
||||
|
||||
No CSRF token (`§16.1` against): the R-135 gate exists because a browser re-sends cached Basic credentials and cookies
|
||||
on a cross-site form. A browser attaches no bearer header by itself, and a page on another site can set an
|
||||
`Authorization` header only through a CORS preflight, which the hub never answers. So a request that carries the token
|
||||
was written by whoever holds it. Basic auth keeps its header rule unchanged (`TestToken_CSRFRuleUnchangedForBasic`).
|
||||
|
||||
**A token never:** opens a session-only route (below); reaches `/api/v1` — the box API refuses any `fhp_` bearer on
|
||||
every route before its switch (`TestAPI_AccessTokenIsRefusedEverywhere`); logs in a browser (`/login` takes the
|
||||
password only); sees the token list (a token-authenticated read of `/configuration` renders no token card); creates or
|
||||
revokes a token (the route is session-only AND both handlers check again — `TestToken_CannotManageTokens`).
|
||||
|
||||
### 17.2 The groups
|
||||
|
||||
Write includes read. A tick is offered only when it opens at least one route (`TestScopeGroups_EveryOfferedTickOpensARoute`),
|
||||
so two groups have no read tick: the Day-0 manifest and the mail hold have no page of their own (the manifest shows on
|
||||
Configuration, which `floor:read` opens; the mail hold is a banner, and held mail is dropped, not kept — `internal/mailhold`).
|
||||
|
||||
| group | read opens | write opens |
|
||||
|---|---|---|
|
||||
| `overview` | dashboard, customer list (`/configs`), hosts list, apps, off-site page, system page | — |
|
||||
| `floor` | Configuration page, global-floor impact | global floor, per-customer floor |
|
||||
| `vouch` | — | `POST /configuration/artifacts` (golden, agent, MinAgent) |
|
||||
| `customers` | new/edit forms, DR recipe (secret-free) | create, edit, block/unblock, geo disable, create-config, claim resend |
|
||||
| `hosts` | host page, log bundles, log tails | operator actions, log-bundle and log-tail requests |
|
||||
| `offsite` | run the key check now (it reads, changes no key) | window grant, weekly windows on/off, off-site and PBS-DR re-issue, freeze |
|
||||
| `os` | the fleet queue (`/os/fleet`) | approve (guest, Docker, Proxmox, kernel), ring, on/off |
|
||||
| `mailhold` | — | release |
|
||||
|
||||
**CC's token** („cc-releases"): `overview:read` + `floor:write`. Not `vouch` — the vouch stays the operator's
|
||||
(decision 250).
|
||||
|
||||
### 17.3 The route table — the code's single source
|
||||
|
||||
`hub/internal/web/routes.go` `operatorRoutes`: one entry per case of `ServeHTTP`'s switch, same order, same match;
|
||||
the classifier walks it first-match-wins exactly as the switch does. `TestRouteTable_MatchesServeHTTPSwitch` reads the
|
||||
switch from `server.go`'s syntax tree and fails on any added, removed, reordered or changed case (R-952, web side). A
|
||||
path no entry matches, and a method an entry does not name, is session only. „—" = the route does not serve that
|
||||
method (405), so a token gets 403.
|
||||
|
||||
| path | GET | POST | why session-only |
|
||||
|---|---|---|---|
|
||||
| `/` | `overview:read` | — | |
|
||||
| `/style.css` | any token | — | |
|
||||
| `/static/chart.min.js` | any token | — | |
|
||||
| `/static/fonts/{id}` | any token | — | |
|
||||
| `/configuration` | `floor:read` | **session only** | The Configuration page shows the floors; its POST is the asset re-seed, which fits no group. |
|
||||
| `/apps` | `overview:read` | — | |
|
||||
| `/apps/` | `overview:read` | — | |
|
||||
| `/apps/{id}/reset-telemetry` | — | **session only** | Telemetry reset and issue dismissal change the fleet view; no group fits them. |
|
||||
| `/apps/{id}/dismiss-issues` | — | **session only** | |
|
||||
| `/apps/{id}` | `overview:read` | — | |
|
||||
| `/offsite` | `overview:read` | — | |
|
||||
| `/offsite/endpoints` | — | **session only** | The WireGuard endpoint record is infrastructure; no group fits a save. |
|
||||
| `/offsite/endpoints/{id}/delete` | — | **session only** | |
|
||||
| `/hosts` | `overview:read` | — | |
|
||||
| `/hosts/` | `overview:read` | — | |
|
||||
| `/system` | `overview:read` | — | |
|
||||
| `/appliances/{id}/bind` | — | **session only** | A bind delivers the household's retrieval passphrase to the box that asks — a secret delivery. |
|
||||
| `/appliances/{id}/discard` | — | **session only** | |
|
||||
| `/hosts/{id}/delete-impact` | **session only** | — | |
|
||||
| `/hosts/{id}/delete` | — | **session only** | |
|
||||
| `/hosts/{id}/reveal-recovery-credential` | — | **session only** | |
|
||||
| `/hosts/{id}/operator-action` | — | `hosts:write` | |
|
||||
| `/hosts/{id}/request-logs` | — | `hosts:write` | |
|
||||
| `/hosts/{id}/log-bundles/{x}` | `hosts:read` | — | |
|
||||
| `/hosts/{id}` | `hosts:read` | — | |
|
||||
| `/login` | exempt (public) | exempt (public) | |
|
||||
| `/bind/<token>` | exempt (public) | exempt (public) | |
|
||||
| `/customers/{id}/block` | — | `customers:write` | |
|
||||
| `/customers/{id}/selfbind-link` | — | **session only** | The self-bind link is a capability that binds a box to the household — minted here. |
|
||||
| `/customers/{id}/unblock` | — | `customers:write` | |
|
||||
| `/customers/{id}/geo/disable` | — | `customers:write` | |
|
||||
| `/customers/{id}/floor` | — | `floor:write` | |
|
||||
| `/customers/{id}/create-config` | — | `customers:write` | |
|
||||
| `/customers/{id}/dr-recipe.json` | `customers:read` | — | |
|
||||
| `/customers/{id}/request-log-tail` | — | `hosts:write` | |
|
||||
| `/customers/{id}/log-tail/{x}` | `hosts:read` | — | |
|
||||
| `/customers/{id}` | **session only** | **session only** | The customer page embeds the owner passphrase and the controller API key (customer_unified.html). |
|
||||
| `/configs` | `overview:read` | — | |
|
||||
| `/configs/new` | `customers:read` | `customers:write` | |
|
||||
| `/configuration/global-floor/impact` | `floor:read` | — | |
|
||||
| `/configuration/global-floor` | — | `floor:write` | |
|
||||
| `/configuration/artifacts` | — | `vouch:write` | |
|
||||
| `/configuration/mail-hold/release` | — | `mailhold:write` | |
|
||||
| `/configuration/password` | — | **session only** | |
|
||||
| `/configuration/tokens` | — | **session only** | |
|
||||
| `/configuration/tokens/{id}/revoke` | — | **session only** | |
|
||||
| `/configs/{id}/delete` | **session only** | **session only** | |
|
||||
| `/configs/{id}/edit` | `customers:read` | `customers:write` | |
|
||||
| `/offsite/remove-unpinned/{id}` | — | **session only** | |
|
||||
| `/offsite/abandon-cancel/{id}` | — | **session only** | Cancelling a pending set-aside deletion: no group names it; until the operator places it, session only. |
|
||||
| `/offsite/window-grant/{id}` | — | `offsite:write` | |
|
||||
| `/offsite/windows-enabled` | — | `offsite:write` | |
|
||||
| `/os/{id}` | `os:read` | `os:write` | |
|
||||
| `/offsite/key-audit` | — | `offsite:read` | Runs the daily key check now: it reads keys and reports, it changes none. |
|
||||
| `/configs/{id}/offsite-reissue` | — | `offsite:write` | |
|
||||
| `/configs/{id}/claim-resend` | — | `customers:write` | |
|
||||
| `/configs/{id}/pbsdr-reissue` | — | `offsite:write` | |
|
||||
| `/configs/{id}/offsite-freeze` | — | `offsite:write` | |
|
||||
| `/configs/{id}/preview` | **session only** | — | The preview is the generated controller config — it carries the controller API key. |
|
||||
| `/configs/{id}/regen-password` | — | **session only** | |
|
||||
| `/configs/{id}/reset` | **session only** | **session only** | |
|
||||
| `/configs/{id}` | `overview:read` | `overview:read` | |
|
||||
|
||||
**Session only, by reason:** a secret is shown or minted (reveal recovery credential; the customer page — it embeds the
|
||||
owner passphrase and the controller API key; the config preview — the generated controller config carries that key;
|
||||
regen-password; the self-bind link; an appliance bind — it delivers the passphrase to the box that asks); something is
|
||||
deleted or reset (customer delete and its preview, reset and its preview, host delete and its impact, off-site
|
||||
endpoint delete, remove-unpinned); the password; the tokens.
|
||||
|
||||
**Session only because no group fits — for the operator to place** (R-1056): `/apps/{id}/reset-telemetry`,
|
||||
`/apps/{id}/dismiss-issues`, `POST /configuration` (asset re-seed), `POST /offsite/endpoints` (the WireGuard endpoint
|
||||
record), `/offsite/abandon-cancel/{id}`.
|
||||
|
||||
### 17.4 The box API's route table (R-952, API side)
|
||||
|
||||
`hub/internal/api/routes.go` `apiRoutes` names who may call each `/api/v1` route: `public` (with its reason — only the
|
||||
appliance register), `customer` (controller key or global key), `host` (host key, self-scoped, or global), `global`,
|
||||
`passphrase` (X-Retrieval-Password), `appliance` (its own token), `recovery` (re-enroll — only while the operator armed
|
||||
recovery mode). `TestAPIRouteTable_MatchesSwitch` pins it to `handler.go`'s switch; `TestAPIRoutes_NoCredential` sends
|
||||
every non-public route a request with no credential, for a household and host that EXIST (so a 404 cannot stand in for
|
||||
a refusal), and requires 401/403. The table documents and pins; each handler still makes its own check. Tokens do not
|
||||
replace the global key yet (R-1054).
|
||||
|
||||
### 17.5 What a stolen token can do
|
||||
|
||||
- **CC's token:** set the global or a per-customer floor — within the floor rules the page enforces (a floor above the
|
||||
vouched golden needs a declared MinAgent, R-472) — and read the overview pages. It cannot see any secret, delete or
|
||||
reset anything, vouch a golden or agent, release the mail hold, or touch a box's API. Revoke on the card; it stops at
|
||||
once (no cache).
|
||||
- **A `customers:write` token** can change a household's e-mail and then resend its claim code — the code goes to the
|
||||
new address. That is a takeover path for the household's dashboard password; it is in the group because the operator
|
||||
table put „resend claim" there. Decision open (R-1055).
|
||||
- **No token** can reach the break-glass password, the escrow, the off-site keys, or any destructive verb.
|
||||
|
||||
### 17.6 No operator password configured = closed
|
||||
|
||||
Until v0.152.0 an empty password **opened** the operator UI: `RequireAuth` served everything when the effective hash was
|
||||
empty, and `main` did not mount `RequireAuth` at all when the ConfigMap seed was empty — even if a password was set in
|
||||
the DB override. Now `main` always mounts `webServer.Handler()` (`TestWebMountAlwaysAuthenticated`) and an empty password
|
||||
answers **503** on every operator route; `/bind/` stays public, `/login` renders but cannot succeed
|
||||
(`TestRequireAuth_NoPasswordRefusesEveryOperatorRoute`). **The live hub was never open:** 2026-10-15, hub 0.150.0, an
|
||||
unauthenticated `GET /`, `/configuration`, `/hosts` each answered 302 → `/login`, and the ConfigMap carries a hash.
|
||||
|
||||
@@ -925,14 +925,20 @@ its length, and both fixes cost something the household would notice — operato
|
||||
2. **CC raises the floor itself**, with the release's MinAgent declared, when both guards hold: (a) the release ran on
|
||||
the three test boxes (Tester 1, demo-hp, demo-felhom) through the normal update path; (b) the box walk's checks are
|
||||
green on all three afterwards (`scripts/https_only_check.py` included). Not at night unless the night brief says so.
|
||||
*Route:* the only way to save the global floor today is the operator page with the operator's own password; CC
|
||||
does not use it for this until the operator grants a scoped route (R-1051).
|
||||
*Route:* the floor save on the operator page, with CC's scoped token (decision 251, `05` §17); until the token
|
||||
works live, the operator password for the floor only (R-1051).
|
||||
3. **Agent releases (and their bundles) still stop for the operator's vouch;** a floor whose MinAgent is above the
|
||||
vouched agent is never set.
|
||||
4. **The golden is baked weekly** (Sunday night, `runbooks/nightly-rotation.md`); the operator vouches it. A new box
|
||||
installs the vouched golden and updates itself to the floor (not yet proven end to end — R-1052).
|
||||
5. The push gate judges the golden by its AGE (older than 7 days while the floor is above it), not by the newest
|
||||
CHANGELOG heading (`scripts/golden_currency_gate.py`).
|
||||
251. **Scripts use scoped hub tokens, not the operator password; CC's floor step uses its own.** *Operator ruling
|
||||
2026-10-15 11:38.* Tokens with groups (read/write per group), managed on the Configuration tab, shown once, stored
|
||||
hashed, every use logged by name (`05` §17, hub v0.152.0). Never by any token: a secret shown or minted, a delete or
|
||||
reset, the password, the tokens. CC gets one token, `overview:read` + `floor:write` — not the vouch (decision 250
|
||||
item 3). **Until that token works live, CC may use the operator password ONLY to set the floor.** After it works,
|
||||
the operator changes the password so CC no longer holds it. Amends decision 250 item 2's *Route* sentence (R-1051).
|
||||
|
||||
### 2026-10-15 (07:48–08:20) — bake first; Plex through its own key; HSTS one day (recorded before the media build)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ scripts are held with the review's working notes.
|
||||
| component | routes | no credential | listeners |
|
||||
|---|---|---|---|
|
||||
| controller | 231 (+8 in setup mode) | 21, of which 7 change state, each behind its own password, code or token | port 8080 on the guest's Docker networks (not published); mail relay 2525/2465/2526 |
|
||||
| hub | 119 (1 health, 53 API, 65 web) | appliance register (rate-limited, by design), re-enroll (only while the operator has armed recovery mode), login, health | one ingress, path `/`, geo filter (HU, IE, LAN) |
|
||||
| hub | 121 (1 health, 53 API, 67 web — v0.152.0 added the two token routes; both route tables are now pinned to the source by tests, R-952) | appliance register (rate-limited, by design), re-enroll (only while the operator has armed recovery mode), login, health | one ingress, path `/`, geo filter (HU, IE, LAN) |
|
||||
| agent | 38 local-API routes (`POST /host/poweroff` added 2026-10-13, R-998) | 0 | local API on the island bridge; WireGuard; poke (tunnel only); felhom-sshd (tunnel only, measured) |
|
||||
|
||||
**Outbound fetches that bring code or config onto a box:** the catalog (git, `main`), app images, the four infra
|
||||
@@ -65,6 +65,7 @@ OS packages (exact versions, apt-signed), the installer script (by tag, R-980).
|
||||
| **added (R-1007):** controller → an app's own job queue | holds — nothing new | the `after_unclean_boot` commands run inside the app's container through the docker socket the controller already holds, and read the app's database with a read-only session: no key into the household's account, nothing a compromised controller could not already do (exec in any app, read its database). The household's lost-file names stay on the box (`app.yaml`, the app page); the operator event carries counts only, and a failing command's output is never logged (security review of controller `8d862e4`, fixed in `3b85a0c`) |
|
||||
| **added:** agent ↔ root on the host | holds by the sudoers | every root verb is one regex-anchored line or a root wrapper that re-checks its input (`03` §3); agent v0.156.1 adds one delete: `felhom-vzdump-partial-clean` removes only a DEAD half-written vzdump of the named guest on a `dir` storage (no vzdump on the host, nothing holding it open) — a compromised agent gains the deletion of a leftover that is not a backup |
|
||||
| agent ↔ hub | holds for destructive ops | signed jobs verified in the documented order; unsigned spec has no size bounds (R-984) |
|
||||
| **added 2026-10-15:** operator script ↔ hub UI | holds from hub v0.152.0 | scripts used the operator password, which can do everything; now a scoped, expiring, hashed token (`05` §17); a token never reaches a secret, a delete or `/api/v1`. Open: a `customers:write` token can redirect a claim code (R-1055); the global API key is still one all-powerful bearer (R-1054) |
|
||||
| controller ↔ hub | **did not hold until 2026-10-12** | the global key was public (R-950, rotated) and three routes needed no key (R-951, fixed); per-box keys are bound to their box (R-955) |
|
||||
| box ↔ PBS | holds as written | ciphertext only; the zero-knowledge wording is stronger than a live box allows (R-985) |
|
||||
| guest ↔ Proxmox host | holds | the guest has no Proxmox credentials (measured) |
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
=== RUN TestRouteTable_MatchesServeHTTPSwitch
|
||||
tokens_test.go:193: route 21: switch has {Exact: Prefix:/hosts/ Suffix:/operator-action Contains: BindFn:false Get:{Group: Write:false SessionOnly:false AnyToken:false Exempt:false} Post:{Group: Write:false SessionOnly:false AnyToken:false Exempt:false}}, table has {Exact: Prefix:/hosts/ Suffix:/request-logs Contains: BindFn:false Get:{Group: Write:false SessionOnly:false AnyToken:false Exempt
|
||||
--- FAIL: TestRouteTable_MatchesServeHTTPSwitch (0.00s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.027s
|
||||
FAIL
|
||||
@@ -0,0 +1,7 @@
|
||||
=== RUN TestAPIRoutes_NoCredential
|
||||
routes_test.go:156: GET /customers (global) with no credential: 200 "[]", want 401/403
|
||||
routes_test.go:156: GET /customers/c1 (global) with no credential: 404 "404 page not found", want 401/403
|
||||
--- FAIL: TestAPIRoutes_NoCredential (0.04s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/api 0.061s
|
||||
FAIL
|
||||
@@ -0,0 +1,57 @@
|
||||
=== RUN TestAPI_AccessTokenIsRefusedEverywhere
|
||||
routes_test.go:174: POST /report with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: GET /wait with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: POST /appliance/register with an access token: 400 "uuid and at least one MAC are required"
|
||||
routes_test.go:174: GET /appliance/poll with an access token: 404 "not found"
|
||||
routes_test.go:174: POST /host-report with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: POST /host-enroll with an access token: 400 "Invalid payload: customer_id required"
|
||||
routes_test.go:174: POST /admin/hosts with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: PUT /hosts/h1/escrow with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: GET /hosts/h1/escrow with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: GET /hosts/h1/escrow/retained with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: PUT /hosts/h1/recovery-credential with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: GET /admin/hosts/h1/recovery-credential with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: PUT /admin/hosts/h1/recovery-mode with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: DELETE /admin/hosts/h1/recovery-mode with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: POST /hosts/h1/re-enroll with an access token: 403 "Forbidden: host not in recovery mode (operator must arm it)"
|
||||
routes_test.go:174: GET /hosts/h1/restore-directive with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: POST /hosts/h1/wg with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: POST /hosts/h1/pbs/consume-token with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: POST /hosts/h1/os-report with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: GET /hosts/h1/desired-state with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: GET /hosts/h1/jobs with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: DELETE /hosts/h1/jobs/j1 with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: PUT /admin/hosts/h1/desired-state with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: POST /admin/hosts/h1/jobs with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: PUT /admin/wg/endpoint with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: GET /admin/wg/endpoint with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: POST /admin/wg/peers with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: DELETE /admin/wg/peers with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: GET /admin/wg/peers with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: PUT /admin/wg/operator-peer with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: GET /admin/wg/operator-peer with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: POST /claim/reset-request with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: POST /event with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: POST /mail with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: POST /notify with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: POST /preferences with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: GET /customers with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: GET /customers/c1 with an access token: 403 "Forbidden: global key required"
|
||||
routes_test.go:174: GET /recovery/c1 with an access token: 401 "Unauthorized: X-Retrieval-Password header required"
|
||||
routes_test.go:174: GET /config/c1 with an access token: 401 "Unauthorized: X-Retrieval-Password header required"
|
||||
routes_test.go:174: POST /offsite/consume-password/c1 with an access token: 401 "unauthorized"
|
||||
routes_test.go:174: POST /offsite/register-key/c1 with an access token: 401 "unauthorized"
|
||||
routes_test.go:174: POST /offsite/confirm-key/c1 with an access token: 401 "unauthorized"
|
||||
routes_test.go:174: POST /offsite/move-aside/c1 with an access token: 401 "unauthorized"
|
||||
routes_test.go:174: POST /offsite/window-open/c1 with an access token: 401 "unauthorized"
|
||||
routes_test.go:174: POST /offsite/window-close/c1 with an access token: 401 "unauthorized"
|
||||
routes_test.go:174: POST /offsite/abandon-request/c1 with an access token: 401 "unauthorized"
|
||||
routes_test.go:174: POST /offsite/abandon-cancel/c1 with an access token: 401 "unauthorized"
|
||||
routes_test.go:174: POST /offsite/abandon-status/c1 with an access token: 401 "unauthorized"
|
||||
routes_test.go:174: GET /artifacts/c1 with an access token: 401 "Unauthorized: X-Retrieval-Password header required"
|
||||
routes_test.go:174: GET /assets/manifest with an access token: 401 "Unauthorized"
|
||||
routes_test.go:174: GET /assets/file/c1 with an access token: 401 "Unauthorized"
|
||||
--- FAIL: TestAPI_AccessTokenIsRefusedEverywhere (0.04s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/api 0.064s
|
||||
FAIL
|
||||
@@ -0,0 +1,6 @@
|
||||
=== RUN TestWebMountAlwaysAuthenticated
|
||||
token_wiring_test.go:43: mux.Handle("/", …): 0 via webServer.Handler(), 1 other — want exactly 1 and 0
|
||||
--- FAIL: TestWebMountAlwaysAuthenticated (0.00s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/cmd/hub 0.025s
|
||||
FAIL
|
||||
@@ -0,0 +1,79 @@
|
||||
=== RUN TestToken_SessionOnlyRoutesRefuseEveryToken
|
||||
tokens_test.go:292: POST / with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Felhom Hub — Customer Overview</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n <meta http-equiv=\"refresh\" content=\"60\">\n</head>\n<body>\n <s
|
||||
tokens_test.go:292: POST /style.css with an all-scope token: 200 "/* Felhom Hub — Dark theme */\n/* Felhom Hub — design system v2 (TASK-D4). Canonical tokens:\n documentation/design/design-system.md */\n\n@font-face {\n font-family: 'Plus Jakarta Sans';\n font-style: normal;\n font-weight: 400 800;\n font-display: swap;\n src: url('/static/fonts/pjs-latin.woff2') format('w
|
||||
tokens_test.go:292: POST /static/chart.min.js with an all-scope token: 200 "/**\n * Skipped minification because the original files appears to be already minified.\n * Original file: /npm/chart.js@4.4.7/dist/chart.umd.js\n *\n * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files\n */\n/*!\n * Chart.js v4.4.7\n * https://www.
|
||||
tokens_test.go:292: POST /static/fonts/x1 with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: POST /configuration with an all-scope token: 303 "", want 403 session only
|
||||
tokens_test.go:292: POST /apps with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Apps — Felhom Hub</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n</head>\n<body>\n <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"display:non
|
||||
tokens_test.go:292: POST /apps/ with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Apps — Felhom Hub</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n</head>\n<body>\n <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"display:no
|
||||
tokens_test.go:292: GET /apps/x1/reset-telemetry with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /apps/x1/reset-telemetry with an all-scope token: 303 "", want 403 session only
|
||||
tokens_test.go:292: GET /apps/x1/dismiss-issues with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /apps/x1/dismiss-issues with an all-scope token: 400 "Invalid action\n", want 403 session only
|
||||
tokens_test.go:292: POST /apps/x1 with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>x1 — Felhom Hub</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n <script src=\"/static/chart.min.js\"></script>\n \n<script>\n\n\nfunction
|
||||
tokens_test.go:292: POST /offsite with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Offsite — Felhom Hub</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n</head>\n<body>\n <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"displ
|
||||
tokens_test.go:292: GET /offsite/endpoints with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /offsite/endpoints with an all-scope token: 400 "Invalid endpoint id (use lowercase letters, digits, hyphens) — nothing stored.\n", want 403 session only
|
||||
tokens_test.go:292: GET /offsite/endpoints/x1/delete with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /offsite/endpoints/x1/delete with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: POST /hosts with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Hosts — Felhom Hub</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n</head>\n<body>\n <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"display:n
|
||||
tokens_test.go:292: POST /hosts/ with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Hosts — Felhom Hub</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n</head>\n<body>\n <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"display:
|
||||
tokens_test.go:292: POST /system with an all-scope token: 503 "os updates not configured\n", want 403 session only
|
||||
tokens_test.go:292: GET /appliances/x1/bind with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /appliances/x1/bind with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /appliances/x1/discard with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /appliances/x1/discard with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /hosts/x1/delete-impact with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: POST /hosts/x1/delete-impact with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /hosts/x1/delete with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /hosts/x1/delete with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: GET /hosts/x1/reveal-recovery-credential with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /hosts/x1/reveal-recovery-credential with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: GET /hosts/x1/operator-action with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /hosts/x1/request-logs with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /hosts/x1/log-bundles/y1 with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: POST /hosts/x1 with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: GET /customers/x1/block with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /customers/x1/selfbind-link with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /customers/x1/selfbind-link with an all-scope token: 502 "Self-bind mailer is not configured on this hub\n", want 403 session only
|
||||
tokens_test.go:292: GET /customers/x1/unblock with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /customers/x1/geo/disable with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /customers/x1/floor with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /customers/x1/create-config with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /customers/x1/dr-recipe.json with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /customers/x1/request-log-tail with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /customers/x1/log-tail/y1 with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: GET /customers/x1 with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: POST /customers/x1 with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: POST /configs with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Felhom Hub — Customers</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n</head>\n<body>\n <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"dis
|
||||
tokens_test.go:292: POST /configuration/global-floor/impact with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /configuration/global-floor with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /configuration/artifacts with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /configuration/mail-hold/release with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /configuration/password with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /configuration/password with an all-scope token: 303 "", want 403 session only
|
||||
tokens_test.go:292: GET /configuration/tokens with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /configuration/tokens/x1/revoke with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /configs/x1/delete with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: POST /configs/x1/delete with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: GET /offsite/remove-unpinned/x1 with an all-scope token: 503 "unavailable\n", want 403 session only
|
||||
tokens_test.go:292: POST /offsite/remove-unpinned/x1 with an all-scope token: 503 "unavailable\n", want 403 session only
|
||||
tokens_test.go:292: GET /offsite/abandon-cancel/x1 with an all-scope token: 503 "unavailable\n", want 403 session only
|
||||
tokens_test.go:292: POST /offsite/abandon-cancel/x1 with an all-scope token: 503 "unavailable\n", want 403 session only
|
||||
tokens_test.go:292: GET /offsite/window-grant/x1 with an all-scope token: 503 "unavailable\n", want 403 session only
|
||||
tokens_test.go:292: GET /offsite/windows-enabled with an all-scope token: 503 "unavailable\n", want 403 session only
|
||||
tokens_test.go:292: GET /offsite/key-audit with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /configs/x1/offsite-reissue with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /configs/x1/claim-resend with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /configs/x1/pbsdr-reissue with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /configs/x1/offsite-freeze with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: GET /configs/x1/preview with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: POST /configs/x1/preview with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: GET /configs/x1/regen-password with an all-scope token: 405 "Method not allowed\n", want 403 session only
|
||||
tokens_test.go:292: POST /configs/x1/regen-password with an all-scope token: 303 "", want 403 session only
|
||||
tokens_test.go:292: GET /configs/x1/reset with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
tokens_test.go:292: POST /configs/x1/reset with an all-scope token: 404 "404 page not found\n", want 403 session only
|
||||
--- FAIL: TestToken_SessionOnlyRoutesRefuseEveryToken (0.09s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.107s
|
||||
FAIL
|
||||
@@ -0,0 +1,6 @@
|
||||
=== RUN TestToken_NoTokenUnknownExpiredRevoked401
|
||||
tokens_test.go:326: expired token: 200, want 401
|
||||
--- FAIL: TestToken_NoTokenUnknownExpiredRevoked401 (0.05s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.073s
|
||||
FAIL
|
||||
@@ -0,0 +1,7 @@
|
||||
=== RUN TestToken_ScopeDecides
|
||||
tokens_test.go:353: floor:read token on floor save: 303, want 403
|
||||
tokens_test.go:356: a refused token changed the floor
|
||||
--- FAIL: TestToken_ScopeDecides (0.05s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.069s
|
||||
FAIL
|
||||
@@ -0,0 +1,6 @@
|
||||
=== RUN TestAPIToken_StoredRowHasNoPlainToken
|
||||
tokens_test.go:414: t.db holds the plain token
|
||||
--- FAIL: TestAPIToken_StoredRowHasNoPlainToken (0.04s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.063s
|
||||
FAIL
|
||||
@@ -0,0 +1,112 @@
|
||||
=== RUN TestRequireAuth_NoPasswordRefusesEveryOperatorRoute
|
||||
tokens_test.go:546: no password: GET / → 200, want 503
|
||||
tokens_test.go:546: no password: POST / → 200, want 503
|
||||
tokens_test.go:546: no password: GET /style.css → 200, want 503
|
||||
tokens_test.go:546: no password: POST /style.css → 200, want 503
|
||||
tokens_test.go:546: no password: GET /static/chart.min.js → 200, want 503
|
||||
tokens_test.go:546: no password: POST /static/chart.min.js → 200, want 503
|
||||
tokens_test.go:546: no password: GET /static/fonts/x1 → 404, want 503
|
||||
tokens_test.go:546: no password: POST /static/fonts/x1 → 404, want 503
|
||||
tokens_test.go:546: no password: GET /configuration → 200, want 503
|
||||
tokens_test.go:546: no password: POST /configuration → 303, want 503
|
||||
tokens_test.go:546: no password: GET /apps → 200, want 503
|
||||
tokens_test.go:546: no password: POST /apps → 200, want 503
|
||||
tokens_test.go:546: no password: GET /apps/ → 200, want 503
|
||||
tokens_test.go:546: no password: POST /apps/ → 200, want 503
|
||||
tokens_test.go:546: no password: GET /apps/x1/reset-telemetry → 405, want 503
|
||||
tokens_test.go:546: no password: POST /apps/x1/reset-telemetry → 303, want 503
|
||||
tokens_test.go:546: no password: GET /apps/x1/dismiss-issues → 405, want 503
|
||||
tokens_test.go:546: no password: POST /apps/x1/dismiss-issues → 400, want 503
|
||||
tokens_test.go:546: no password: GET /apps/x1 → 200, want 503
|
||||
tokens_test.go:546: no password: POST /apps/x1 → 200, want 503
|
||||
tokens_test.go:546: no password: GET /offsite → 200, want 503
|
||||
tokens_test.go:546: no password: POST /offsite → 200, want 503
|
||||
tokens_test.go:546: no password: GET /offsite/endpoints → 405, want 503
|
||||
tokens_test.go:546: no password: POST /offsite/endpoints → 400, want 503
|
||||
tokens_test.go:546: no password: GET /offsite/endpoints/x1/delete → 405, want 503
|
||||
tokens_test.go:546: no password: POST /offsite/endpoints/x1/delete → 404, want 503
|
||||
tokens_test.go:546: no password: GET /hosts → 200, want 503
|
||||
tokens_test.go:546: no password: POST /hosts → 200, want 503
|
||||
tokens_test.go:546: no password: GET /hosts/ → 200, want 503
|
||||
tokens_test.go:546: no password: POST /hosts/ → 200, want 503
|
||||
tokens_test.go:546: no password: GET /appliances/x1/bind → 405, want 503
|
||||
tokens_test.go:546: no password: POST /appliances/x1/bind → 405, want 503
|
||||
tokens_test.go:546: no password: GET /appliances/x1/discard → 405, want 503
|
||||
tokens_test.go:546: no password: POST /appliances/x1/discard → 405, want 503
|
||||
tokens_test.go:546: no password: GET /hosts/x1/delete-impact → 404, want 503
|
||||
tokens_test.go:546: no password: POST /hosts/x1/delete-impact → 405, want 503
|
||||
tokens_test.go:546: no password: GET /hosts/x1/delete → 405, want 503
|
||||
tokens_test.go:546: no password: POST /hosts/x1/delete → 404, want 503
|
||||
tokens_test.go:546: no password: GET /hosts/x1/reveal-recovery-credential → 405, want 503
|
||||
tokens_test.go:546: no password: POST /hosts/x1/reveal-recovery-credential → 404, want 503
|
||||
tokens_test.go:546: no password: GET /hosts/x1/operator-action → 405, want 503
|
||||
tokens_test.go:546: no password: POST /hosts/x1/operator-action → 403, want 503
|
||||
tokens_test.go:546: no password: GET /hosts/x1/request-logs → 405, want 503
|
||||
tokens_test.go:546: no password: POST /hosts/x1/request-logs → 403, want 503
|
||||
tokens_test.go:546: no password: GET /hosts/x1/log-bundles/y1 → 404, want 503
|
||||
tokens_test.go:546: no password: POST /hosts/x1/log-bundles/y1 → 404, want 503
|
||||
tokens_test.go:546: no password: GET /hosts/x1 → 404, want 503
|
||||
tokens_test.go:546: no password: POST /hosts/x1 → 404, want 503
|
||||
tokens_test.go:546: no password: GET /customers/x1/block → 405, want 503
|
||||
tokens_test.go:546: no password: POST /customers/x1/block → 404, want 503
|
||||
tokens_test.go:546: no password: GET /customers/x1/selfbind-link → 405, want 503
|
||||
tokens_test.go:546: no password: POST /customers/x1/selfbind-link → 502, want 503
|
||||
tokens_test.go:546: no password: GET /customers/x1/unblock → 405, want 503
|
||||
tokens_test.go:546: no password: POST /customers/x1/unblock → 404, want 503
|
||||
tokens_test.go:546: no password: GET /customers/x1/geo/disable → 405, want 503
|
||||
tokens_test.go:546: no password: POST /customers/x1/geo/disable → 404, want 503
|
||||
tokens_test.go:546: no password: GET /customers/x1/floor → 405, want 503
|
||||
tokens_test.go:546: no password: POST /customers/x1/floor → 404, want 503
|
||||
tokens_test.go:546: no password: GET /customers/x1/create-config → 405, want 503
|
||||
tokens_test.go:546: no password: POST /customers/x1/create-config → 303, want 503
|
||||
tokens_test.go:546: no password: GET /customers/x1/dr-recipe.json → 404, want 503
|
||||
tokens_test.go:546: no password: POST /customers/x1/dr-recipe.json → 405, want 503
|
||||
tokens_test.go:546: no password: GET /customers/x1/request-log-tail → 405, want 503
|
||||
tokens_test.go:546: no password: POST /customers/x1/request-log-tail → 400, want 503
|
||||
tokens_test.go:546: no password: GET /customers/x1/log-tail/y1 → 404, want 503
|
||||
tokens_test.go:546: no password: POST /customers/x1/log-tail/y1 → 404, want 503
|
||||
tokens_test.go:546: no password: GET /customers/x1 → 200, want 503
|
||||
tokens_test.go:546: no password: POST /customers/x1 → 200, want 503
|
||||
tokens_test.go:546: no password: GET /configs → 200, want 503
|
||||
tokens_test.go:546: no password: POST /configs → 200, want 503
|
||||
tokens_test.go:546: no password: GET /configs/new → 200, want 503
|
||||
tokens_test.go:546: no password: POST /configs/new → 200, want 503
|
||||
tokens_test.go:546: no password: GET /configuration/global-floor/impact → 200, want 503
|
||||
tokens_test.go:546: no password: POST /configuration/global-floor/impact → 405, want 503
|
||||
tokens_test.go:546: no password: GET /configuration/global-floor → 405, want 503
|
||||
tokens_test.go:546: no password: POST /configuration/global-floor → 303, want 503
|
||||
tokens_test.go:546: no password: GET /configuration/artifacts → 405, want 503
|
||||
tokens_test.go:546: no password: POST /configuration/artifacts → 303, want 503
|
||||
tokens_test.go:546: no password: GET /configuration/mail-hold/release → 405, want 503
|
||||
tokens_test.go:546: no password: POST /configuration/mail-hold/release → 303, want 503
|
||||
tokens_test.go:546: no password: GET /configuration/password → 405, want 503
|
||||
tokens_test.go:546: no password: POST /configuration/password → 303, want 503
|
||||
tokens_test.go:546: no password: GET /configuration/tokens → 405, want 503
|
||||
tokens_test.go:546: no password: POST /configuration/tokens → 303, want 503
|
||||
tokens_test.go:546: no password: GET /configuration/tokens/x1/revoke → 405, want 503
|
||||
tokens_test.go:546: no password: POST /configuration/tokens/x1/revoke → 404, want 503
|
||||
tokens_test.go:546: no password: GET /configs/x1/delete → 200, want 503
|
||||
tokens_test.go:546: no password: POST /configs/x1/delete → 400, want 503
|
||||
tokens_test.go:546: no password: GET /configs/x1/edit → 302, want 503
|
||||
tokens_test.go:546: no password: POST /configs/x1/edit → 200, want 503
|
||||
tokens_test.go:546: no password: GET /offsite/key-audit → 405, want 503
|
||||
tokens_test.go:546: no password: GET /configs/x1/offsite-reissue → 405, want 503
|
||||
tokens_test.go:546: no password: POST /configs/x1/offsite-reissue → 502, want 503
|
||||
tokens_test.go:546: no password: GET /configs/x1/claim-resend → 405, want 503
|
||||
tokens_test.go:546: no password: POST /configs/x1/claim-resend → 502, want 503
|
||||
tokens_test.go:546: no password: GET /configs/x1/pbsdr-reissue → 405, want 503
|
||||
tokens_test.go:546: no password: POST /configs/x1/pbsdr-reissue → 502, want 503
|
||||
tokens_test.go:546: no password: GET /configs/x1/offsite-freeze → 405, want 503
|
||||
tokens_test.go:546: no password: POST /configs/x1/offsite-freeze → 502, want 503
|
||||
tokens_test.go:546: no password: GET /configs/x1/preview → 200, want 503
|
||||
tokens_test.go:546: no password: POST /configs/x1/preview → 200, want 503
|
||||
tokens_test.go:546: no password: GET /configs/x1/regen-password → 405, want 503
|
||||
tokens_test.go:546: no password: POST /configs/x1/regen-password → 303, want 503
|
||||
tokens_test.go:546: no password: GET /configs/x1/reset → 200, want 503
|
||||
tokens_test.go:546: no password: POST /configs/x1/reset → 400, want 503
|
||||
tokens_test.go:546: no password: GET /configs/x1 → 303, want 503
|
||||
tokens_test.go:546: no password: POST /configs/x1 → 303, want 503
|
||||
--- FAIL: TestRequireAuth_NoPasswordRefusesEveryOperatorRoute (0.11s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.128s
|
||||
FAIL
|
||||
@@ -0,0 +1,6 @@
|
||||
=== RUN TestToken_CSRFRuleUnchangedForBasic
|
||||
tokens_test.go:437: Basic without X-Felhom-Operator: 303, want 403
|
||||
--- FAIL: TestToken_CSRFRuleUnchangedForBasic (0.06s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.080s
|
||||
FAIL
|
||||
@@ -0,0 +1,7 @@
|
||||
=== RUN TestToken_CannotManageTokens
|
||||
tokens_test.go:462: handleTokenCreate under a token: 200
|
||||
tokens_test.go:466: a token was created by a token: 2 rows
|
||||
--- FAIL: TestToken_CannotManageTokens (0.05s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.071s
|
||||
FAIL
|
||||
@@ -0,0 +1,6 @@
|
||||
=== RUN TestAPIRouteTable_MatchesSwitch
|
||||
routes_test.go:119: route 36: switch has {Method:GET Exact:/customers Prefix: Suffix: Contains: Auth: Why:}, table has {Method:GET Exact: Prefix:/customers/ Suffix: Contains: Auth: Why:} — classify it in routes.go in the switch's order
|
||||
--- FAIL: TestAPIRouteTable_MatchesSwitch (0.01s)
|
||||
FAIL
|
||||
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/api 0.030s
|
||||
FAIL
|
||||
@@ -26,6 +26,12 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-15 (evening) — hub access tokens (hub v0.152.0, built, not deployed)
|
||||
|
||||
| Row | What | Closed | Evidence |
|
||||
|---|---|---|---|
|
||||
| **R-952** | **Nothing failed when a new hub route was added without deciding who may call it.** (P3) | CLOSED 2026-10-15 — **built in hub v0.152.0**, both surfaces: `/api/v1` — `internal/api/routes.go` names each route's caller (public with a reason, customer, host, global, passphrase, appliance, recovery), pinned to the switch from source; every non-public route with no credential, for a household and host that exist, answers 401/403. Web — `internal/web/routes.go` classifies every route for access tokens, pinned the same way. Full text: `git show e92501f5:documentation/backlog/OPEN-ITEMS.md` | `TestAPIRouteTable_MatchesSwitch`, `TestAPIRoutes_NoCredential`, `TestRouteTable_MatchesServeHTTPSwitch` (each red-proved, `REPORT-hub-tokens.md`); `05` §17.3–17.4 |
|
||||
|
||||
## 2026-10-15 (afternoon) — the floor reached 0.313.0
|
||||
|
||||
| Row | What | Closed | Evidence |
|
||||
|
||||
@@ -197,7 +197,7 @@ stopping line that lies.
|
||||
| **R-332** | Storage & devices | P3 | **The new Hiba-from-counters path has never fired on real hardware.** v0.215.0's whole point is a verdict the product could not previously reach, and it is proven only against the committed fixture's values in unit tests (12 scenario groups, 11 of 12 red-proofs failing as required). The live validation on demo-hp proved the **negative** — three healthy disks still read Rendben across the deploy, no false alert — and the **severity wire** end to end, but no live disk has actually reached Hiba. **This is the honest gap and it must not be closed by pointing at the fixture tests**: the drive that produced the fixture is in DooPlex, which is Tier 2 and never a drill target, and the demo boxes are all-flash and healthy | **WATCHING — NEW 2026-08-14, NARROWED same day.** One item originally in this gap is now PROVEN LIVE: the **persisted state surviving a controller restart**. The v0.215.0→v0.216.0 redeploy destroyed and rebuilt the container, and the new one read back a `changed_at` written by the PREVIOUS version (`2026-08-14T07:23:14.640216851Z`, still intact at 09:31:35Z) instead of re-baselining — Scenario L on real hardware, not just the production-path unit test. **What remains unproven is the verdict itself, plus the stronger restart half: an already-ALERTED disk not re-alerting** | a real degrading disk, or an injection harness | **Closing condition:** a live disk reaching Hiba from counters, OR a deliberate injection through the REAL pipeline (agent `/disks` → controller check → hub event), not a hand-set verdict | CC |
|
||||
| **R-331** | Storage & devices | P4 | **Disk health Phase 3 — growth-rate detection, and retiring the static 64.** The v0.215.0 count backstop (64 unreadable sectors → Hiba) is **a judgement from ONE drive**: the observed benign excursion peaked at 16 and cleared inside an hour, and the terminal run passed 64 at 13 Aug 11:28 and never came back. It is deliberately a backstop BEHIND the sustain rule, not the primary signal, but it is still a magic number tuned on a single sample and it will be wrong for some drive. With Phase 2's history the box can ask the question that actually matters — *is this count climbing, and how fast* — which distinguishes a drive with eight stable aging sectors from one adding forty a day, something no static threshold can do. Revisit 64 when that exists | **READY (M) — NEW 2026-08-14** | R-330 | Growth-rate rule over persisted samples; re-derive or delete the static 64 | CC |
|
||||
|
||||
## Security & access — 20 rows (P1 2, P2 3, P3 9, P4 6)
|
||||
## Security & access — 22 rows (P1 2, P2 3, P3 11, P4 6)
|
||||
|
||||
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
@@ -221,6 +221,8 @@ stopping line that lies.
|
||||
| **R-904** | Security & access | P4 | **Cloudflare can read every household's app traffic; replacing it with our own relay is a later item.** Facts (reviewer discussion 2026-10-08; `01`): app traffic and the dashboard reach the box through the Cloudflare Tunnel (`01` §5 trust table, rows end-user ↔ apps and customer ↔ controller UI; §7), and the tunnel's public end is Cloudflare's edge, where TLS ends — so Cloudflare can technically read that traffic (the FAQ says so since 2026-10-08, R-900; „TLS ends at the edge" is not written in `01` — add it there). What Cloudflare gives today, free: inbound reach with no router setup, the CGNAT answer (`01` §4, §7); certificates (`01` §7, the free tier covers one level below a zone); the geo-WAF the hub enforces (`01` §5 last row, §7); flood protection (not in `01`). The alternative named: our own EU relay over WireGuard with TLS passthrough by SNI, certificates on the box, the geo-block on the relay. Its costs: one more machine the operator keeps up, and a single point of reach for every box; weaker flood protection; about a week of work after a spike. Operator ruling 2026-10-08 09:07 (`09` §3 decision 184): a later item. | **DEFERRED** — **2026-10-10: the asked sentence is in `01` §7** („TLS ends at Cloudflare's edge …”); the spike stays deferred. **DEFERRED — after the first customers (operator ruling 2026-10-08 09:07)** | — | A spike after the first customers (the relay's reach, cost and flood behaviour, measured) | operator |
|
||||
| **R-1040** | Security & access | P4 | **Until the household makes Sonarr's or Radarr's first admin, any container on the box can read the app's API key.** Measured on the bench 2026-10-14 with the R-1030 seed in place: `AuthenticationRequired=Enabled`, `AuthenticationMethod=None` — `/initialize.json` from another container answers 200 with `apiKey`. Outsiders are blocked by the box's setup gate in that window; a compromised other app is not. | **OPEN** — filed 2026-10-14 | — | Seed the API key at install (`SONARR__AUTH__APIKEY`, measured to work) so the box knows it, and close the window by telling the household in the first steps; or seed a Forms user | CC |
|
||||
| **R-1050** | Security & access | P4 | **Raise HSTS to one year on or after 2026-11-15, if no https trouble was reported.** `09` §3 decision 249 (D2 = A, operator 2026-10-15): HSTS stays `max-age=86400` now; after a month with no https trouble it becomes one year (`31536000`); never preload, never `includeSubDomains`. The value is `HSTSMaxAgeSeconds` in `felhom-controller/controller/internal/infra/infra.go`, pinned by `TestRenderForwardedHeaders_HTTPSOnly`. „Trouble” = any report of a name that will not open, or a box answering http after the floor. | **WATCHING** — filed 2026-10-15 | the date | On or after 2026-11-15: read the register and the operator's mail for https trouble since 2026-10-15; none → a controller release with one year (test updated), bake → vouch → floor | CC |
|
||||
| **R-1054** | Security & access | P3 | **The hub's operator/global API key is still one bearer that can do everything on `/api/v1`, including reading every box's break-glass root password (`GET /admin/hosts/{id}/recovery-credential`).** Hub v0.152.0 gave the web surface scoped tokens (`05` §17) but tokens are refused on `/api/v1` by design. Who uses the global key today is not listed in one place. Fix: list its consumers; move each operator use to a scoped token on the web side (or a token class on the API), and keep the global key only where a box path needs it — or retire it. | OPEN — filed 2026-10-15 by the token build | — | List the global key's consumers (pod env, scripts, runbooks) | CC |
|
||||
| **R-1055** | Security & access | P3 | **A stolen `customers:write` token can take over a household's dashboard: edit the household's e-mail, then „resend claim" — the claim code goes to the new address.** The operator table (`09` §3 decision 251) put „resend claim" in Customers write; the self-bind link, which mints a capability, was made session only. Nobody holds a `customers:write` token today. Fix (operator decision): keep it, or make `/configs/{id}/claim-resend` session only (one line in `hub/internal/web/routes.go`). | WAITING-ON-OPERATOR — filed 2026-10-15 by the token build | operator decision | Decide: claim resend stays in Customers write, or session only | operator |
|
||||
|
||||
## Box system & updates — 13 rows (P2 1, P3 9, P4 3)
|
||||
|
||||
@@ -257,12 +259,11 @@ stopping line that lies.
|
||||
| **R-1049** | Monitoring & notifications | P3 | **A pulled drive mails the operator „unexpectedly disconnected” (error), and nothing ever mails that it came back.** MEASURED 2026-10-14 night (`audits/media-drive-events-2026-10-14/K8-hub-mails.txt`, the hub DB with its WAL from the running pod, and the mailbox as the second channel): two pulls → two `storage_disconnected` mails; the hub RECEIVED both `storage_reconnected` and both `health_recovered` (severity info) and logged no notification for any of them — they are not in `recoveredPairedDownTypes` (`hub/internal/notify/dispatcher.go`), so the severity gate drops them. The R-97a shape: told it broke, never told it healed — the operator cannot tell a returned drive from one still gone without opening the hub. **BUILT 2026-10-15 — hub v0.151.0 (`12552aca`): both pairs in `recoveredPairedDownTypes`, cooldowns ended by the next loss (`TestR1049_DriveBackMailsBothEdges`, red-proved). Not deployed: the operator's „yes” first (decision 162); the image build waits for it too.** | **WAITING-ON-OPERATOR** — hub v0.151.0 code pushed, not built, not deployed | the operator's „yes” to the hub deploy | On the „yes”: build + deploy hub v0.151.0; read a „came back” mail on the next pull | CC |
|
||||
|
||||
|
||||
## Hub & operator — 13 rows (P3 8, P4 5)
|
||||
## Hub & operator — 13 rows (P3 7, P4 6)
|
||||
|
||||
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **R-975** | Hub & operator | P3 | **The hub database keeps every customer's tunnel token, Cloudflare API token and git token unsealed in one column, so anyone with a copy of the database (or its nightly snapshots) gets them.** R-879 sealed the other secret columns but not this one. Fix: seal this column the same way. Security review 2026-10-12 (`audits/security-review-2026-10-12/`); detail held off-git for the operator (`/mnt/5_hdd/felhom.eu/evidence/security-review-2026-10-12-PRIVATE/`). | OPEN | — | Seal with a migration and a test | CC |
|
||||
| **R-952** | Hub & operator | P3 | **Nothing fails when a new hub `/api/v1` route is added without deciding who may call it — that is how three routes stayed open with no credential until v0.148.0 (R-951).** Fix: a test that reads the API route table from source (not by text search) and fails on any route that is not classified (public with a reason, operator key, host key, customer key); every non-public route gets a request with no credential and must be refused. | OPEN — filed 2026-10-12 by the security review; not built in v0.148.0 (the session could not write it) | — | Build the test in its own task, with a red-proof | CC |
|
||||
| **R-953** | Hub & operator | P3 | **A full ArgoCD sync of the `felhom` app with prune would DELETE three live Secrets and so break the hub and Umami.** Measured 2026-10-12: `Secret/gitea-creds` (read by `Deployment/hub`), `Secret/umami-config` (read by `Deployment/umami` and `Deployment/umami-db`) and `Secret/healthchecks-config` (read by nothing) are live but no longer in git since R-925 de-gitted `felhom.secret.yaml`; ArgoCD marks all three `requiresPruning`. `Deployment/umami` is also OutOfSync (not pruning). Today only the build-deploy skill's „sync only the hub" line prevents it. Fix: mark the three Secrets `argocd.argoproj.io/compare-options: IgnoreExtraneous` (or move them out of the app's scope), decide whether `healthchecks-config` is still needed, and bring `Deployment/umami` back in sync. | OPEN — filed 2026-10-12 | — | Annotate or move the three Secrets; then a whole-app diff shows nothing to prune | CC |
|
||||
| **R-31** | Hub & operator | P3 | **[P2-HIGH] Offsite provisioning is synchronous with no status affordance.** Save runs the Hetzner sync in-request, so the request can hit the nginx 504 **while succeeding server-side**: the operator cannot tell failed from slow, and a retry races the first attempt. **MIGRATED FROM `ROADMAP.md` 2026-08-22 (R-369) — originally filed 2026-07-21, size M, roadmap state `idea`.** Moved verbatim; nothing added or reinterpreted. The roadmap keeps its copy as history, marked moved. | **OPEN — migrated from ROADMAP 2026-08-22, rank unchanged** **Re-ranked 2026-10-03: P2→P3: operator-only; a known workaround (click once, wait, verify) exists.** **2026-10-06 night: the race half fixed on felhom.eu main (hub, unreleased):** a second Save while the first still provisions is refused with 409 („already running — wait about a minute, then reload; do not save again"); nothing saved, nothing created; per customer, in memory. `TestProvision_R31_*`, red-proof `audits/night-burndown-2026-10-06/hub/R-31-red.txt`. LEFT: the async save with a status card (the escrow-card idiom). | — | Direction: make it async + a status card, reusing the proven **awaiting-card/poll idiom** (v0.138.0 escrow card). **Interim mitigation belongs in R-3 as an operator note: click once, wait, verify — do not re-click.** | CC |
|
||||
| **R-882** | Hub & operator | P3 | **Longhorn on DooPlex could not grow a volume online: its `instance-manager` (116 days up) called a host process that no longer existed** — `nsenter: cannot open /host/proc/196610/ns/mnt` on every expansion retry, and an offline growth was blocked by the expansion's own attachment ticket (found 2026-10-05 growing `hub-data` to 2 Gi). A restart of the instance-manager (operator-approved) fixed it: 77/77 volumes back `attached/healthy` in 110 s. **Why the cached PID went stale was not established** (likely a containerd/k3s or iscsid restart after the instance-manager started), so it will recur after the next such restart and stay invisible until a volume needs to grow. `audits/hub-db-offsite-2026-10-05/partA/step1-*.txt` | **OPEN** | — | Find which host process the PID was and whether Longhorn 1.10.x re-resolves it; until then, before growing any volume, check the instance-manager's age against the last k3s/containerd restart | operator |
|
||||
@@ -273,7 +274,8 @@ stopping line that lies.
|
||||
| **R-844** | Hub & operator | P4 | **The household's OS-update line exists only on the hub's customer timeline.** 2026-10-04: the box itself has no event surface for agent results (the controller UI shows no timeline), so `os_update_applied` is a hub customer event (info: recorded, never mailed). Its stored text is the hub's English sentence; the hu/en bundle text (`mail.event.os_update_applied`) is used only if it is ever mailed. Fix direction: a controller-side line (the controller already polls the agent's local API) when the box gets a household timeline. `audits/os-guest-lane-2026-10-04/partG/hub-customer-timeline-demo-hp.txt` | **READY — owner: CC** **2026-10-05 (burn-down night): NEEDS A DESIGN** — a household timeline on the box does not exist yet. | — | — | CC |
|
||||
| **R-1002** | Hub & operator | P4 | **A box the household switched off reads ONLINE on the hub's hosts page for its first 45 minutes** (seen live 2026-10-13, R-998 proof, `audits/poweroff-2026-10-13/B-hub-watch.log`: the „Power: Kikapcsolva…" line at 18:53, the KIKAPCSOLVA badge only at 19:38, when the host report turned stale). The customer page read „warn" in part of that gap (seen, not captured). No alarm and no mail fired; the operator page is only slow to say it. | **READY** — filed 2026-10-13 | — | CC: let `hostDisplayStatus` read `deliberate_off_at` before staleness, with a render test per state; next hub release | CC |
|
||||
| **R-1011** | Hub & operator | P3 | **The Day-0 „Agent" list offers step-bundle packages (`0.156.1-step1`, `0.152.0-step1` …) as agent versions; one has no agent binary, so the hub shows the bundle JSON's sha (`FileSHA256` falls back to the first file) and a vouch would point fresh installs at a package with no binary.** Seen 2026-10-14 (`/configuration`: two „0.156.1" entries, `ac83ea41…` is the step bundle). | **READY** — filed 2026-10-14 | — | CC: list only versions whose package holds the `felhom-agent` file (refuse a missing preferred file instead of falling back), with a test; next hub release | CC |
|
||||
| **R-1051** | Hub & operator | P3 | **CC cannot raise the global floor without the operator's own password.** `09` §3 decision 250 lets CC raise the floor after the three test boxes, but the only route is the operator page (`POST /configuration/global-floor`, Basic auth with the operator password, `hub/internal/web/configs.go` `handleSetGlobalFloor`); the hub's `/api/v1/` Bearer routes carry no floor. Read 2026-10-15 (no save made). The smallest grant: a hub route `POST /api/v1/operator/floor` (floor + declared MinAgent only, the same checks as the page) behind its own Bearer key stored out-of-band on DooPlex — or the operator's word that CC may use the operator password for this one save. | **WAITING-ON-OPERATOR** — filed 2026-10-15 | the operator's pick | On a scoped route: build it (a hub release, the operator's „yes"), prove a no-op save, write it into the `felhom-build-deploy` skill's release steps | operator |
|
||||
| **R-1051** | Hub & operator | P3 | **CC cannot raise the global floor without the operator's own password.** `09` §3 decision 250 lets CC raise the floor after the three test boxes, but the only route was the operator page with the operator password. Operator ruling 2026-10-15 11:38 (`09` §3 decision 251): scoped tokens; until CC's token works, the password for the floor only. | **BLOCKED** — built in hub v0.152.0 (`05` §17: tokens with groups, CC = `overview:read` + `floor:write`), not deployed | the operator's „yes" to deploy v0.152.0; then the operator creates „cc-releases" and saves it to `~/.felhom-hub/cc-token` | Deploy; a no-op floor save with the token, read back; a vouch attempt → 403; a session-only route → 403; then close | operator |
|
||||
| **R-1056** | Hub & operator | P4 | **Five operator routes fit no token group, so they are session only until the operator places them:** apps reset-telemetry and dismiss-issues, the Configuration asset re-seed, the off-site WireGuard endpoint save, the off-site abandon-cancel (`05` §17.3). Nothing needs them by token today. | WAITING-ON-OPERATOR — filed 2026-10-15 by the token build | operator decision | Place each in a group, or leave session only | operator |
|
||||
|
||||
|
||||
## Business & legal — 14 rows (P2 4, P3 2, P4 8)
|
||||
|
||||
Reference in New Issue
Block a user