Skip to content

docs(adr): ADR-0003 — node-anchored wikitext editing for content actions#1

Merged
schiste merged 2 commits into
mainfrom
adr-0003-node-anchored-wikitext-editing
Jun 5, 2026
Merged

docs(adr): ADR-0003 — node-anchored wikitext editing for content actions#1
schiste merged 2 commits into
mainfrom
adr-0003-node-anchored-wikitext-editing

Conversation

@tieguy

@tieguy tieguy commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

SP42's two content-edit actions — InlineEdit and TagCitationNeeded — currently apply edits via first-occurrence literal String::replacen(…, 1) (sp42-server/src/action_routes.rs:378, :411-423). That's fine for patrol/revert, but it can target the wrong occurrence, is brittle to whitespace/TOCTOU, can't express structural edits, and on a whole-page save a mis-located swap silently corrupts an article.

ADR-0003 proposes replacing that with node-anchored editing behind a new WikitextEditor trait (consistent with ADR-0001 §7 trait-DI), Parsoid-backed via the parsoid crate, plus an immediate, parser-independent hardening: swap blind replacen(…, 1) for an exactly-one-occurrence guard, closing the wrong-target / silent-corruption risk now.

Motivation: an incoming collaboration (with @schiste — discussed beforehand) bringing LLM-assisted, human-confirmed citation verification & repair onto SP42, which needs structural, anti-drift edits replacen cannot express.

This is a Proposed ADR for discussion per CONSTITUTION §4 — docs only, no code yet. It records the decision and the one protected contract change (a node locator on SessionActionExecutionRequest).

Why Parsoid-first, in SP42's terms

  • HTTP edge behind a trait (ADR-0001 §7); no new language (§1); SP42 already depends on EventStreams / LiftWing / the Action API.
  • Outsources round-trip fidelity to MediaWiki's own parser rather than reinventing it.
  • Vehicle: the parsoid crate (mwbot-rs, GPL-3.0) — typed cite/Reference/Template API, Rust-native, no JS engine. A WASM build of wikiparser-node is documented as the offline fallback; the trait seam makes the choice reversible.

Validation evidence (2026-06-04, AI-assisted)

Round-trip spike against en.wikipedia Cosmic latte (rev 1357330394), via the Parsoid REST transform with ?stash=true + If-Match:

  • No-op round-trip: byte-for-byte identical (selective serialization disturbs nothing untouched).
  • Param modify + param add on real {{cite}} templates: re-serialized only the edited template; all other refs/templates/prose byte-identical; the new param inserted in the template's existing |key=val style. Residual: light whitespace normalization within the edited template only.
  • Source read of the parsoid crate (parsoid/src/api.rs): it transforms via core REST with flavor=edit HTML but without the explicit stash/If-Match selser flow — so its byte-level cleanliness isn't yet proven equal to the raw spike's (acceptance gate 2 below).

Acceptance gates (resolve in review, before merge)

  1. GPL: confirm that this is acceptable. [See below.]
  2. Bare-URL-wrap round-trip — confirm inserting a new node (wrap a bare-URL <ref> body into {{cite web|url=…}}) round-trips cleanly; the spike covered param modify/add only. [Update: I've validated these.]
  3. parsoid-crate serialization fidelity — does transform_to_wikitext preserve untouched wikitext byte-for-byte, or normalize more than selser? If it under-preserves: contribute explicit selser upstream, or pair the crate's manipulation with a bespoke stash/If-Match transform (record in Decision 6). [Update: I've validated these.]
  4. Locator shape — model both the <ref> and cite-template ordinal spaces in the locator union now, or ship <ref> first and extend? (fold into Decision 3).
  5. Stash-expiry fallback — behaviour when an If-Match stash has aged out (pass the full original in the POST body).

Per ADR hygiene these gates live in this PR (not the ADR body); their resolutions get folded into the body before merge so the immutable record carries no open TODOs.


Transparency: the ADR draft and the validation spike were AI-assisted (Claude). The spike was read-only — it used the stateless Parsoid HTML→wikitext transform and never wrote to any wiki.

🤖 Generated with Claude Code

tieguy and others added 2 commits June 4, 2026 12:24
…actions

Proposed ADR: replace first-occurrence `replacen` content edits with
node-anchored editing behind a new `WikitextEditor` trait (Parsoid-backed
via the `parsoid` crate), plus an immediate exactly-one-occurrence guard.
Motivated by an incoming citation-verification collaboration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng consequence

Crate-level parsoid spike (en.wp Cosmic latte rev 1357330394) confirms the
fidelity gates: byte-lossless no-op round-trip, node-scoped param edit, and a
clean bare-URL -> {{cite web}} wrap. Remaining open item is the licensing
decision: parsoid is GPL-3.0 and statically linked, making distributed
binaries a GPL-3.0 combined work (no in-process boundary escape).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tieguy

tieguy commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

On the GPL question

the parsoid crate is GPL-3.0-or-later, and a Cargo dependency is statically linked. Rust has no dynamic-linking / runtime-classpath separation, so the crate is compiled into the final binary — making any SP42 distributed binary that links it a GPL-3.0 combined work.

Three ways through it:

  1. Relicense SP42 to GPL-3.0. Apache-2.0 → GPL-3.0 is allowed (one-way). Cleanest if copyleft is acceptable — keeps the crate's typed ergonomics.
  2. Bespoke permissive Parsoid-REST client. Skip the crate; reimplement the (small) wire calls — GET …/page/{title}/html?flavor=edit, POST …/transform/html/to/wikitext — plus DOM manipulation via a permissive HTML lib (html5ever/scraper, MIT). Same serialization fidelity — it's Parsoid's, server-side; the byte-lossless result is client-independent (my earlier bespoke curl spike got the same). Cost: hand-writing the typed cite/ref/template helpers the crate gives for free. Keeps SP42 Apache-2.0 / deny.toml clean.
  3. Isolate the editor in a separate GPL process. A small GPL sidecar doing the parsoid-crate work, called over IPC/HTTP; SP42 core stays Apache. Recreates the arms-length boundary but adds a process — somewhat ironic given Parsoid is already remote, so docs(prd): introduce Product Requirements Documents (PRDs) #2 usually dominates this.

The same GPL reality applies to the documented WASM-wikiparser-node fallback (also GPL, also linked-in), so #2 (bespoke client) is the only permissive option. The trade is purely typed-crate ergonomics + GPL vs more code + permissivefidelity is identical across all three.

My read: if SP42-goes-GPL is on the table, #1 is simplest and keeps the best ergonomics; if SP42 must stay permissive, #2 is the path. Either way the WikitextEditor trait seam (Decision 1) makes the vehicle a swappable implementation, so this licensing call doesn't block the trait/contract decision the ADR is really asking you to ratify.

@schiste

schiste commented Jun 4, 2026

Copy link
Copy Markdown
Owner

On the GPL question

the parsoid crate is GPL-3.0-or-later, and a Cargo dependency is statically linked. Rust has no dynamic-linking / runtime-classpath separation, so the crate is compiled into the final binary — making any SP42 distributed binary that links it a GPL-3.0 combined work.

Three ways through it:

  1. Relicense SP42 to GPL-3.0. Apache-2.0 → GPL-3.0 is allowed (one-way). Cleanest if copyleft is acceptable — keeps the crate's typed ergonomics.

Relicensed done per : 766f154 if you prefer another or even recommend a dual license I'm happy to do it. Copyleft is absolutely fine here.

@schiste

schiste commented Jun 5, 2026

Copy link
Copy Markdown
Owner

I'm merging PR with ADR as is but in implementing we will need to update the citation feature to keep it working.

@schiste
schiste merged commit cd01950 into main Jun 5, 2026
schiste added a commit that referenced this pull request Jun 28, 2026
Session validity was spread across five layers (cookie, server session,
upstream token, SW cache, the App-local auth signal) with no single authority:
the only re-gate path was polling /auth/session, and one page worked around it
with error.contains("401") string-matching. The HTTP layer also collapsed every
non-2xx into a String, losing the status.

Make the server session the one authority and a 401 the one re-gate trigger:
- http.rs: a thread-local unauthorized handler (mirroring the CSRF one) +
  finish_response() that invokes it on HTTP 401. set_unauthorized_handler is
  registered once by App at mount to flip auth -> anonymous, so any API 401 from
  any page drops to the login gate. The per-layer cookie/session/token timers
  are now belt-and-suspenders, not the load-bearing trigger.
- action_controller.rs: drop the bespoke is_auth_error string sniff and
  dev-session retry; the global handler covers it.
- revision_artifacts.rs: the two inline StatusCode::UNAUTHORIZED returns now use
  the shared unauthorized_error() helper, so every 401 has one shape/message.

Adds a wasm-gated test: only 401 fires the handler; other errors/success do not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
schiste added a commit that referenced this pull request Jun 29, 2026
…in (#90)

* feat(auth,wiki): any Wikimedia project + required Wikimedia OAuth login

Two capabilities (ADR-0014):

Any Wikimedia project
- Embed an authoritative SiteMatrix snapshot (crates/sp42-wiki/data/
  wikimedia-sites.json, 1070 sites; refresh via scripts/sync-wikis.sh) and add
  WikiRegistry::resolve(): a hand-configured wiki wins, otherwise a WikiConfig is
  derived from the embedded dbname->url map (api/parsoid URLs from the host,
  shared Wikimedia endpoints as constants, default namespaces, a universal
  language-agnostic scoring policy). SSRF-safe: hosts only come from vendored
  data, unknown wiki_id derives nothing. The server now resolve()s, so e.g.
  dewiki/commonswiki/eswiktionary work out of the box.
- Add active/default-language-agnostic to the embedded compiled-policy set.

Required Wikimedia OAuth login
- The server's OAuth2 PKCE flow already existed; wire the browser app to it: a
  required login gate (fetch_auth_session -> "Log in with your Wikimedia
  account" -> /auth/login), session header with username + logout, and the
  dev-token bootstrap demoted to a local-mode secondary. Add the missing /auth
  proxy to Trunk.toml (login 404'd in dev without it).
- Add a header wiki picker (?wiki=<dbname> override) so the workspace can point
  at any project.

Docs: ADR-0014, CONTRIBUTING (OAuth consumer registration + sync-wikis), docs map.

Verified: ci-build, ci-clippy (pedantic), ci-doc, ci-test, wasm32 --tests gate,
layer check, link check. The OAuth consumer registration + credentials are an
operational prerequisite for the live login (owner-only suffices for self-test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(auth): first-run local setup window writes .env.wikimedia.local

When running in local deployment mode with no Wikimedia credentials configured,
the login gate now shows a setup window: a "where to get a token" explainer +
link, and fields for a personal access token and/or OAuth consumer key+secret.
Saving POSTs to a new local-only endpoint that writes the values into
.env.wikimedia.local; it takes effect on the next dev-server start.

Server: POST /dev/auth/local-credentials, hard-gated to
SP42_DEPLOYMENT_MODE=local (same localhost-only trust as the dev bridge),
accepts only the three known WIKIMEDIA_* keys, preserves existing lines, writes
0600, never echoes the secret. write_local_credentials + merge_env in local_env.rs
with unit tests.

Frontend: LocalSetupPanel + save_local_credentials; is_local_deployment() reads
the existing deploymentMode runtime config. The panel shows only in local mode
when neither an OAuth client nor a local token is configured.

Verified: ci-build, ci-clippy (pedantic), ci-doc, wasm32 --tests gate, layer
check, link check, and sp42-server/sp42-wiki unit tests (incl. the env-merge +
unknown-key-rejection tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(app): wiki picker is a type-to-filter dropdown over all projects

Replace the 6 hardcoded picker suggestions with the full embedded site list:
add GET /wikis (the SiteMatrix snapshot's wiki_ids, ADR-0014), fetch it once on
mount, and populate the picker's <datalist> from it. The native combobox then
shows a dropdown and filters live as you type across all ~1070 Wikimedia
projects. Adds the /wikis Trunk proxy.

Verified: ci-build, ci-clippy (pedantic), ci-doc, wasm32 --tests gate, link
check; /wikis returns 1070 ids locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth,wiki): address Codex review on #90

- OAuth login now requests the full action grants (basic, editpage, patrol,
  rollback), so OAuth-logged-in users pass the edit/patrol/rollback capability
  gates instead of being read-only (oauth_runtime.rs). [P1]
- current_login_next() preserves the path + ?wiki= query + #view hash, so OAuth
  login returns to the requested project/view rather than the default. [P2]
- Article and Citation surfaces initialize from selected_wiki_id(), so the
  header wiki picker applies to every workspace view, not just Patrol. [P2]
- Derived wikis use a broader patrol-relevant namespace allowlist
  (0/4/6/10/14) so e.g. Commons File edits aren't dropped by the filter. [P2]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): re-gate workspace on idle-session expiry

Re-check /auth/session when the tab regains visibility so an expired
session returns the user to the login gate instead of a stale workspace
whose next API call 401s. The re-check only swaps auth state when the
authenticated status actually flips, so a still-valid session does not
remount (and reset) the workspace on every focus. Codex review #90.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(wiki): align derived namespace allowlist with patrol filter default

Add User (2) to the dynamically-derived namespace allowlist so it mirrors
the patrol filter UI default (DEFAULT_NAMESPACES). A derived wiki has no
curated config, so leaving filters at their default sends no override and
the server falls back to this allowlist; without User (2) here, User-
namespace edits were silently dropped even though the filter showed that
namespace as included. Codex review #90.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(wiki): accept a derivable default wiki id at startup

SP42_DEFAULT_WIKI_ID may now be any known Wikimedia project, not just a
hand-configured one: from_configs resolves the default once (configured
config wins, else derive from the embedded SiteMatrix snapshot) and stores
it, so a deployment can default to e.g. dewiki without a YAML config. An id
that is neither configured nor derivable is still rejected at startup.
default_config() returns the stored resolved config (now infallible). The
derived default is not folded into the hand-configured set, so config() and
wiki_ids() keep their curated-only contract. Codex review #90.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): split-origin OAuth return + hide dev-token outside local mode

Two Codex review findings on #90:

- Split frontend/API deployments: the login button navigates to the API
  origin, but `next` carried only path/search/hash, so after /auth/callback
  the server redirected to that relative path on the API host and stranded
  the user on the backend. current_login_next() now returns an absolute
  frontend-origin URL when the API base is cross-origin, and the server's
  sanitize_redirect_target honors an absolute target only when its origin is
  in the configured allowed-origins list (open-redirect guard); same-origin
  deployments keep the relative path.

- Dev-token bootstrap button: shown whenever a local token was reported
  available, even in vps/desktop where post_bootstrap_session rejects the
  route — a dead button. It is now gated on is_local_deployment().

Adds unit tests for the redirect sanitizer (relative kept, protocol-relative
and off-origin absolute rejected, allowed-origin absolute honored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): honor configured non-HTTP app origins in redirect sanitizer

The split-origin redirect guard only accepted http/https targets, so a
packaged Tauri webview reporting location.origin as tauri://localhost (a
configured desktop allowed origin) had its return target rejected and the
OAuth callback landed on the sidecar root instead of the desktop app.

Compare origins as reconstructed scheme://host[:port] strings instead of
requiring an http(s) tuple origin: configured app schemes such as
tauri://localhost now match the allow list, while authority-less targets
(javascript:, mailto:) still yield None and fall back to "/". The allow
list remains the open-redirect boundary. Adds a desktop-origin test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): compare parsed origins for split-deployment detection

is_split_origin_deployment() compared the API base against the frontend
origin with starts_with, which misclassifies a different origin that merely
shares a string prefix (e.g. :4173 vs :41730, or app.example.org vs
app.example.org.evil) as same-origin — so current_login_next() sent a
relative next and the OAuth callback stranded split-origin users on the API
host. Compare parsed scheme://host[:port] origins instead. Adds tests for
the port- and host-suffix prefix pitfalls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): cross-site session cookie for split-origin deployments

The session cookie was always SameSite=Lax, which browsers drop on
credentialed cross-site fetches — so after a split-origin OAuth login (a
different-site vps frontend/API pair, or the desktop tauri://localhost →
loopback sidecar) /auth/session and subsequent API calls looked
unauthenticated. Session cookies now use SameSite=None; Secure for the
cross-site modes (vps, desktop) and keep SameSite=Lax for local (same-site
localhost across ports, plain http). Secure is forced whenever SameSite=None
since browsers require the pairing. CORS already allows credentials with a
specific-origin list and the client already sends credentials, so this was
the missing piece. Tests cover all three modes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): session cookie Max-Age tracks absolute timeout, not idle

The cookie Max-Age was pinned to the 30-minute idle timeout, set only at
login/bootstrap. Authenticated reads slide the server-side idle window
(last_seen_at_ms) but emit no new Set-Cookie, so the browser dropped
sp42_dev_session 30 minutes after login even for an actively-working user,
bouncing them well before the 8h absolute timeout. The cookie now lives for
the absolute timeout (the longest a session can possibly last); the server
still enforces the finer sliding-idle policy, so an idle-expired session just
yields a cookie the server rejects. Adds a Max-Age coverage test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(desktop): accept custom app-scheme origins from SP42_ALLOWED_ORIGINS

The desktop sidecar sets SP42_ALLOWED_ORIGINS to include tauri://localhost,
but the env-origin parser used parse_http_url, which rejects non-HTTP(S)
schemes — so DeploymentConfig::load() errored and the packaged desktop app
could not boot, even though the default desktop allow list already trusts
tauri://localhost. The SP42_ALLOWED_ORIGINS path now canonicalizes origins
via a lenient parser that accepts custom app schemes (scheme://host[:port]),
mirroring the redirect sanitizer; SP42_PUBLIC_BASE_URL stays HTTP(S)-only
since it builds the OAuth redirect URI. Adds a parser test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): keep auth out of SW cache + cap session at token expiry

Two Codex review findings on #90:

- The PWA service worker's network-only list covered /dev/ and /oauth/ but
  not /auth/, so networkFirst could cache /auth/session JSON (incl. the CSRF
  token) and replay it offline, leaving stale authenticated state instead of
  re-gating. Added /auth/ to NETWORK_ONLY_PATTERNS.

- The SP42 session could outlive the upstream OAuth access token: when the
  token endpoint returns a shorter expires_in, the session stayed valid for
  up to 8h while every wiki API call failed with an expired bearer.
  session_expires_at_ms now caps the deadline at upstream_access_expires_at_ms
  so the session re-gates when the token dies (refresh is out of scope,
  ADR-0014). Adds a cap test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(platform): one shared origin primitive (root-cause #2)

Origin parsing/comparison was copied ad hoc in three places, which is why the
same bugs (string-prefix matching, HTTP-only-scheme assumptions) each recurred
twice during review. Extract a single primitive in sp42-platform
(origin.rs: origin_of / origin_of_url / origins_match) — canonical
scheme://host[:port], default ports omitted, accepts custom app schemes like
tauri://localhost, rejects authority-less targets — and delegate the three
copies to it:
- sp42-server oauth_runtime.rs (redirect sanitizer allow-list match)
- sp42-server deployment.rs (SP42_ALLOWED_ORIGINS canonicalization)
- sp42-app config.rs (split-origin detection)

Behavior is unchanged; tests now live with the primitive. Layering clean
(both shells depend on the platform layer per ADR-0013).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(wiki): one WikiConfig contract for authored + derived (closes #91)

Derived WikiConfigs bypassed the authored contract, leaving three default
sources that drifted: WikiTemplates::default() (French), the authored scoring
default (active/frwiki-vandalism), and the derived path (English + language-
agnostic), plus a hand-duplicated [0,2,4,6,10,14] namespace list in two crates.

Unify on a single source in sp42-platform:
- DEFAULT_PATROL_NAMESPACES — used by both the derived allowlist (sites.rs) and
  the frontend filter default (filter_bar.rs); they can no longer drift.
- DEFAULT_SCORING_POLICY_REF / DEFAULT_SCORING_POLICY_WIKI_ID — the authored
  default ref now equals the derived one, and parse_wiki_config accepts the
  universal "default" policy for any wiki, so authored and derived share the
  same language-agnostic fallback.
- WikiTemplates.citation_needed is now Option<String> (no French default).
  Derived wikis carry None and the tag-citation-needed action refuses with
  citation-needed-not-enabled rather than inserting a wrong-language template,
  mirroring how bare_url_citation already gates bare-URL repair. Closes #91.

frwiki/testwiki keep their explicit tuned policies and templates. Tests cover
the refusal + configured paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(auth): single 401-driven re-gate authority (root-cause #1)

Session validity was spread across five layers (cookie, server session,
upstream token, SW cache, the App-local auth signal) with no single authority:
the only re-gate path was polling /auth/session, and one page worked around it
with error.contains("401") string-matching. The HTTP layer also collapsed every
non-2xx into a String, losing the status.

Make the server session the one authority and a 401 the one re-gate trigger:
- http.rs: a thread-local unauthorized handler (mirroring the CSRF one) +
  finish_response() that invokes it on HTTP 401. set_unauthorized_handler is
  registered once by App at mount to flip auth -> anonymous, so any API 401 from
  any page drops to the login gate. The per-layer cookie/session/token timers
  are now belt-and-suspenders, not the load-bearing trigger.
- action_controller.rs: drop the bespoke is_auth_error string sniff and
  dev-session retry; the global handler covers it.
- revision_artifacts.rs: the two inline StatusCode::UNAUTHORIZED returns now use
  the shared unauthorized_error() helper, so every 401 has one shape/message.

Adds a wasm-gated test: only 401 fires the handler; other errors/success do not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(platform): don't link a public const to a private fn

The DEFAULT_SCORING_POLICY_REF doc linked to the private default_scoring_policy_ref
fn, which rustdoc rejects under -D warnings (the CI Docs gate). Use plain code
formatting instead of an intra-doc link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth,wiki): CSPRNG session id, gate metadata refresh, ns override

Four Codex findings on #90:

- P1: session ids were derived from millisecond + atomic counter + pid, so an
  attacker who can approximate a login time could guess another user's
  sp42_dev_session cookie and use the stored Wikimedia bearer token. Generate a
  256-bit CSPRNG identifier (ServerRng) instead; drop the now-unused counter
  field from AppState.

- The login gate ignored unauthenticated metadata changes: the refresh guard
  only updated state when the authenticated bool flipped, so oauth_client_ready /
  local_token_available changes (after local setup or a server restart) left the
  gate stuck on a stale "not configured"/setup screen. Now refresh whenever
  unauthenticated, and skip updates only while staying authenticated (still
  avoiding a workspace remount on every probe).

- The 401 handler installed a synthetic AuthSession::default() (oauth_client_ready
  false, empty paths), stranding users on a "not configured" gate after an
  expired-session 401. It now re-fetches the real /auth/session so the correct
  login UI shows.

- parse_recent_changes_response treated config.namespace_allowlist as a hard cap,
  dropping namespaces (Talk/User-talk) the user explicitly selected via the filter
  even though the request already included them. It now honors the query
  namespace override, falling back to the allowlist only as a default.

Tests: namespace-override honored; gate metadata/401 paths via the wasm gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): gate shared env-token fallbacks to local mode

Gating the dev-bootstrap endpoint wasn't enough: access_token_for_request and
the capability probe still fell back to state.local_oauth.access_token()
regardless of deployment mode. With WIKIMEDIA_ACCESS_TOKEN present in vps/desktop,
an unauthenticated request could borrow the shared bearer for /operator/live,
article inventory, and capability probes — even though this PR makes per-user
OAuth the required identity. Apply the same local-only gate
(permits_dev_token_bootstrap) to those fallbacks: access_token_for_request and
capability_probe_token return None outside local mode, and the shared-token
capability cache is not served there. Adds a gate test. Codex review #90.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(auth): centralize the shared-token mode gate (root cause)

Follow-up to baa236c: patching access_token_for_request + capability_probe_token
fixed the two flagged sites but not the cause — eight consumers each read
local_oauth.access_token() raw and decided independently, so the availability
flags (local_token_available, bootstrap_ready, readiness "available") still
advertised the env token outside local mode, and the gate logic was duplicated.

Introduce one gated accessor, AppState::shared_local_access_token(), that returns
the env token only in local mode, and route every identity- and
availability-bearing consumer through it: the request fallback, the capability
probe, to_status, auth_session_view, bootstrap_status, and server readiness.
to_status now takes a precomputed local_token_available bool. So no caller can
treat the shared token as identity (or advertise it) outside local mode, and the
frontend is_local_deployment() gate becomes defense-in-depth rather than the only
guard.

The three remaining raw reads are intentional and documented: the accessor's own
body, the bootstrap endpoint (already hard-gated to local mode), and the
background poll of *public* recentchanges (not a per-user identity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): fail-closed local-mode gate + 401 from /operator/live

Two Codex findings on #90:

- is_local_deployment() treated an absent deploymentMode as local, so a split or
  static frontend that doesn't load /runtime-config.js from the API origin would
  show local-only setup/dev-token controls a vps/desktop server rejects. Fail
  closed: only an explicit `local` enables them (unknown -> non-local).

- After the shared-token gating, /operator/live with no session returned the
  assembly's generic 502 (missing token), but the wasm auth refresh only re-gates
  on 401 — leaving the user stranded on a Patrol load error. The handler now
  returns 401 (token-gated like the action/citation routes) so the app drops back
  to the login gate. Adds an integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(patrol,citation): per-wiki namespace defaults + local-only citation bootstrap

Two Codex findings on #90:

- The patrol filter's default namespace checkboxes used a static [0,2,4,6,10,14]
  that only matches derived/frwiki configs. For a hand-configured wiki with a
  different allowlist (enwiki/testwiki are [0,1]), untouched checkboxes showed the
  wrong set while the server (empty query -> config.namespace_allowlist) returned
  the curated one — UI and reality disagreed. Add GET /wikis/{wiki_id} returning
  the resolved namespace allowlist, fetch it for the active wiki, and drive the
  FilterBar default from it so the checkboxes match server behavior.

- The Citations view bootstrapped a dev session (/dev/auth/session/bootstrap)
  whenever unauthenticated, regardless of mode. In vps/desktop that route is
  forbidden (403, not 401), so the global 401 handler never re-gated and the user
  was stuck with "Auth bootstrap failed". The bootstrap is now gated to local
  mode; outside local mode the verify call 401s and the app re-gates to login.

Tests: /wikis/{id} resolved namespaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tieguy
tieguy deleted the adr-0003-node-anchored-wikitext-editing branch July 1, 2026 23:56
schiste pushed a commit that referenced this pull request Jul 3, 2026
Net-new Wikidata convenience verb (no prior Wikidata path in SP42). Fetches the
entity via the keyless Special:EntityData endpoint, renders the statement into a
natural-language claim (resolving property/item-value labels via wbgetentities),
extracts the P854 reference URL, and verifies the claim against it with
verify_claim. A statement with no reference URL returns SourceUnavailable (no
model call). The rendered claim is always returned for inspection (rendering is
best-effort; richer datatypes are follow-on — PRD-0010 open question #1).

- generic over HttpClient + ModelClient; fully stub-tested (3 cases:
  full-path render+verify, no-reference SourceUnavailable, missing-entity error)
- exposed as the third MCP tool; tool-router test asserts all three verbs

Deferred (per review): verify_wikipedia_page needs the Parsoid editor extracted
from sp42-server to a shared crate; Phase 6 (Tasks/progress/estimate_only) rides
on the page decomposition. Both are a focused follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ge3js4ukPTvs9hBBtnoGj
tieguy added a commit that referenced this pull request Jul 14, 2026
…okup join, distinct failure labels

1. Books consulted section: render book_resolutions (resolved outcomes only)
   with edition title and scan state (exact, similar, none, unknown) via new
   render_books_section() fn, positioned after skipped section before
   extraction failures. Adds 6 copy constants for scan-state vocabulary;
   keeps render_ga_appendix at 98 lines (under 100-line budget by extracting fn).

2. Per-source lookup-failure join: replace ref_id-only key with (ref_id AND
   identifiers) matching. For URL-less refs citing multiple books, each skip
   now joins to the matching BookResolution (not all LookupFaileds), so
   NotFound and LookupFailed outcomes render their distinct copy. Falls back
   to ref_id-only when skip.book_sources is empty.

3. Extraction-failure labels: unnamed cite_ref-<digits> now render as
   "an unnamed ref (block N)" using BlockFailure.block_ordinal (not global
   "ref #1"), so distinct failures stay distinct. Named refs stay on
   derived-name path. Sanitize_reason now takes block_ordinal parameter.

Test coverage: added distinct_unnamed_refs_in_same_output_render_distinct_labels(),
per_source_lookup_join_distinguishes_multiple_books_in_same_ref(),
books_consulted_section_renders_resolved_editions(), plus block-anchored
helper tests. All tests pass; cargo fmt, clippy -D warnings,
check-design-system.sh, check-links.sh all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants