Complete experimental OKF branch hardening

This commit is contained in:
Franz Rolfsvaag 2026-07-17 22:10:00 +02:00
parent 32ec0a102f
commit a782412a43
129 changed files with 8974 additions and 921 deletions

View File

@ -4,18 +4,27 @@ Discord bot + WebUI with role-based access, plugin management, and self-update s
## Quick start ## Quick start
Requires Node.js 18+. Requires Node.js 18+ on Windows or Linux.
1. Install dependencies: 1. Install dependencies:
``` ```
npm install npm install
``` ```
Do not use `--ignore-scripts`: Lumi uses the native `better-sqlite3` module,
which must download or build a binary for your Node.js version. If Node was
upgraded after installation, run `npm rebuild better-sqlite3` or remove
`node_modules` and run `npm install` again. Linux source builds may also need
Python and a C/C++ compiler toolchain.
2. Run with auto-restart: 2. Run with auto-restart:
``` ```
npm run run npm run run
``` ```
3. Open `http://localhost:3000/setup` and enter your Discord app + bot settings. 3. Open `http://localhost:3000/setup` and enter your Discord app + bot settings.
Before starting Lumi, you can check the runtime and native dependency with
`npm run verify:preflight`. Run the complete syntax and focused test suite with
`npm run verify:all`; it stops at the first failure and prints the failing check.
You can also seed local configuration with a `.env` file. Use `.env.example` You can also seed local configuration with a `.env` file. Use `.env.example`
as the template; `.env` is ignored by git. as the template; `.env` is ignored by git.
@ -54,11 +63,23 @@ Recovery mode can be started with `LUMI_SAFE_MODE=1 npm run run`,
## Twitch bot ## Twitch bot
Configure Twitch chat settings in **Admin → Settings**: Configure Twitch chat settings in **Admin → Settings**:
- `twitch_bot_username` - `twitch_bot_username`
- `twitch_bot_oauth` (OAuth token) - `twitch_bot_oauth` (OAuth token)
- `twitch_channels` (comma-separated) - `twitch_channels` (comma-separated)
Custom commands can target Discord, Twitch, or both from **Admin → Commands**. Custom commands can target any enabled chat platform from **Admin → Commands**.
Static commands send one template. Random Reply commands choose among weighted
messages and can optionally roll a number for conditions such as `<20` or
`>=10`; insert that roll with `{{core.command.rng}}`. Admin-only Dynamic commands
accept JavaScript or Python snippets that return a reply. Lumi wraps simple
snippets automatically while preserving explicit `run(ctx)` and module exports.
Previews use deterministic mock data and never send external network requests;
network calls, if intentionally used by an admin-authored command, occur only in
the real command runtime.
Admins can create reusable permission-aware `{{custom.*}}` text values under
**Admin → Settings → Reusable text values**.
## Users and linking ## Users and linking
@ -75,6 +96,13 @@ incomplete values fall back safely to the selected built-in base theme.
Developer and modding conventions are documented in Developer and modding conventions are documented in
[`docs/lumi-ui.md`](docs/lumi-ui.md). [`docs/lumi-ui.md`](docs/lumi-ui.md).
## OBS overlays
Admins can create live, token-protected OBS Browser Sources under **Admin → OBS
overlays**, with server-controlled or fixed scenes and optional local OBS
WebSocket or zero-credential OBS Browser Bridge synchronization. See
[`docs/obs-overlays.md`](docs/obs-overlays.md).
## Notes ## Notes
- Auto-update uses `git pull` from the configured remote + branch. - Auto-update uses `git pull` from the configured remote + branch.

70
TODO.md
View File

@ -4,6 +4,30 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
## Current Local State / Source of Truth ## Current Local State / Source of Truth
### P0 OBS Overlay System
Implemented locally on 2026-07-17: admin-only overlay management, separate public
Browser Source rendering, encrypted and revocable overlay/scene tokens, live
scoped event updates with reconnect recovery, scene and module-instance CRUD and
ordering, optional OBS WebSocket control/synchronization, connector provider
interfaces, focused verification, and operator documentation.
The editor now also includes an OBS-style live canvas with source selection,
eight resize handles, drag/nudge positioning, edge/center/source snapping,
anchors, contextual source fields, text layout/overflow controls, and external
website-overlay health checks plus manual and automatic refresh. Video and audio
sources now include playback controls and OBS-routable sound; web sources include
isolated custom frame CSS, cropping, and zoom. An optional OBS Browser Bridge now
syncs supported OBS scene, transition, canvas, output, permission, and source
state without WebSocket credentials, with a one-action setup importer.
Remaining work:
- Build and authenticate the future trusted Lumi streaming-computer client.
- Add moderator-to-overlay assignment UI and persistence when the assignment
model is defined; centralized overlay capability hooks are ready and continue
to deny moderators and all secret access by default.
### OKF Knowledge System ### OKF Knowledge System
Current state on `experimental-okf` as of 2026-06-21: standalone OKF plugin work is implemented locally and not pushed. The plugin provides SQLite-backed entries, sanitized Markdown, logged-in browsing, role-gated details, admin/editor management, per-user OKF permission grants through the shared core user lookup, workflow actions, version history, version restore, role-preview tooling, role-aware Lumi AI context integration through the generic AI context provider hook, a file-backed Markdown knowledge retrieval layer, generated core/plugin OKF baseline files, admin-editable community OKF files, and admin-created correction OKF files from reviewed core feedback. Current state on `experimental-okf` as of 2026-06-21: standalone OKF plugin work is implemented locally and not pushed. The plugin provides SQLite-backed entries, sanitized Markdown, logged-in browsing, role-gated details, admin/editor management, per-user OKF permission grants through the shared core user lookup, workflow actions, version history, version restore, role-preview tooling, role-aware Lumi AI context integration through the generic AI context provider hook, a file-backed Markdown knowledge retrieval layer, generated core/plugin OKF baseline files, admin-editable community OKF files, and admin-created correction OKF files from reviewed core feedback.
@ -192,6 +216,33 @@ Current state on `experimental-okf` as of 2026-06-21: the core feedback system i
## Active Work ## Active Work
Audited on 2026-07-17 after completing the exported P0-P3 queue. Only the items
in this section remain active; the detailed planning checklists below are kept
as historical design context and are no longer an open-work list.
- Build and authenticate the future trusted Lumi streaming-computer client.
- Add moderator-to-overlay assignments after the ownership model is defined;
the centralized overlay capability hooks already fail closed.
- Add Twitch live state, follower/subscriber statistics, and scheduled stream
placeholders only after a background provider cache exists. Rendering must
never make provider API calls.
- Revisit a persistent OKF index when there are at least 2,000 active files and
generated-fixture or production warm-search p95 exceeds 100 ms. The
2026-07-17 benchmark measured about 45 ms p95 at 1,000 files and 87 ms at
2,000 files, so the current mtime/size parse cache remains appropriate.
- Continue live Lumi AI controller tuning only when real usage provides evidence
that the current heuristics need adjustment.
- Consider Discord.js 14 as a separately planned compatibility upgrade; the
current v13 integration is verified and a major upgrade should not be mixed
into unrelated security fixes.
- Broaden browser-matrix checks for clipboard image copy/read behavior. Upload,
paste, native-menu, URL-copy, and file-picker fallbacks are implemented.
- Add deeper provider-specific homepage failure detection only where a safe
cached API or browser signal exists. Cross-origin iframe CSP/X-Frame-Options
failures cannot be reliably preflighted without creating an SSRF path.
## Historical Planning Checklists (Implemented or Superseded)
### OKF Knowledge System: File-Backed Knowledge Storage ### OKF Knowledge System: File-Backed Knowledge Storage
- Add richer category/tag management and Markdown preview/editor polish. - Add richer category/tag management and Markdown preview/editor polish.
@ -621,6 +672,25 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no
## Done ## Done
### 2026-07-17
- 2026-07-17: Completed exported P0 preflight/security work: deterministic preflight/all verifiers, shared atomic retrying file writes with diagnostics, shared WebUI authentication guards, OKF privilege/field filtering, direct dependency audit remediation, and signature-validated bounded uploads with cleanup and protected downloads.
- 2026-07-17: Completed exported P1 update/recovery work: protected-data preservation tests, rollback-safe repository/tool updates, shared async update actions, clearer update language, and activation validation inside tool-install transactions.
- 2026-07-17: Completed Lumi AI feedback and work-history tasks: answer ratings/comments/context snapshots, admin review/corrections, correction links/inventory, sanitized grouped Codex exports including legacy rows, and controller/retrieval diagnostics coverage.
- 2026-07-17: Completed OKF usability and retrieval work: live Markdown previews, role-safe contributor navigation, category/tag filtering, deterministic generated files, improved specific-query ranking, generic-help suppression, and correction discovery.
- 2026-07-17: Completed core feedback hardening: merge/unmerge with canonical support aggregation, richer route/plugin/target filters, stronger duplicate similarity, attachment lifecycle/security, clipboard fallbacks, and expanded correction redaction.
- 2026-07-17: Completed the trusted placeholder migration audit, fixed deterministic async replacement, added resolver timeouts/fallbacks, documented cache-only platform data status, and added the admin-managed permission-aware `{{custom.*}}` registry.
- 2026-07-17: Completed homepage embed reliability: Twitch multi-parent player/chat layouts and minimum sizing, YouTube cached connected-channel live lookup and valid-video live chat, live-only fall-through, responsive dual-frame rendering, and clearer setup warnings.
- 2026-07-17: Completed the major UI wording/action audit: task-focused labels, contextual helper text, consistent destructive styling/confirmations, and removal of generic Save/Update button labels from primary forms.
- 2026-07-17: Completed custom-command expansion: automatically wrapped JavaScript/Python snippets, preserved explicit/module handlers, Random Reply mode, optional RNG conditions, integer weights, the `{{core.command.rng}}` value, migration-safe storage, validation, and previews.
- 2026-07-17: Hardened Dynamic command previews after release audit: safe built-ins remain available, harmless blocked-word text no longer causes false failures, host-constructor escapes remain disabled, external preview network requests are blocked, and long previews now expand inline without altering punctuation or whitespace.
- 2026-07-17: Benchmarked file-backed OKF search with generated 1,000- and 2,000-file fixtures, including visibility and changed/deleted-file assertions. Persistent indexing is deferred until the documented 2,000-file/100-ms-p95 threshold is crossed.
- 2026-07-17: Added the optional OBS Browser Bridge provider using OBS-injected page bindings: scoped heartbeat/state reporting, active-instance election, page-permission diagnostics, scene synchronization and commands, transitions/output/canvas/source status, profile/scene-collection capability reporting, and a non-destructive OBS setup importer. No repo push yet.
- 2026-07-17: Connected destructive controls across core and plugins to the shared timed-confirmation contract, moved generic Lumi AI and Auto VC deletes onto explicit protected routes, added timed update-restore requests, and added a repository-wide destructive-action verifier. No repo push yet.
- 2026-07-17: Extended OBS overlays with video/media and audio sources, play-once/loop/manual behavior, volume/start/speed/video-fit controls, live playback restart, website-alert autoplay audio, and isolated web-frame CSS plus crop/zoom controls. No repo push yet.
- 2026-07-17: Added the visual overlay editor follow-up locally: simplified user-facing language, contextual help, a responsive sticky preview canvas, drag/resize/nudge transforms, Ctrl/Cmd-toggle snapping to canvas and source lines, nine anchors, conditional source controls, horizontal/vertical text alignment and overflow modes, and sandboxed third-party website sources with live manual refresh plus bounded automatic health recovery. No repo push yet.
- 2026-07-17: Implemented the P0 OBS overlay system locally: multi-overlay and scene management, fixed and active-scene Browser Sources, encrypted revocable tokens, live scoped updates with reconnect recovery, reusable overlay module instances, optional OBS WebSocket testing/control/mapping/sync with loop prevention, future trusted-client provider interfaces, focused tests, and documentation. No repo push yet.
### 2026-06-25 ### 2026-06-25
- 2026-06-25: Continued Lumi AI runtime CUDA-preference work locally: Windows NVIDIA-compatible installs now prefer the managed CUDA llama.cpp runtime over Vulkan, the runtime manifest supports CUDA dependency archives, runtime downloads validate/reuse complete partial archives, install metadata records the backend variant/dependencies, and `/plugins/lumi_ai` shows the total runtime download size. No repo push yet. - 2026-06-25: Continued Lumi AI runtime CUDA-preference work locally: Windows NVIDIA-compatible installs now prefer the managed CUDA llama.cpp runtime over Vulkan, the runtime manifest supports CUDA dependency archives, runtime downloads validate/reuse complete partial archives, install metadata records the backend variant/dependencies, and `/plugins/lumi_ai` shows the total runtime download size. No repo push yet.

164
docs/obs-overlays.md Normal file
View File

@ -0,0 +1,164 @@
# OBS overlays
Admins manage overlays at **Admin → OBS overlays**. Management pages use the
normal Lumi session, role checks, layout, forms, lists, modals, and confirmation
flow. Browser Source pages use a separate transparent document with no Lumi
navigation or authenticated website data.
## Editing an overlay
The editor uses the same basic model as OBS: scenes contain ordered sources, and
the selected source has a red box with resize handles. On a wide screen the
preview stays beside the settings; on smaller screens it moves above them.
- Drag a source to move it.
- Drag any corner or edge handle to resize it.
- Use arrow keys for small moves, or Shift plus an arrow for larger moves.
- Snapping uses canvas edges, center lines, and other source edges/midpoints.
- Hold Ctrl on Windows/Linux or Cmd on macOS to temporarily reverse the Snap
setting.
- Center, Fit to canvas, and Reset box provide familiar transform shortcuts.
Every source can use any corner, edge midpoint, or center as its anchor. Text
sources have separate horizontal and vertical alignment plus wrapping, shrinking,
clipping, single-line, and scrolling ticker behavior. The editor only shows fields
that apply to the selected source type.
## Private OBS links
Each overlay has one server-controlled URL. It follows the active overlay scene.
Each scene also has a fixed URL that always renders that scene. Both URL forms
contain 256-bit random bearer tokens instead of database IDs. Tokens are stored
encrypted for admin copy actions and as SHA-256 hashes for public lookup.
Regenerating a URL immediately revokes the old token.
Treat every Browser Source URL as a password. Only admins can view, copy, preview,
or regenerate it. Public state responses contain render data only; they do not
contain other URLs, tokens, OBS credentials, or website session data.
Browser Sources receive scoped live-change events through Lumi's existing event
service. The render page fetches a fresh authorized snapshot after each event and
after every reconnect, so temporary network loss does not require an OBS refresh.
## Scenes and sources
An overlay always keeps at least one scene. Admins can create, edit, duplicate,
order, enable, activate, and delete scenes. Current built-in source types are:
- Text
- Image URL
- Video / media URL
- Audio URL
- Website or alert overlay
Module instances can be created, edited, duplicated, ordered, enabled, and
deleted. New module types can call `registerOverlayModuleType(...)` in
`src/services/overlay-modules.js`; providers normalize their configuration and
select the safe built-in `text`, `image`, `video`, `audio`, or `web` render type while the overlay
service retains the same instance model.
### Video and audio
Video sources support browser-playable media such as WebM and MP4; audio sources
support browser-playable formats such as Ogg, MP3, and WAV. Actual codec support
follows the Chromium/CEF build used by the browser or OBS.
Playback can start once when the overlay scene loads, loop continuously, or wait
for manual playback. Sources also provide volume, start offset, playback speed,
optional player controls, and a live **Restart playback** action. Video adds mute
and contain/cover/stretch sizing. Activating another Lumi scene removes the old
scene's media elements, which stops their playback; returning to the scene applies
its configured start behavior again.
Website overlays can play their own alert audio because their iframe receives
autoplay permission. To route video, audio, and website-alert sound through an
independent OBS mixer channel, enable **Control audio via OBS** in the Lumi Browser
Source properties, then configure monitoring/output in OBS as desired.
### External website and alert overlays
Website sources support third-party browser overlays such as alert and chat
overlays. They run in a sandbox and do not receive the Lumi admin page or its
login data. The **Refresh now** action sends a live refresh command to every open
OBS copy of that source.
Automatic recovery is optional. When enabled it watches for a page that never
finishes loading, can check public website replies for HTTP errors, empty content,
unexpected content types, and obviously malformed pages, retries at 5, 15, and 45
second-style bounded intervals, and periodically reloads the source to avoid stale
content. Retry count and refresh interval are configurable. Local/private website
addresses are periodically refreshed but are not fetched by the Lumi server for
security; browser load-timeout recovery still applies.
Website sources also support per-edge percentage cropping, zoom, and isolated
custom frame CSS. Custom CSS is scoped to the source frame so it cannot restyle
the Lumi render page or other sources. Standard browser cross-origin security
does not allow parent-page CSS to select elements inside a third-party iframe;
use crop and zoom to hide unwanted portions of an external site. Frame-level CSS
can target `iframe`, for example `iframe { filter: saturate(1.2); }`.
## OBS WebSocket
The connector is disabled by default. Enable OBS WebSocket in OBS, keep its
password enabled, and use the default local endpoint
`ws://127.0.0.1:4455` when Lumi runs on the streaming computer. If Lumi runs on
another trusted machine, use a private LAN/VPN address and firewall rules. Do not
port-forward or publicly expose OBS WebSocket.
The admin page can test the connection, read OBS scenes, change the current OBS
scene, and map OBS scene names to overlay scenes. Sync can run in either direction
or bidirectionally. Source tagging and a short outbound suppression window prevent
sync loops. Connector errors never stop overlay rendering, and optional reconnect
uses bounded exponential backoff.
OBS passwords use authenticated AES-256-GCM encryption derived from Lumi's stored
session secret. They are never returned by public APIs or rendered for
non-admins.
## OBS Browser Bridge
The optional **OBS Browser Bridge** uses the `window.obsstudio` bindings that OBS
injects into Browser Sources. It does not need an OBS WebSocket address or
password. Select the provider, save it, then load the private Lumi overlay URL in
OBS. The render page reports a heartbeat every five seconds and immediately after
relevant OBS events.
Use the minimum Page permission needed:
- **Read access to user information** reads the current scene, scene list,
transitions, canvas dimensions, output status, and Browser Source activity.
- **Advanced access to OBS** is needed only when Lumi should change OBS scenes.
- Full access is not requested or used by the current bridge.
Lumi elects one recently active Browser Source instance per overlay as the bridge,
targets commands to that instance, and deduplicates command IDs. Existing
scene-direction checks and outbound suppression prevent synchronization loops. If
the source stops reporting, Lumi marks the bridge as waiting instead of breaking
overlay rendering. Leave OBS's **Shutdown source when not visible** option off if
the bridge must remain available outside the source's active scene.
The **Apply detected OBS setup** action adds missing OBS scenes, maps same-name
Lumi scenes, adopts the detected OBS canvas size, and selects the current OBS
scene. It does not delete existing Lumi scenes. OBS Browser bindings do not expose
profile or scene-collection names, so those fields are explicitly reported as
unavailable; the OBS WebSocket provider can read them when that metadata is
required.
## Connector extension point
`src/services/overlay-connectors.js` owns the provider registry. A provider
creates a connector with these operations:
- `connect()` and `disconnect()`
- `status()`
- `listScenes()` and `getActiveScene()`
- `setActiveScene(sceneName)`
- `onSceneChanged(listener)` and `onClosed(listener)`
The reserved `trusted_lumi_client` provider is intentionally unavailable. A
future trusted desktop client can implement this interface to transport OBS state
and commands without changing overlay routes, mappings, or synchronization rules.
Plugins can register providers, module types, or future scoped access providers
through `global.lumiFrameworks.overlays`.
Run focused verification with `npm run verify:overlays`.

View File

@ -27,6 +27,8 @@ Use namespaced double-brace tokens:
- `{{core.main.bot_name}}` - `{{core.main.bot_name}}`
- `{{core.main.command_prefix}}` - `{{core.main.command_prefix}}`
- `{{core.command.rng}}`
- `{{custom.stream.title}}`
- `{{user.public.display_name}}` - `{{user.public.display_name}}`
- `{{platform.discord.guild.member_count}}` - `{{platform.discord.guild.member_count}}`
- `{{okf.file.community.currency.primary_name}}` - `{{okf.file.community.currency.primary_name}}`
@ -35,23 +37,27 @@ Use namespaced double-brace tokens:
Avoid unqualified names for new work. Compatibility aliases may exist for old Avoid unqualified names for new work. Compatibility aliases may exist for old
templates, but the catalog should present canonical namespaced tokens. templates, but the catalog should present canonical namespaced tokens.
Custom admin-defined placeholders, if added later, should live under the Admin-managed values live under `{{custom.*}}`. Admins create them under
`{{custom.*}}` namespace and be managed from a trusted admin settings page. Settings > Reusable text values; names are validated, cannot collide with
system/plugin namespaces, and are stored in the update-preserved settings
database.
## Core Namespaces ## Core Namespaces
Current core-owned namespaces include: Current core-owned namespaces include:
- `core.main.*`: safe Lumi core settings such as bot/site name and command prefix - `core.main.*`: safe Lumi core settings such as bot/site name and command prefix
- `core.command.*`: values created for one command execution, such as a Random Reply RNG roll
- `custom.*`: admin-managed reusable values with explicit user, moderator, or admin visibility
- `user.public.*`: safe viewer/triggering-user display information - `user.public.*`: safe viewer/triggering-user display information
- `platform.discord.guild.*`: safe Discord guild statistics from the configured guild - `platform.discord.guild.*`: safe Discord guild statistics from the configured guild
- `platform.twitch.channel.*`: safe locally configured Twitch channel/runtime values - `platform.twitch.channel.*`: safe locally configured Twitch channel/runtime values
- `platform.youtube.channel.*`: safe locally configured or hydrated YouTube channel/runtime values - `platform.youtube.channel.*`: safe locally configured or hydrated YouTube channel/runtime values
- `okf.file.*`: file-backed OKF frontmatter values, with legacy aliases such as `{{community.currency.primary_name}}` - `okf.file.*`: file-backed OKF frontmatter values, with legacy aliases such as `{{community.currency.primary_name}}`
API-backed platform statistics such as Twitch follower/subscriber counts or Each platform namespace exposes a public-safe `data_status` value. Current values are `live_cache`, `connected`, `configured_not_connected`, or `unavailable`, depending on the integration. Discord statistics read only the connected client's guild cache; placeholder rendering never triggers a Discord fetch.
future stream schedules should be registered here only after the integration has
a reliable server-side fetch/cache layer and clear sensitivity rules. Twitch follower/subscriber counts and Twitch/YouTube scheduled-start values remain intentionally unavailable. The current Twitch integration is chat-only, and the YouTube runtime does not maintain those statistics. Add them only after a background refresh service stores value, fetched-at time, expiry/stale state, and last error. Rendering must read that cache without a provider API call and return a safe unavailable/stale result when credentials or rate limits prevent refresh.
## Sensitivity ## Sensitivity
@ -91,9 +97,25 @@ Security is based on the intersection of:
- plugin availability - plugin availability
- runtime context - runtime context
Current trusted field policies:
| Field ID | Destination | Editor | Output | Allowed values |
| --- | --- | --- | --- | --- |
| `core.custom_commands.static_response` | Static and Random Reply custom-command replies | Moderator | User | Public-safe `core.main.*`, `core.command.*`, `custom.*`, and `user.public.*` |
| `plugin.throne_wishlist.message_template` | Throne event announcements | Admin | User | Public-safe Throne event placeholders |
| `okf.markdown` | SQLite/community OKF Markdown | OKF contributor | Selected role | Permission-filtered core, platform, and visible OKF values |
The current WebUI contains no inline JSON placeholder catalogs; double-brace editors use these server-owned IDs. Welcome Messages and Auto VC retain their older `{username}` and `[username]` runtime token formats for compatibility. They are not treated as generic placeholders until their event-context renderers and saved templates are migrated together; adding only autocomplete would advertise tokens those runtimes cannot safely resolve.
An admin editing a user-visible template is not enough to allow admin-only An admin editing a user-visible template is not enough to allow admin-only
placeholders. The output audience still limits what can render. placeholders. The output audience still limits what can render.
Custom values use the same catalog, validation, preview, and runtime checks as
code-registered placeholders. Values marked for moderators or admins do not
appear in lower-privilege catalogs and cannot render into user-visible command
replies. The `custom` namespace is reserved for this registry; plugins must use
their own `plugin.*` namespace.
## Plugin Registration ## Plugin Registration
Plugins should register placeholders during backend plugin initialization using Plugins should register placeholders during backend plugin initialization using
@ -130,3 +152,5 @@ Templates must be checked:
Unauthorized placeholders should fail closed. User-visible output should show a Unauthorized placeholders should fail closed. User-visible output should show a
generic unavailable marker or leave legacy unknown tokens unchanged, depending generic unavailable marker or leave legacy unknown tokens unchanged, depending
on the existing renderer contract, but it must not reveal the restricted value. on the existing renderer contract, but it must not reveal the restricted value.
Async resolvers are bounded by a short timeout (1.5 seconds by default). Duplicate and adjacent tokens are resolved by source position, and resolver output is inserted literally rather than scanned again. A failed, unavailable, forbidden, or timed-out value therefore affects only that token and renders the caller's safe fallback.

View File

@ -2,7 +2,9 @@
Failed updates should leave an administrator with a recovery path. Lumi writes a Failed updates should leave an administrator with a recovery path. Lumi writes a
recovery marker before update files are applied and keeps snapshots available for recovery marker before update files are applied and keeps snapshots available for
manual revert. manual revert. If an update throws after replacement begins, Lumi first attempts
to restore the new snapshot automatically. Failed automatic restoration keeps
the backup and records both errors for safe-mode recovery.
## Recovery Marker ## Recovery Marker
@ -50,5 +52,6 @@ Admins can:
- clear a stale marker after verifying startup, - clear a stale marker after verifying startup,
- retry normal startup. - retry normal startup.
Rollback is never automatic. Major-version rollback remains blocked unless the Manual major-version rollback remains blocked unless the snapshot is explicitly
snapshot is explicitly marked rollback safe. marked rollback safe. The automatic rollback attempted for the same failed
update is allowed because it restores the immediately preceding state.

View File

@ -61,10 +61,34 @@ Manual revert is available for core and individual plugins. Revert actions are
limited to the previous-version snapshot for that target. Major-version rollback limited to the previous-version snapshot for that target. Major-version rollback
is blocked unless the snapshot or manifest explicitly marks rollback as safe. is blocked unless the snapshot or manifest explicitly marks rollback as safe.
If a repository or ZIP update fails after file replacement begins, Lumi
automatically attempts to restore the snapshot. The failed recovery marker and
backup are retained when automatic restore cannot finish, so safe mode can show
the actionable error instead of silently deleting the recovery copy.
## Preserved Local Data
Core updates, ZIP updates, plugin updates, and restores leave these local areas
in place:
- `data/`, including the database, feedback files, AI models/runtimes, tool
settings, caches, and local exports;
- plugin-owned `data/` directories;
- `knowledge/community/` and `knowledge/corrections/`;
- files under generated knowledge folders unless they explicitly declare both
`generated: true` and `editable: false`;
- local configuration, storage, uploads, logs, secrets, environment files, and
the plugin directory during core-only updates.
Full core updates remove stale replaceable code before copying the new version.
Plugin code is prepared in a staging directory and swapped only after the new
files are ready; plugin data is moved into the replacement as part of that
transaction.
## ZIP Fallback ## ZIP Fallback
Core and plugin ZIP updates remain available, but they are hidden under Core and plugin ZIP updates remain available, but they are hidden under
**Show advanced ZIP update options**. ZIP updates create snapshots and recovery **Manual ZIP updates**. ZIP updates create snapshots and recovery
markers. They may bypass repo metadata and compatibility checks unless the ZIP markers. They may bypass repo metadata and compatibility checks unless the ZIP
contains valid manifest data, so use them as a manual fallback. contains valid manifest data, so use them as a manual fallback.

View File

@ -9,7 +9,6 @@ category: Core
tags: core, routes, commands, settings tags: core, routes, commands, settings
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# lumi-bot # lumi-bot
Lumi is the core web UI and bot runtime. Lumi is the core web UI and bot runtime.
@ -97,6 +96,8 @@ Version: 0.1.9
- POST /admin/feedback/export - POST /admin/feedback/export
- POST /admin/feedback/:id/export - POST /admin/feedback/:id/export
- POST /admin/feedback/:id/create-okf-correction - POST /admin/feedback/:id/create-okf-correction
- POST /admin/feedback/:id/merge
- POST /admin/feedback/:id/unmerge
- POST /admin/feedback/:id - POST /admin/feedback/:id
- POST /admin/feedback/:id/finalize - POST /admin/feedback/:id/finalize
- POST /admin/feedback/:id/reopen - POST /admin/feedback/:id/reopen
@ -213,7 +214,7 @@ Version: 0.1.9
- Inputs: file upload: multipart form file data; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service - Inputs: file upload: multipart form file data; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
- Response format: API response; exact schema was not detected statically. - Response format: API response; exact schema was not detected statically.
- Access: logged-in session required or used - Access: logged-in session required or used
- Side effects: writes or mutates server-side state; writes files - Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
### GET /api/feedback/notifications ### GET /api/feedback/notifications
@ -508,9 +509,9 @@ Version: 0.1.9
- Purpose: Handles feedback id attachments attachmentId. - Purpose: Handles feedback id attachments attachmentId.
- Inputs: path params: `attachmentId`, `id` - Inputs: path params: `attachmentId`, `id`
- Response format: static file response - Response format: file download
- Access: admin access expected; logged-in session required or used - Access: admin access expected; logged-in session required or used
- Side effects: writes or mutates server-side state - Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### GET /health ### GET /health
@ -831,7 +832,7 @@ Version: 0.1.9
### GET /admin/feedback ### GET /admin/feedback
- Purpose: Renders the admin feedback WebUI page. - Purpose: Renders the admin feedback WebUI page.
- Inputs: query: `area`, `category`, `date_from`, `date_to`, `needs_action`, `scope`, `severity`, `sort`, `status`, `submitter` - Inputs: query: `area`, `category`, `date_from`, `date_to`, `needs_action`, `plugin`, `route`, `scope`, `severity`, `sort`, `status`, `submitter`, `target`
- Response format: HTML page rendered from an EJS view - Response format: HTML page rendered from an EJS view
- Access: admin access expected - Access: admin access expected
- Side effects: Usually read-only. - Side effects: Usually read-only.
@ -864,6 +865,24 @@ Version: 0.1.9
- Side effects: writes or mutates server-side state - Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /admin/feedback/:id/merge
- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path.
- Inputs: path params: `id`; body: `target_feedback_id`
- Response format: HTTP redirect after handling the request
- Access: admin access expected; logged-in session required or used
- Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /admin/feedback/:id/unmerge
- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path.
- Inputs: path params: `id`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
- Response format: HTTP redirect after handling the request
- Access: admin access expected; logged-in session required or used
- Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /admin/feedback/:id ### POST /admin/feedback/:id
- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. - Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path.
@ -1255,18 +1274,18 @@ Version: 0.1.9
- Purpose: Processes the admin update action and stores or applies submitted form data. - Purpose: Processes the admin update action and stores or applies submitted form data.
- Inputs: No request parameters detected by static analysis. - Inputs: No request parameters detected by static analysis.
- Response format: HTTP redirect after handling the request - Response format: Form/action response; exact format was not detected statically.
- Access: admin access expected - Access: admin access expected
- Side effects: writes or mutates server-side state; may restart or stop runtime processes - Side effects: writes or mutates server-side state; publishes or streams live WebUI events
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /admin/check-update ### POST /admin/check-update
- Purpose: Processes the admin check update action and stores or applies submitted form data. - Purpose: Processes the admin check update action and stores or applies submitted form data.
- Inputs: No request parameters detected by static analysis. - Inputs: No request parameters detected by static analysis.
- Response format: HTTP redirect after handling the request - Response format: Form/action response; exact format was not detected statically.
- Access: admin access expected - Access: admin access expected
- Side effects: writes or mutates server-side state - Side effects: Action route; side effects were not detected statically.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /admin/restart ### POST /admin/restart
@ -1281,12 +1300,12 @@ Version: 0.1.9
# Lumi Bot # Lumi Bot
Discord bot + WebUI with role-based access, plugin management, and self-update support. Discord bot + WebUI with role-based access, plugin management, and self-update support.
## Quick start ## Quick start
Requires Node.js 18+. Requires Node.js 18+ on Windows or Linux.
1. Install dependencies: 1. Install dependencies:
``` ```
npm install npm install
``` ```
2. Run with auto-restart: Do not use `--ignore-scripts`: Lumi uses the native `better-sqlite3` module,
``` which must download or build a binary for your Node.js version. If Node was
npm run run upgraded after installation, run `npm rebuild better-sqlite3` or remove
``` `node_modules` and run `npm install` again. Linux source builds may also need

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, auto-vc tags: plugin, auto-vc
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# Auto VC # Auto VC
Auto-create managed voice channels from lobby rooms. Auto-create managed voice channels from lobby rooms.
@ -21,6 +20,7 @@ Default state: enabled
- /plugins/auto-vc - /plugins/auto-vc
- GET /plugins/auto-vc - GET /plugins/auto-vc
- POST /plugins/auto-vc/settings - POST /plugins/auto-vc/settings
- POST /plugins/auto-vc/settings/remove
- POST /plugins/auto-vc/bans - POST /plugins/auto-vc/bans
- POST /plugins/auto-vc/unban - POST /plugins/auto-vc/unban
## Route Reference ## Route Reference
@ -45,9 +45,18 @@ Default state: enabled
### POST /plugins/auto-vc/settings ### POST /plugins/auto-vc/settings
- Purpose: Processes the auto-vc plugin action for settings. - Purpose: Processes the auto-vc plugin action for settings.
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service - Inputs: No request parameters detected by static analysis.
- Response format: HTTP redirect after handling the request - Response format: Form/action response; exact format was not detected statically.
- Access: admin access expected; logged-in session required or used - Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
- Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /plugins/auto-vc/settings/remove
- Purpose: Processes the auto-vc plugin action for settings remove.
- Inputs: No request parameters detected by static analysis.
- Response format: Form/action response; exact format was not detected statically.
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
- Side effects: writes or mutates server-side state - Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, birthday tags: plugin, birthday
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# Birthday # Birthday
Birthday profiles, announcements, lookup commands, and optional birthday currency gifts. Birthday profiles, announcements, lookup commands, and optional birthday currency gifts.

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, economy-framework tags: plugin, economy-framework
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# Economy Framework # Economy Framework
Cross-platform currency framework with shared balances and extensible hooks. Cross-platform currency framework with shared balances and extensible hooks.

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, economy-games tags: plugin, economy-games
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# Economy Games # Economy Games
Cross-platform mini-games that use the Economy currency framework. Cross-platform mini-games that use the Economy currency framework.

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, expression-interaction tags: plugin, expression-interaction
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# Expression Interaction # Expression Interaction
Express yourself through interactions with other users, such as hugging, bonking, comforting, etc Express yourself through interactions with other users, such as hugging, bonking, comforting, etc

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, lumi_ai tags: plugin, lumi_ai
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# Lumi AI # Lumi AI
Managed local AI provider and scoped WebUI assistant for Lumi. Managed local AI provider and scoped WebUI assistant for Lumi.
@ -39,6 +38,7 @@ Default state: enabled
- POST /plugins/lumi_ai/logs/:name/delete - POST /plugins/lumi_ai/logs/:name/delete
- POST /plugins/lumi_ai/runtime/:action - POST /plugins/lumi_ai/runtime/:action
- GET /plugins/lumi_ai/diagnostics/download - GET /plugins/lumi_ai/diagnostics/download
- GET /plugins/lumi_ai/work-history/:id/export
- POST /plugins/lumi_ai/assistant/message - POST /plugins/lumi_ai/assistant/message
- POST /plugins/lumi_ai/assistant/feedback - POST /plugins/lumi_ai/assistant/feedback
- GET /plugins/lumi_ai/assistant/jobs/:id - GET /plugins/lumi_ai/assistant/jobs/:id
@ -61,9 +61,12 @@ Default state: enabled
- GET /plugins/lumi_ai/improvement_center - GET /plugins/lumi_ai/improvement_center
- POST /plugins/lumi_ai/improvement_center/settings - POST /plugins/lumi_ai/improvement_center/settings
- POST /plugins/lumi_ai/improvement_center/reviews/:id - POST /plugins/lumi_ai/improvement_center/reviews/:id
- POST /plugins/lumi_ai/improvement_center/reviews/:id/delete
- POST /plugins/lumi_ai/improvement_center/reviews/:id/archive
- POST /plugins/lumi_ai/improvement_center/reviews/:id/implement - POST /plugins/lumi_ai/improvement_center/reviews/:id/implement
- POST /plugins/lumi_ai/improvement_center/corrections/save - POST /plugins/lumi_ai/improvement_center/corrections/save
- POST /plugins/lumi_ai/improvement_center/corrections/:id - POST /plugins/lumi_ai/improvement_center/corrections/:id
- POST /plugins/lumi_ai/improvement_center/corrections/:id/delete
- POST /plugins/lumi_ai/improvement_center/evals - POST /plugins/lumi_ai/improvement_center/evals
- POST /plugins/lumi_ai/improvement_center/evals/:id/delete - POST /plugins/lumi_ai/improvement_center/evals/:id/delete
- POST /plugins/lumi_ai/improvement_center/evals/run - POST /plugins/lumi_ai/improvement_center/evals/run
@ -258,6 +261,15 @@ Default state: enabled
- Side effects: writes or mutates server-side state - Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### GET /plugins/lumi_ai/work-history/:id/export
- Purpose: Renders or serves the lumi_ai plugin page for work history id export.
- Inputs: path params: `id`
- Response format: plain or HTML response
- Access: admin access expected; logged-in session required or used
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler.
### POST /plugins/lumi_ai/assistant/message ### POST /plugins/lumi_ai/assistant/message
- Purpose: Processes the lumi_ai plugin action for assistant message. - Purpose: Processes the lumi_ai plugin action for assistant message.
@ -270,7 +282,7 @@ Default state: enabled
### POST /plugins/lumi_ai/assistant/feedback ### POST /plugins/lumi_ai/assistant/feedback
- Purpose: Processes the lumi_ai plugin action for assistant feedback. - Purpose: Processes the lumi_ai plugin action for assistant feedback.
- Inputs: body: `assistant_answer`, `feedback_kind`, `feedback_tag`, `model`, `optional_correction`, `route_used`, `timestamp`, `user_message` - Inputs: body: `assistant_answer`, `context_snapshot`, `feedback_kind`, `feedback_tag`, `model`, `optional_correction`, `rating`, `route_used`, `timestamp`, `user_message`
- Response format: Form/action response; exact format was not detected statically. - Response format: Form/action response; exact format was not detected statically.
- Access: logged-in session required or used - Access: logged-in session required or used
- Side effects: Consumes submitted data; state mutation happens in called helpers if present. - Side effects: Consumes submitted data; state mutation happens in called helpers if present.
@ -456,6 +468,24 @@ Default state: enabled
- Side effects: writes or mutates server-side state - Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /plugins/lumi_ai/improvement_center/reviews/:id/delete
- Purpose: Processes the lumi_ai plugin action for improvement center reviews id delete.
- Inputs: path params: `id`
- Response format: Form/action response; exact format was not detected statically.
- Access: logged-in session required or used
- Side effects: Action route; side effects were not detected statically.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /plugins/lumi_ai/improvement_center/reviews/:id/archive
- Purpose: Processes the lumi_ai plugin action for improvement center reviews id archive.
- Inputs: path params: `id`; body: `review_notes`
- Response format: Form/action response; exact format was not detected statically.
- Access: logged-in session required or used
- Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /plugins/lumi_ai/improvement_center/reviews/:id/implement ### POST /plugins/lumi_ai/improvement_center/reviews/:id/implement
- Purpose: Processes the lumi_ai plugin action for improvement center reviews id implement. - Purpose: Processes the lumi_ai plugin action for improvement center reviews id implement.
@ -483,6 +513,15 @@ Default state: enabled
- Side effects: writes or mutates server-side state - Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /plugins/lumi_ai/improvement_center/corrections/:id/delete
- Purpose: Processes the lumi_ai plugin action for improvement center corrections id delete.
- Inputs: path params: `id`
- Response format: Form/action response; exact format was not detected statically.
- Access: logged-in session required or used
- Side effects: Action route; side effects were not detected statically.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /plugins/lumi_ai/improvement_center/evals ### POST /plugins/lumi_ai/improvement_center/evals
- Purpose: Processes the lumi_ai plugin action for improvement center evals. - Purpose: Processes the lumi_ai plugin action for improvement center evals.

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, moderation tags: plugin, moderation
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# Moderation Center # Moderation Center
Cross-platform moderation actions, notes, and sanctions. Cross-platform moderation actions, notes, and sanctions.

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, okf tags: plugin, okf
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# OKF Knowledge # OKF Knowledge
Role-gated knowledge, facts, and Q&A entries for Lumi communities. Role-gated knowledge, facts, and Q&A entries for Lumi communities.
@ -21,6 +20,7 @@ Default state: enabled
- /plugins/okf - /plugins/okf
- GET /plugins/okf - GET /plugins/okf
- GET /plugins/okf/admin - GET /plugins/okf/admin
- POST /plugins/okf/admin/preview
- POST /plugins/okf/admin/community - POST /plugins/okf/admin/community
- POST /plugins/okf/admin/community/:slug - POST /plugins/okf/admin/community/:slug
- POST /plugins/okf/admin/entries - POST /plugins/okf/admin/entries
@ -58,6 +58,15 @@ Default state: enabled
- Side effects: Usually read-only. - Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### POST /plugins/okf/admin/preview
- Purpose: Processes the okf plugin administration action for preview.
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
- Response format: JSON response
- Access: OKF editor or manager permission required; admin access expected
- Side effects: Action route; side effects were not detected statically.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /plugins/okf/admin/community ### POST /plugins/okf/admin/community
- Purpose: Processes the okf plugin administration action for community. - Purpose: Processes the okf plugin administration action for community.

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, quotes tags: plugin, quotes
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# Quotes # Quotes
Store, search, and manage community quotes. Store, search, and manage community quotes.

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, sample-plugin tags: plugin, sample-plugin
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# Sample Plugin # Sample Plugin
Example plugin with a simple page. Example plugin with a simple page.

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, throne_wishlist tags: plugin, throne_wishlist
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# Throne Wishlist # Throne Wishlist
Throne wishlist webhook integration with verified payloads, debug viewer, and cross-platform event messages. Throne wishlist webhook integration with verified payloads, debug viewer, and cross-platform event messages.

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, welcome_messages tags: plugin, welcome_messages
generated: true generated: true
editable: false editable: false
updated_at: "2026-06-25T12:06:11.231Z"
--- ---
# Welcome Messages # Welcome Messages
Randomized Discord welcome and welcome-back messages with safe pronoun preferences. Randomized Discord welcome and welcome-back messages with safe pronoun preferences.

282
package-lock.json generated
View File

@ -15,7 +15,8 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.19.2", "express": "^4.19.2",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"multer": "^1.4.5-lts.1", "multer": "^2.2.0",
"obs-websocket-js": "^5.0.8",
"tmi.js": "^1.8.5" "tmi.js": "^1.8.5"
}, },
"engines": { "engines": {
@ -55,6 +56,15 @@
"node": ">=16.9.0" "node": ">=16.9.0"
} }
}, },
"node_modules/@msgpack/msgpack": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz",
"integrity": "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==",
"license": "ISC",
"engines": {
"node": ">= 10"
}
},
"node_modules/@sapphire/async-queue": { "node_modules/@sapphire/async-queue": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@ -219,9 +229,9 @@
} }
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "~3.1.2", "bytes": "~3.1.2",
@ -232,7 +242,7 @@
"http-errors": "~2.0.1", "http-errors": "~2.0.1",
"iconv-lite": "~0.4.24", "iconv-lite": "~0.4.24",
"on-finished": "~2.4.1", "on-finished": "~2.4.1",
"qs": "~6.14.0", "qs": "~6.15.1",
"raw-body": "~2.5.3", "raw-body": "~2.5.3",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"unpipe": "~1.0.0" "unpipe": "~1.0.0"
@ -243,9 +253,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@ -349,50 +359,20 @@
} }
}, },
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "1.6.2", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [ "engines": [
"node >= 0.8" "node >= 6.0"
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"buffer-from": "^1.0.0", "buffer-from": "^1.0.0",
"inherits": "^2.0.3", "inherits": "^2.0.3",
"readable-stream": "^2.2.2", "readable-stream": "^3.0.2",
"typedarray": "^0.0.6" "typedarray": "^0.0.6"
} }
}, },
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/concat-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/concat-stream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -429,10 +409,10 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-util-is": { "node_modules/crypto-js": {
"version": "1.0.3", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/date-fns": { "node_modules/date-fns": {
@ -659,6 +639,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expand-template": { "node_modules/expand-template": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@ -669,14 +655,14 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
"body-parser": "~1.20.3", "body-parser": "~1.20.5",
"content-disposition": "~0.5.4", "content-disposition": "~0.5.4",
"content-type": "~1.0.4", "content-type": "~1.0.4",
"cookie": "~0.7.1", "cookie": "~0.7.1",
@ -695,7 +681,7 @@
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12", "path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "~6.14.0", "qs": "~6.15.1",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
"send": "~0.19.0", "send": "~0.19.0",
@ -773,16 +759,16 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@ -904,9 +890,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@ -988,11 +974,14 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/isarray": { "node_modules/isomorphic-ws": {
"version": "1.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
"license": "MIT" "license": "MIT",
"peerDependencies": {
"ws": "*"
}
}, },
"node_modules/jake": { "node_modules/jake": {
"version": "10.9.4", "version": "10.9.4",
@ -1012,9 +1001,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
@ -1099,9 +1088,9 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "5.1.6", "version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.1"
@ -1119,18 +1108,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mkdirp-classic": { "node_modules/mkdirp-classic": {
"version": "0.5.3", "version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@ -1144,22 +1121,22 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "1.4.5-lts.2", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
"busboy": "^1.0.0", "busboy": "^1.6.0",
"concat-stream": "^1.5.2", "concat-stream": "^2.0.0",
"mkdirp": "^0.5.4", "type-is": "^1.6.18"
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
}, },
"engines": { "engines": {
"node": ">= 6.0.0" "node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/napi-build-utils": { "node_modules/napi-build-utils": {
@ -1209,15 +1186,6 @@
} }
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -1230,6 +1198,47 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/obs-websocket-js": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/obs-websocket-js/-/obs-websocket-js-5.0.8.tgz",
"integrity": "sha512-QDnQJMr5wuCoYugK02ggZ1/cvESs4KJDEK+UhGg0Ry35jnY8tK1Xr3KoPjKyoD6sd8G+WdtIdzuY+yUyPtogQQ==",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^2.7.1",
"crypto-js": "^4.1.1",
"debug": "^4.3.2",
"eventemitter3": "^5.0.1",
"isomorphic-ws": "^5.0.0",
"type-fest": "^3.11.0",
"ws": "^8.13.0"
},
"engines": {
"node": ">16.0"
}
},
"node_modules/obs-websocket-js/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/obs-websocket-js/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -1270,9 +1279,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/picocolors": { "node_modules/picocolors": {
@ -1307,12 +1316,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -1337,12 +1340,13 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.1", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"
@ -1503,14 +1507,14 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3", "object-inspect": "^1.13.4",
"side-channel-list": "^1.0.0", "side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1", "side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2" "side-channel-weakmap": "^1.0.2"
}, },
@ -1522,13 +1526,13 @@
} }
}, },
"node_modules/side-channel-list": { "node_modules/side-channel-list": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3" "object-inspect": "^1.13.4"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -1734,6 +1738,18 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/type-fest": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
"integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@ -1827,10 +1843,11 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.19.0", "version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
}, },
@ -1846,15 +1863,6 @@
"optional": true "optional": true
} }
} }
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
} }
} }
} }

View File

@ -6,7 +6,15 @@
"scripts": { "scripts": {
"start": "node src/main.js", "start": "node src/main.js",
"run": "node run.js", "run": "node run.js",
"verify:webui": "node scripts/verify-webui.js" "verify:all": "node scripts/verify-all.js",
"verify:preflight": "node scripts/verify-preflight.js",
"verify:safe-files": "node scripts/verify-safe-files.js",
"verify:uploads": "node scripts/verify-upload-security.js",
"verify:web-auth": "node scripts/verify-web-auth.js",
"verify:destructive-actions": "node scripts/verify-destructive-actions.js",
"verify:overlays": "node scripts/verify-overlays.js",
"verify:webui": "node scripts/verify-webui.js && node scripts/verify-destructive-actions.js",
"benchmark:okf": "node scripts/benchmark-okf-search.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -19,7 +27,8 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.19.2", "express": "^4.19.2",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"multer": "^1.4.5-lts.1", "multer": "^2.2.0",
"obs-websocket-js": "^5.0.8",
"tmi.js": "^1.8.5" "tmi.js": "^1.8.5"
} }
} }

View File

@ -54,7 +54,7 @@ module.exports = {
res.send(html); res.send(html);
}); });
router.post("/settings", (req, res) => { const saveSettings = (req, res) => {
if (!req.session.user || !req.session.user.isAdmin) { if (!req.session.user || !req.session.user.isAdmin) {
return res.status(403).render("error", { return res.status(403).render("error", {
title: "Access denied", title: "Access denied",
@ -69,7 +69,10 @@ module.exports = {
message: "Auto VC settings saved." message: "Auto VC settings saved."
}; };
res.redirect("/plugins/auto-vc"); res.redirect("/plugins/auto-vc");
}); };
router.post("/settings", saveSettings);
router.post("/settings/remove", saveSettings);
router.post("/bans", (req, res) => { router.post("/bans", (req, res) => {
if (!req.session.user || !(req.session.user.isAdmin || req.session.user.isMod)) { if (!req.session.user || !(req.session.user.isAdmin || req.session.user.isMod)) {

View File

@ -164,7 +164,7 @@
<% if (isAdmin) { %> <% if (isAdmin) { %>
<section class="card"> <section class="card">
<h2>Lobby setup</h2> <h2>Lobby setup</h2>
<form method="post" action="/plugins/auto-vc/settings" class="form-grid"> <form method="post" action="/plugins/auto-vc/settings" class="form-grid" data-auto-vc-settings data-confirm-mode="modal" data-confirm-title="Delete lobby" data-confirm-text="Save these settings and permanently delete the selected lobby configuration?" data-confirm-label="Delete lobby">
<div class="card"> <div class="card">
<h3>Rate limits</h3> <h3>Rate limits</h3>
<div class="form-grid rate-limit-grid"> <div class="form-grid rate-limit-grid">
@ -371,11 +371,11 @@
<div class="modal" id="delete-lobby-modal" aria-hidden="true"> <div class="modal" id="delete-lobby-modal" aria-hidden="true">
<div class="modal-backdrop" data-modal-close></div> <div class="modal-backdrop" data-modal-close></div>
<div class="modal-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-lobby-title"> <div class="modal-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-lobby-title">
<h3 id="delete-lobby-title">Delete lobby?</h3> <h3 id="delete-lobby-title">Mark lobby for deletion?</h3>
<p>This removes the lobby configuration and stops auto-creating rooms from it.</p> <p>The lobby is removed only after you save the settings and complete the safety timer.</p>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="button subtle" data-modal-cancel>Cancel</button> <button type="button" class="button subtle" data-modal-cancel>Cancel</button>
<button type="button" class="button danger" data-modal-confirm>Delete lobby</button> <button type="button" class="button danger" data-modal-confirm>Mark for deletion</button>
</div> </div>
</div> </div>
</div> </div>
@ -437,6 +437,7 @@
const addButton = document.getElementById("add-lobby"); const addButton = document.getElementById("add-lobby");
const container = document.getElementById("lobby-sections"); const container = document.getElementById("lobby-sections");
const template = document.getElementById("lobby-template"); const template = document.getElementById("lobby-template");
const settingsForm = document.querySelector("[data-auto-vc-settings]");
if (!addButton || !container || !template) { if (!addButton || !container || !template) {
return; return;
} }
@ -492,6 +493,7 @@
field.disabled = true; field.disabled = true;
}); });
updateHeaders(); updateHeaders();
if (settingsForm) settingsForm.action = "/plugins/auto-vc/settings/remove";
}; };
const openModal = (card) => { const openModal = (card) => {

View File

@ -13,7 +13,7 @@
<tr><th>Discord client</th><td><%= diagnostics.discordAvailable ? (diagnostics.discordReady ? "Ready" : "Available") : "Unavailable" %></td></tr> <tr><th>Discord client</th><td><%= diagnostics.discordAvailable ? (diagnostics.discordReady ? "Ready" : "Available") : "Unavailable" %></td></tr>
<tr><th>Announcement channel</th><td><%= diagnostics.channel.message %></td></tr> <tr><th>Announcement channel</th><td><%= diagnostics.channel.message %></td></tr>
<tr><th>Economy</th><td><%= diagnostics.economyAvailable ? "Available" : "Unavailable" %></td></tr> <tr><th>Economy</th><td><%= diagnostics.economyAvailable ? "Available" : "Unavailable" %></td></tr>
<tr><th>Current plugin date</th><td><%= diagnostics.currentDate.year %>-<%= String(diagnostics.currentDate.month).padStart(2, "0") %>-<%= String(diagnostics.currentDate.day).padStart(2, "0") %></td></tr> <tr><th>Today in the selected timezone</th><td><%= diagnostics.currentDate.year %>-<%= String(diagnostics.currentDate.month).padStart(2, "0") %>-<%= String(diagnostics.currentDate.day).padStart(2, "0") %></td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -37,8 +37,8 @@
<div class="field"> <div class="field">
<label>Gift mode</label> <label>Gift mode</label>
<select name="gift_mode"> <select name="gift_mode">
<option value="automatic" <%= config.gift_mode === "automatic" ? "selected" : "" %>>automatic</option> <option value="automatic" <%= config.gift_mode === "automatic" ? "selected" : "" %>>Send automatically</option>
<option value="manual" <%= config.gift_mode === "manual" ? "selected" : "" %>>manual</option> <option value="manual" <%= config.gift_mode === "manual" ? "selected" : "" %>>Staff sends manually</option>
</select> </select>
</div> </div>
<div class="field"> <div class="field">
@ -53,8 +53,8 @@
<div class="field"> <div class="field">
<label>Feb 29 handling</label> <label>Feb 29 handling</label>
<select name="leap_day_policy"> <select name="leap_day_policy">
<option value="feb28" <%= config.leap_day_policy === "feb28" ? "selected" : "" %>>feb28</option> <option value="feb28" <%= config.leap_day_policy === "feb28" ? "selected" : "" %>>Celebrate on February 28</option>
<option value="mar1" <%= config.leap_day_policy === "mar1" ? "selected" : "" %>>mar1</option> <option value="mar1" <%= config.leap_day_policy === "mar1" ? "selected" : "" %>>Celebrate on March 1</option>
</select> </select>
</div> </div>
<div class="field full"> <div class="field full">
@ -69,7 +69,7 @@
<% } else { %> <% } else { %>
<input name="announcement_channel_id" value="<%= config.announcement_channel_id %>" placeholder="Discord text channel ID" /> <input name="announcement_channel_id" value="<%= config.announcement_channel_id %>" placeholder="Discord text channel ID" />
<% } %> <% } %>
<p class="hint">Only regular guild text channels are accepted.</p> <p class="hint">Choose a regular text channel from this Discord server.</p>
</div> </div>
<div class="field full"> <div class="field full">
<button type="submit" class="button">Save settings</button> <button type="submit" class="button">Save settings</button>
@ -109,7 +109,7 @@
</label> </label>
</div> </div>
<div class="field profile-actions"> <div class="field profile-actions">
<button type="submit" class="button subtle">Save</button> <button type="submit" class="button subtle">Save message</button>
</div> </div>
</form> </form>
<div class="profile-actions"> <div class="profile-actions">
@ -117,9 +117,9 @@
<input type="hidden" name="pool" value="fullYear" /> <input type="hidden" name="pool" value="fullYear" />
<button type="submit" class="button subtle">Duplicate</button> <button type="submit" class="button subtle">Duplicate</button>
</form> </form>
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/remove"> <form method="post" action="/plugins/birthday/templates/<%= message.id %>/remove" data-confirm-mode="modal" data-confirm-title="Remove birthday template" data-confirm-text="Remove this birthday message template?" data-confirm-label="Remove template">
<input type="hidden" name="pool" value="fullYear" /> <input type="hidden" name="pool" value="fullYear" />
<button type="submit" class="button subtle">Remove</button> <button type="submit" class="button danger">Remove</button>
</form> </form>
</div> </div>
<% }) %> <% }) %>
@ -158,7 +158,7 @@
</label> </label>
</div> </div>
<div class="field profile-actions"> <div class="field profile-actions">
<button type="submit" class="button subtle">Save</button> <button type="submit" class="button subtle">Save message</button>
</div> </div>
</form> </form>
<div class="profile-actions"> <div class="profile-actions">
@ -166,9 +166,9 @@
<input type="hidden" name="pool" value="partialYear" /> <input type="hidden" name="pool" value="partialYear" />
<button type="submit" class="button subtle">Duplicate</button> <button type="submit" class="button subtle">Duplicate</button>
</form> </form>
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/remove"> <form method="post" action="/plugins/birthday/templates/<%= message.id %>/remove" data-confirm-mode="modal" data-confirm-title="Remove birthday template" data-confirm-text="Remove this birthday message template?" data-confirm-label="Remove template">
<input type="hidden" name="pool" value="partialYear" /> <input type="hidden" name="pool" value="partialYear" />
<button type="submit" class="button subtle">Remove</button> <button type="submit" class="button danger">Remove</button>
</form> </form>
</div> </div>
<% }) %> <% }) %>

View File

@ -22,8 +22,8 @@
</div> </div>
</form> </form>
<% if (birthday) { %> <% if (birthday) { %>
<form method="post" action="/plugins/birthday/profile/unset"> <form method="post" action="/plugins/birthday/profile/unset" data-confirm-mode="modal" data-confirm-title="Remove birthday" data-confirm-text="Remove your saved birthday?" data-confirm-label="Remove birthday">
<button type="submit" class="button subtle">Remove birthday</button> <button type="submit" class="button danger">Remove birthday</button>
</form> </form>
<% } %> <% } %>
<% } else { %> <% } else { %>

View File

@ -6,6 +6,10 @@ const express = require("express");
const multer = require("multer"); const multer = require("multer");
const EventEmitter = require("events"); const EventEmitter = require("events");
const { ensureUserForIdentity } = require("../../src/services/users"); const { ensureUserForIdentity } = require("../../src/services/users");
const {
cleanupUploadedFiles,
validateUploadedFile
} = require("../../src/services/upload-security");
const PLUGIN_ID = "economy-framework"; const PLUGIN_ID = "economy-framework";
const LEGACY_STEM = ["echo", "nomy"].join(""); const LEGACY_STEM = ["echo", "nomy"].join("");
@ -218,6 +222,7 @@ module.exports = {
fs.mkdirSync(uploadDir, { recursive: true }); fs.mkdirSync(uploadDir, { recursive: true });
const upload = multer({ const upload = multer({
dest: uploadDir, dest: uploadDir,
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 10, parts: 12 },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
if (file.mimetype === "image/png") { if (file.mimetype === "image/png") {
return cb(null, true); return cb(null, true);
@ -225,6 +230,31 @@ module.exports = {
cb(new Error("Only PNG files are allowed.")); cb(new Error("Only PNG files are allowed."));
} }
}); });
const currencyIconUpload = (req, res, next) => upload.single("currency_icon")(req, res, (error) => {
if (error) {
cleanupUploadedFiles(req.file);
req.file = null;
req.currencyIconUploadError = error.code === "LIMIT_FILE_SIZE"
? "Currency icons must be 2 MB or smaller."
: error.message;
return next();
}
try {
if (req.file) {
validateUploadedFile(req.file, {
allowedTypes: ["png"],
maxBytes: 2 * 1024 * 1024,
requireExtension: true,
label: "currency icon"
});
}
} catch (validationError) {
cleanupUploadedFiles(req.file);
req.file = null;
req.currencyIconUploadError = validationError.message;
}
next();
});
const router = web.createRouter(); const router = web.createRouter();
router.use("/assets", express.static(uploadDir)); router.use("/assets", express.static(uploadDir));
@ -464,18 +494,18 @@ module.exports = {
res.redirect(`/plugins/${PLUGIN_ID}`); res.redirect(`/plugins/${PLUGIN_ID}`);
}); });
router.post("/settings/icon", upload.single("currency_icon"), (req, res) => { router.post("/settings/icon", (req, res, next) => {
if (!req.session.user?.isAdmin) { if (!req.session.user?.isAdmin) {
return deny(res); return deny(res);
} }
if (!req.file) { next();
req.session.flash = { type: "error", message: "Upload a PNG icon." }; }, currencyIconUpload, (req, res) => {
if (req.currencyIconUploadError) {
req.session.flash = { type: "error", message: req.currencyIconUploadError };
return res.redirect(`/plugins/${PLUGIN_ID}`); return res.redirect(`/plugins/${PLUGIN_ID}`);
} }
const ext = path.extname(req.file.originalname || "").toLowerCase(); if (!req.file) {
if (ext && ext !== ".png") { req.session.flash = { type: "error", message: "Upload a PNG icon." };
fs.rmSync(req.file.path, { force: true });
req.session.flash = { type: "error", message: "Only PNG files are allowed." };
return res.redirect(`/plugins/${PLUGIN_ID}`); return res.redirect(`/plugins/${PLUGIN_ID}`);
} }
const filename = `currency-${Date.now()}-${crypto.randomUUID()}.png`; const filename = `currency-${Date.now()}-${crypto.randomUUID()}.png`;

View File

@ -203,7 +203,7 @@
<div class="field"> <div class="field">
<label>Upload PNG icon</label> <label>Upload PNG icon</label>
<input type="file" name="currency_icon" accept="image/png" /> <input type="file" name="currency_icon" accept="image/png" />
<span class="hint">PNG only. Used across the WebUI.</span> <span class="hint">PNG only, up to 2 MB. Used across the WebUI.</span>
</div> </div>
<button type="submit" class="button">Upload icon</button> <button type="submit" class="button">Upload icon</button>
</form> </form>
@ -454,8 +454,8 @@
<% events.forEach((event) => { %> <% events.forEach((event) => { %>
<li> <li>
<span><strong><%= event.name %></strong> (<%= event.amount %>)</span> <span><strong><%= event.name %></strong> (<%= event.amount %>)</span>
<form method="post" action="/plugins/economy-framework/events/<%= event.id %>/delete"> <form method="post" action="/plugins/economy-framework/events/<%= event.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete event reward" data-confirm-text="Delete this event reward?" data-confirm-label="Delete reward">
<button type="submit" class="button subtle">Delete</button> <button type="submit" class="button danger">Delete</button>
</form> </form>
</li> </li>
<% }) %> <% }) %>
@ -523,7 +523,7 @@
<button type="button" class="button subtle" data-response-add>Add response</button> <button type="button" class="button subtle" data-response-add>Add response</button>
<div class="response-note">Weights are used only when "Weighted" is selected.</div> <div class="response-note">Weights are used only when "Weighted" is selected.</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="submit" class="button">Save</button> <button type="submit" class="button">Save responses</button>
</div> </div>
</form> </form>
</div> </div>

View File

@ -196,7 +196,7 @@
<button type="submit" class="button">Restore</button> <button type="submit" class="button">Restore</button>
</form> </form>
<% } else { %> <% } else { %>
<form method="post" action="/plugins/expression-interaction/actions/<%= action.id %>/archive" class="inline-form"> <form method="post" action="/plugins/expression-interaction/actions/<%= action.id %>/archive" class="inline-form" data-confirm-mode="modal" data-confirm-title="Archive expression" data-confirm-text="Archive this expression interaction?" data-confirm-label="Archive expression">
<button type="submit" class="button danger">Archive</button> <button type="submit" class="button danger">Archive</button>
</form> </form>
<% } %> <% } %>
@ -244,7 +244,7 @@
</label> </label>
</div> </div>
<div class="field full"> <div class="field full">
<button type="submit" class="button">Save</button> <button type="submit" class="button">Save expression</button>
</div> </div>
</form> </form>
</td> </td>

View File

@ -522,6 +522,15 @@ class AiProvider {
force_through_reason: gateDecision?.forced ? gateDecision.reason_code : null, force_through_reason: gateDecision?.forced ? gateDecision.reason_code : null,
internal_generated_length: initialText.length + String(finalText || "").length, internal_generated_length: initialText.length + String(finalText || "").length,
fallback_reason: fallbackReason, fallback_reason: fallbackReason,
feedback_context_snapshot: buildFeedbackContextSnapshot({
contextBlocks,
correctionContext,
repoContext,
contextDiagnostics,
controllerDecision,
exposedTools: toolExposure.exposed,
selectedTool
}),
stage_timings: { stage_timings: {
deterministic_ms: gateDecision?.deterministic_ms || 0, deterministic_ms: gateDecision?.deterministic_ms || 0,
gate_ms: gateDecision?.gate_ms || 0, gate_ms: gateDecision?.gate_ms || 0,
@ -841,6 +850,49 @@ function redactPrompt(value) {
.slice(0, 6000); .slice(0, 6000);
} }
function buildFeedbackContextSnapshot({
contextBlocks = [],
correctionContext = [],
repoContext = [],
contextDiagnostics = [],
controllerDecision = {},
exposedTools = [],
selectedTool = null
} = {}) {
const retrieval = summarizeContextDiagnostics(contextDiagnostics);
return {
retrieval: {
query: safeFeedbackText(retrieval.query, 500),
candidate_count: retrieval.candidate_count,
selected_count: Array.isArray(contextBlocks) ? contextBlocks.length : 0,
depth: String(controllerDecision.okf_retrieval || "none").slice(0, 40)
},
okf: safeFeedbackList(contextBlocks, 4),
corrections: safeFeedbackList(correctionContext, 3),
repository: safeFeedbackList(repoContext, 3),
tools: [...new Set([
...exposedTools.map((tool) => tool?.tool_id || tool?.id).filter(Boolean),
selectedTool
].filter(Boolean).map((value) => String(value).slice(0, 120)))].slice(0, 20)
};
}
function safeFeedbackList(values, limit) {
return (Array.isArray(values) ? values : [])
.slice(0, limit)
.map((value) => safeFeedbackText(typeof value === "string" ? value : JSON.stringify(value), 1200))
.filter(Boolean);
}
function safeFeedbackText(value, limit) {
const cwd = process.cwd();
return redactPrompt(value)
.replaceAll(cwd, "[repo]")
.replaceAll(cwd.replaceAll("\\", "/"), "[repo]")
.replace(/\b[A-Za-z]:[\\/][^\r\n"']+/g, "[local path]")
.slice(0, limit);
}
module.exports = { module.exports = {
AiProvider, AiProvider,
isInScope, isInScope,
@ -849,5 +901,6 @@ module.exports = {
isExactHelpShortcut, isExactHelpShortcut,
normalizeHistory, normalizeHistory,
normalizeInferenceDiagnostics, normalizeInferenceDiagnostics,
resolveOutputBudget resolveOutputBudget,
buildFeedbackContextSnapshot
}; };

View File

@ -1,6 +1,7 @@
const fs = require("fs"); const fs = require("fs");
const crypto = require("crypto"); const crypto = require("crypto");
const { resolveData } = require("./paths"); const { resolveData } = require("./paths");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
class SafeAnswerCache { class SafeAnswerCache {
constructor(getConfig) { constructor(getConfig) {
@ -57,13 +58,7 @@ class SafeAnswerCache {
} }
write(store) { write(store) {
const tmp = `${this.file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`; writeJsonAtomicSync(this.file, store);
try {
fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`);
fs.renameSync(tmp, this.file);
} finally {
fs.rmSync(tmp, { force: true });
}
} }
} }

View File

@ -1,4 +1,5 @@
const fs = require("fs"); const fs = require("fs");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
const { resolveData, ensureDataDirs } = require("./paths"); const { resolveData, ensureDataDirs } = require("./paths");
const { DEFAULT_SCOPE, normalizeScope } = require("./scope_manager"); const { DEFAULT_SCOPE, normalizeScope } = require("./scope_manager");
const { DEFAULT_RATE_LIMITS, mergeLimits } = require("./rate_limits"); const { DEFAULT_RATE_LIMITS, mergeLimits } = require("./rate_limits");
@ -99,9 +100,7 @@ function readJson(name, fallback) {
} }
function writeJson(name, value) { function writeJson(name, value) {
const file = resolveData("config", name); const file = resolveData("config", name);
const tmp = `${file}.tmp`; writeJsonAtomicSync(file, value);
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
fs.renameSync(tmp, file);
} }
function getConfig() { function getConfig() {
const config = readJson("ai_config.json", DEFAULT_CONFIG); const config = readJson("ai_config.json", DEFAULT_CONFIG);

View File

@ -58,7 +58,8 @@ class CorrectionStore {
verified_at: null, verified_at: null,
active: false, active: false,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString() updated_at: new Date().toISOString(),
linked_okf_path: null
}; };
const store = this.read(); const store = this.read();
store.entries.unshift(entry); store.entries.unshift(entry);
@ -142,6 +143,14 @@ class CorrectionStore {
return { total: store.entries.length, active }; return { total: store.entries.length, active };
} }
linkOkf(id, correctionPath) {
return this.mutate(id, (entry) => ({
...entry,
linked_okf_path: clean(correctionPath, 1000),
updated_at: new Date().toISOString()
}));
}
match(input, limit = 4) { match(input, limit = 4) {
if (this.getConfig()?.improvement?.corrections_enabled === false) return []; if (this.getConfig()?.improvement?.corrections_enabled === false) return [];
const role = normalizeRole(input.role); const role = normalizeRole(input.role);

View File

@ -2,6 +2,7 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const AdmZip = require("adm-zip"); const AdmZip = require("adm-zip");
const { resolveData, ensureDataDirs } = require("./paths"); const { resolveData, ensureDataDirs } = require("./paths");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
function redact(value) { function redact(value) {
if (Array.isArray(value)) return value.map(redact); if (Array.isArray(value)) return value.map(redact);
@ -26,7 +27,7 @@ function createDiagnostic(input) {
function persistDiagnostic(input) { function persistDiagnostic(input) {
ensureDataDirs(); ensureDataDirs();
const diagnostic = createDiagnostic(input); const diagnostic = createDiagnostic(input);
fs.writeFileSync(resolveData("diagnostics", "latest_runtime_diagnostic.json"), `${JSON.stringify(diagnostic, null, 2)}\n`); writeJsonAtomicSync(resolveData("diagnostics", "latest_runtime_diagnostic.json"), diagnostic);
fs.appendFileSync(resolveData("diagnostics", "runtime_diagnostics.jsonl"), `${JSON.stringify(diagnostic)}\n`); fs.appendFileSync(resolveData("diagnostics", "runtime_diagnostics.jsonl"), `${JSON.stringify(diagnostic)}\n`);
return diagnostic; return diagnostic;
} }

View File

@ -4,6 +4,7 @@ const path = require("path");
const { spawn } = require("child_process"); const { spawn } = require("child_process");
const AdmZip = require("adm-zip"); const AdmZip = require("adm-zip");
const { resolveData } = require("./paths"); const { resolveData } = require("./paths");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
class DownloadManager { class DownloadManager {
constructor(onEvent) { constructor(onEvent) {
@ -144,7 +145,7 @@ class DownloadManager {
job.state = "installing"; job.state = "installing";
await replaceDirectoryContents(staging, finalDir); await replaceDirectoryContents(staging, finalDir);
fs.writeFileSync(path.join(finalDir, "lumi-runtime.json"), `${JSON.stringify({ writeJsonAtomicSync(path.join(finalDir, "lumi-runtime.json"), {
backend: runtimeMetadata?.backend || "cpu", backend: runtimeMetadata?.backend || "cpu",
backend_variant: runtimeMetadata?.backend_variant || null, backend_variant: runtimeMetadata?.backend_variant || null,
version: runtimeMetadata?.version || null, version: runtimeMetadata?.version || null,
@ -155,7 +156,7 @@ class DownloadManager {
size: dependency.size || 0 size: dependency.size || 0
})), })),
installed_at: new Date().toISOString() installed_at: new Date().toISOString()
}, null, 2)}\n`); });
} finally { } finally {
if (installCallbackStarted && typeof afterInstall === "function") { if (installCallbackStarted && typeof afterInstall === "function") {
await afterInstall({ job, finalDir, staging, runtimeMetadata }); await afterInstall({ job, finalDir, staging, runtimeMetadata });

View File

@ -1,5 +1,5 @@
const fs = require("fs"); const fs = require("fs");
const crypto = require("crypto"); const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
const { resolveData } = require("./paths"); const { resolveData } = require("./paths");
const { roleOf } = require("./permissions"); const { roleOf } = require("./permissions");
@ -13,7 +13,10 @@ const FEEDBACK_TAGS = Object.freeze([
"should_clarify", "should_clarify",
"bad_code", "bad_code",
"wrong_scope", "wrong_scope",
"wrong_tool_usage" "wrong_tool_usage",
"bad_retrieval",
"bad_response",
"missing_knowledge"
]); ]);
const FEEDBACK_KINDS = Object.freeze(["instruction_based", "strict_correction"]); const FEEDBACK_KINDS = Object.freeze(["instruction_based", "strict_correction"]);
@ -36,11 +39,14 @@ class FeedbackStore {
platform: clean(input.platform, 80) || "webui", platform: clean(input.platform, 80) || "webui",
model: clean(input.model, 200), model: clean(input.model, 200),
timestamp: validDate(input.timestamp) || new Date().toISOString(), timestamp: validDate(input.timestamp) || new Date().toISOString(),
rating: input.rating === "up" || input.feedback_tag === "good" ? "up" : "down",
feedback_tag: tag, feedback_tag: tag,
feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind) feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind)
? input.feedback_kind ? input.feedback_kind
: "instruction_based", : "instruction_based",
optional_correction: clean(input.optional_correction, 16000), optional_correction: clean(input.optional_correction, 16000),
context_snapshot: normalizeContextSnapshot(input.context_snapshot),
linked_okf_correction: null,
status: "pending", status: "pending",
submitted_by: String(actor?.id || "anonymous"), submitted_by: String(actor?.id || "anonymous"),
reviewed_by: null, reviewed_by: null,
@ -59,8 +65,10 @@ class FeedbackStore {
return entry; return entry;
} }
list({ page = 1, pageSize = 20, status = "" } = {}) { list({ page = 1, pageSize = 20, status = "", viewerRole = "admin" } = {}) {
const filtered = this.read().entries.filter((entry) => !status || entry.status === status); const filtered = this.read().entries
.filter((entry) => roleRank(viewerRole) >= roleRank(entry.role))
.filter((entry) => !status || entry.status === status);
return paginate(filtered, page, pageSize); return paginate(filtered, page, pageSize);
} }
@ -85,7 +93,7 @@ class FeedbackStore {
} }
setStatus(id, status, actor, notes = "") { setStatus(id, status, actor, notes = "") {
if (!["pending", "flagged", "verified", "approved", "rejected"].includes(status)) { if (!["pending", "flagged", "verified", "approved", "rejected", "reviewed", "archived"].includes(status)) {
throw new Error("Invalid review status."); throw new Error("Invalid review status.");
} }
return this.mutate(id, (entry) => ({ return this.mutate(id, (entry) => ({
@ -116,6 +124,15 @@ class FeedbackStore {
})); }));
} }
linkOkfCorrection(id, correctionPath, actor) {
return this.mutate(id, (entry) => ({
...entry,
linked_okf_correction: clean(correctionPath, 1000),
reviewed_by: entry.reviewed_by || String(actor.id),
reviewed_at: new Date().toISOString()
}));
}
delete(id) { delete(id) {
const store = this.read(); const store = this.read();
const before = store.entries.length; const before = store.entries.length;
@ -180,13 +197,7 @@ function paginate(rows, pageValue, pageSizeValue) {
} }
function atomicJson(file, value) { function atomicJson(file, value) {
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`; writeJsonAtomicSync(file, value);
try {
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
fs.renameSync(tmp, file);
} finally {
fs.rmSync(tmp, { force: true });
}
} }
function clean(value, max) { function clean(value, max) {
@ -202,11 +213,42 @@ function validDate(value) {
return Number.isNaN(date.getTime()) ? null : date.toISOString(); return Number.isNaN(date.getTime()) ? null : date.toISOString();
} }
function normalizeContextSnapshot(value) {
const snapshot = value && typeof value === "object" ? value : {};
const retrieval = snapshot.retrieval && typeof snapshot.retrieval === "object" ? snapshot.retrieval : {};
return {
retrieval: {
query: clean(retrieval.query, 500),
candidate_count: safeCount(retrieval.candidate_count),
selected_count: safeCount(retrieval.selected_count),
depth: clean(retrieval.depth, 40)
},
okf: cleanList(snapshot.okf, 4, 1200),
corrections: cleanList(snapshot.corrections, 3, 1200),
repository: cleanList(snapshot.repository, 3, 1200),
tools: cleanList(snapshot.tools, 20, 120)
};
}
function cleanList(value, limit, maxLength) {
return (Array.isArray(value) ? value : []).slice(0, limit).map((entry) => clean(entry, maxLength)).filter(Boolean);
}
function safeCount(value) {
const number = Number(value);
return Number.isFinite(number) ? Math.max(0, Math.min(100000, Math.floor(number))) : 0;
}
function roleRank(role) {
return { user: 1, mod: 2, admin: 3 }[normalizeRole(role)] || 1;
}
module.exports = { module.exports = {
FEEDBACK_KINDS, FEEDBACK_KINDS,
FEEDBACK_TAGS, FEEDBACK_TAGS,
FeedbackStore, FeedbackStore,
improvementAccess, improvementAccess,
normalizeContextSnapshot,
paginate, paginate,
atomicJson atomicJson
}; };

View File

@ -1,5 +1,6 @@
const fs = require("fs"); const fs = require("fs");
const { resolveData } = require("./paths"); const { resolveData } = require("./paths");
const { writeJsonAtomicSync, writeTextAtomicSync } = require("../../../src/services/safe-files");
const historyFile = () => resolveData("metrics", "history.jsonl"); const historyFile = () => resolveData("metrics", "history.jsonl");
const stateFile = () => resolveData("metrics", "summary.json"); const stateFile = () => resolveData("metrics", "summary.json");
@ -118,7 +119,7 @@ function record(entry) {
}); });
summary.slow_requests = summary.slow_requests.slice(0, 25); summary.slow_requests = summary.slow_requests.slice(0, 25);
} }
fs.writeFileSync(stateFile(), JSON.stringify(summary, null, 2)); writeJsonAtomicSync(stateFile(), summary);
fs.appendFileSync(historyFile(), `${JSON.stringify({ timestamp:new Date().toISOString(), ...entry })}\n`); fs.appendFileSync(historyFile(), `${JSON.stringify({ timestamp:new Date().toISOString(), ...entry })}\n`);
} }
function report() { function report() {
@ -176,6 +177,10 @@ function workHistoryPage(query = {}, page = 1, pageSize = 20) {
.filter((work) => matchesWorkFilters(work, filters)); .filter((work) => matchesWorkFilters(work, filters));
return paginateRows(grouped, page, pageSize); return paginateRows(grouped, page, pageSize);
} }
function workHistoryExport(workId) {
const work = groupWorkRows(readHistoryRows()).find((entry) => entry.work_id === String(workId || ""));
return work ? buildWorkHistoryExport(work) : null;
}
function slowRequestsPage(page = 1, pageSize = 15) { function slowRequestsPage(page = 1, pageSize = 15) {
const safePage = Math.max(1, Number.parseInt(page, 10) || 1); const safePage = Math.max(1, Number.parseInt(page, 10) || 1);
const safeSize = Math.max(1, Math.min(100, Number.parseInt(pageSize, 10) || 15)); const safeSize = Math.max(1, Math.min(100, Number.parseInt(pageSize, 10) || 15));
@ -202,12 +207,12 @@ function applyRetention() {
if (!rows.length) return; if (!rows.length) return;
const keepIds = retainedWorkIds(groupWorkRows(rows), retention); const keepIds = retainedWorkIds(groupWorkRows(rows), retention);
if (!keepIds) return; if (!keepIds) return;
const kept = rows.filter((row) => { const kept = rows.filter((row, index) => {
const id = row.request_id || row.work_id; const id = rowWorkId(row, index);
return !id || keepIds.has(id); return keepIds.has(id);
}); });
if (kept.length === rows.length) return; if (kept.length === rows.length) return;
fs.writeFileSync(historyFile(), kept.map((row) => JSON.stringify(row)).join("\n") + (kept.length ? "\n" : "")); writeTextAtomicSync(historyFile(), kept.map((row) => JSON.stringify(row)).join("\n") + (kept.length ? "\n" : ""));
} }
function maybeApplyRetention() { function maybeApplyRetention() {
if (Math.random() > 0.02) return; if (Math.random() > 0.02) return;
@ -225,9 +230,8 @@ function readHistoryRows() {
} }
function groupWorkRows(rows) { function groupWorkRows(rows) {
const groups = new Map(); const groups = new Map();
for (const row of rows) { rows.forEach((row, index) => {
const id = row.request_id || row.work_id; const id = rowWorkId(row, index);
if (!id) continue;
if (!groups.has(id)) { if (!groups.has(id)) {
groups.set(id, { groups.set(id, {
work_id: id, work_id: id,
@ -237,6 +241,8 @@ function groupWorkRows(rows) {
source: row.origin || row.platform || "webui", source: row.origin || row.platform || "webui",
user_id: row.user_id || "", user_id: row.user_id || "",
role: row.role || "", role: row.role || "",
model: row.model || row.model_id || "",
provider: row.provider || row.backend || "",
internal_mode: row.controller_complexity || "", internal_mode: row.controller_complexity || "",
okf_retrieval: row.okf_retrieval_depth || "", okf_retrieval: row.okf_retrieval_depth || "",
reason_code: row.controller_reason_code || row.gate_reason_code || row.reason_code || "", reason_code: row.controller_reason_code || row.gate_reason_code || row.reason_code || "",
@ -260,6 +266,8 @@ function groupWorkRows(rows) {
group.source = row.origin || row.platform || group.source; group.source = row.origin || row.platform || group.source;
group.user_id = row.user_id || group.user_id; group.user_id = row.user_id || group.user_id;
group.role = row.role || group.role; group.role = row.role || group.role;
group.model = row.model || row.model_id || group.model;
group.provider = row.provider || row.backend || group.provider;
group.internal_mode = row.controller_complexity || group.internal_mode; group.internal_mode = row.controller_complexity || group.internal_mode;
group.okf_retrieval = row.okf_retrieval_depth || group.okf_retrieval; group.okf_retrieval = row.okf_retrieval_depth || group.okf_retrieval;
group.reason_code = row.controller_reason_code || row.gate_reason_code || row.reason_code || group.reason_code; group.reason_code = row.controller_reason_code || row.gate_reason_code || row.reason_code || group.reason_code;
@ -283,7 +291,7 @@ function groupWorkRows(rows) {
else if (row.delivered_length != null) group.delivered_tokens = estimateTokensFromChars(row.delivered_length); else if (row.delivered_length != null) group.delivered_tokens = estimateTokensFromChars(row.delivered_length);
group.duration_ms = Math.max(group.duration_ms, validOrZero(row.total_ms ?? row.duration_ms)); group.duration_ms = Math.max(group.duration_ms, validOrZero(row.total_ms ?? row.duration_ms));
group.events.push(workEvent(row)); group.events.push(workEvent(row));
} });
return [...groups.values()] return [...groups.values()]
.map((group) => { .map((group) => {
if (!group.processed_tokens) group.processed_tokens = group.prompt_tokens + group.final_tokens; if (!group.processed_tokens) group.processed_tokens = group.prompt_tokens + group.final_tokens;
@ -339,10 +347,104 @@ function compactWorkData(row) {
"controller_complexity", "okf_retrieval_depth", "answer_style", "source_profile", "controller_complexity", "okf_retrieval_depth", "answer_style", "source_profile",
"controller_confidence", "okf_match_count", "context_block_count", "prompt_tokens", "controller_confidence", "okf_match_count", "context_block_count", "prompt_tokens",
"generated_tokens", "final_tokens", "delivered_tokens", "final_reply_length", "generated_tokens", "final_tokens", "delivered_tokens", "final_reply_length",
"delivered_length", "truncated", "delivery_action", "fallback_reason", "rejected_reason", "error_code" "delivered_length", "truncated", "delivery_action", "fallback_reason", "rejected_reason", "error_code",
"model", "model_id", "provider", "backend", "okf_query", "okf_candidate_count", "okf_limit",
"okf_provider_count", "deterministic_ms", "gate_ms", "queue_ms", "prompt_eval_ms", "generation_ms", "total_ms"
]; ];
return Object.fromEntries(keys.filter((key) => row[key] != null).map((key) => [key, row[key]])); return Object.fromEntries(keys.filter((key) => row[key] != null).map((key) => [key, row[key]]));
} }
function rowWorkId(row, index) {
if (row.request_id || row.work_id) return String(row.request_id || row.work_id);
const timestamp = String(row.timestamp || "unknown").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").slice(0, 60);
return `legacy-${timestamp || "row"}-${index}`;
}
function buildWorkHistoryExport(work) {
const cleanPrompt = sanitizeWorkExportText(work.prompt || "Prompt was not recorded for this legacy entry.", work, 6000);
const lifecycle = (work.events || []).map((event) => ({
timestamp: event.timestamp || null,
type: event.type || "event",
status: event.status || null,
summary: sanitizeWorkExportText(event.summary, work, 500),
tokens: validOrZero(event.tokens),
data: sanitizeWorkEventData(event.data, work)
}));
const retrieval = lifecycle
.filter((event) => event.type === "okf_retrieval")
.map((event) => ({
query: event.data.okf_query || null,
depth: event.data.okf_retrieval_depth || work.okf_retrieval || null,
candidates: validOrZero(event.data.okf_candidate_count),
selected: validOrZero(event.data.okf_match_count),
fallback_reason: event.data.fallback_reason || null
}));
const errors = lifecycle
.filter((event) => event.type === "error" || event.status === "failed" || event.data.error_code)
.map((event) => ({ code: event.data.error_code || null, summary: event.summary }))
.slice(0, 10);
return {
schema: "lumi.ai.work.codex.v1",
generated_at: new Date().toISOString(),
objective: `Investigate Lumi AI work item ${work.status || "unknown"}: ${cleanPrompt.slice(0, 240)}`,
work: {
id: String(work.work_id || "legacy"),
prompt: cleanPrompt,
source: sanitizeWorkExportText(work.source, work, 80),
role: ["user", "mod", "admin"].includes(work.role) ? work.role : "unknown",
status: work.status || "unknown",
route: lifecycle.map((event) => event.data.route_used).find(Boolean) || null,
model: sanitizeWorkExportText(work.model, work, 160) || null,
provider: sanitizeWorkExportText(work.provider, work, 120) || null,
controller: {
mode: work.internal_mode || null,
reason: sanitizeWorkExportText(work.reason_code, work, 160) || null,
okf_retrieval: work.okf_retrieval || null
},
tokens: {
processed: validOrZero(work.processed_tokens),
prompt: validOrZero(work.prompt_tokens),
final: validOrZero(work.final_tokens),
delivered: validOrZero(work.delivered_tokens)
},
duration_ms: validOrZero(work.duration_ms),
flags: [
work.has_error && "error",
work.has_refusal && "refusal",
work.has_fallback && "fallback",
work.has_truncation && "truncation",
work.has_okf_context && "okf_context"
].filter(Boolean),
retrieval,
errors,
lifecycle
},
privacy: {
mode: "task_focused",
removed: ["user identity", "session data", "secrets", "local paths", "raw diagnostics", "tool payloads"]
}
};
}
function sanitizeWorkEventData(value, work) {
const data = value && typeof value === "object" ? value : {};
return Object.fromEntries(Object.entries(data).map(([key, item]) => [
key,
typeof item === "string" ? sanitizeWorkExportText(item, work, 500) : item
]));
}
function sanitizeWorkExportText(value, work, maxLength) {
let text = String(value || "");
if (work.user_id) text = text.replaceAll(String(work.user_id), "[user]");
return text
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[email]")
.replace(/\b((?:token|secret|password|cookie|authorization|api[_ -]?key|client[_ -]?secret)\s*[:=]\s*)\S+/gi, "$1[redacted]")
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
.replace(/\b[A-Za-z]:[\\/][^\r\n"']+/g, "[local path]")
.replace(/\/(?:home|Users|mnt|var|tmp)\/[^\s"']+/g, "[local path]")
.trim()
.slice(0, maxLength);
}
function normalizeWorkFilters(query = {}) { function normalizeWorkFilters(query = {}) {
return { return {
q: String(query.work_q || query.q || "").trim().toLowerCase(), q: String(query.work_q || query.q || "").trim().toLowerCase(),
@ -474,6 +576,9 @@ module.exports = {
history, history,
historyPage, historyPage,
workHistoryPage, workHistoryPage,
workHistoryExport,
buildWorkHistoryExport,
groupWorkRows,
slowRequestsPage, slowRequestsPage,
paginateRows, paginateRows,
isValidTiming, isValidTiming,

View File

@ -2,6 +2,7 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const { spawnSync } = require("child_process"); const { spawnSync } = require("child_process");
const { PLUGIN_ROOT, resolveData } = require("./paths"); const { PLUGIN_ROOT, resolveData } = require("./paths");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
const INDEX_FILE = "index.json"; const INDEX_FILE = "index.json";
const REPOSITORY_URL = "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi"; const REPOSITORY_URL = "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi";
@ -52,7 +53,7 @@ function refreshIndex(root = repoRoot()) {
documents documents
}; };
const target = resolveData("repo_index", INDEX_FILE); const target = resolveData("repo_index", INDEX_FILE);
fs.writeFileSync(target, `${JSON.stringify(index, null, 2)}\n`); writeJsonAtomicSync(target, index);
return index; return index;
} }
@ -71,7 +72,7 @@ function refreshPublicIndex() {
const index = refreshIndex(staging); const index = refreshIndex(staging);
index.source = "public"; index.source = "public";
index.root = REPOSITORY_URL; index.root = REPOSITORY_URL;
fs.writeFileSync(resolveData("repo_index", INDEX_FILE), `${JSON.stringify(index, null, 2)}\n`); writeJsonAtomicSync(resolveData("repo_index", INDEX_FILE), index);
return index; return index;
} finally { } finally {
const resolved = path.resolve(staging); const resolved = path.resolve(staging);

View File

@ -3,6 +3,7 @@ const path = require("path");
const crypto = require("crypto"); const crypto = require("crypto");
const { resolveData } = require("./paths"); const { resolveData } = require("./paths");
const { assertToolId } = require("./tool_repo_client"); const { assertToolId } = require("./tool_repo_client");
const { runFileOperationWithRetries } = require("../../../src/services/safe-files");
const REQUIRED_FIELDS = Object.freeze([ const REQUIRED_FIELDS = Object.freeze([
"tool_id", "tool_id",
@ -46,12 +47,12 @@ class ToolInstaller {
return this.stageAndSwap(toolId, false); return this.stageAndSwap(toolId, false);
} }
async update(toolId) { async update(toolId, options = {}) {
if (!this.local(toolId)) throw new Error("AI tool plugin is not installed."); if (!this.local(toolId)) throw new Error("AI tool plugin is not installed.");
return this.stageAndSwap(toolId, true); return this.stageAndSwap(toolId, true, options);
} }
async stageAndSwap(toolId, updating) { async stageAndSwap(toolId, updating, options = {}) {
assertToolId(toolId); assertToolId(toolId);
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const stageBase = path.join(this.stagingRoot, id); const stageBase = path.join(this.stagingRoot, id);
@ -64,23 +65,37 @@ class ToolInstaller {
const remoteMetadata = validateToolDirectory(stageDir, toolId); const remoteMetadata = validateToolDirectory(stageDir, toolId);
if (updating) preserveConfiguredPaths(target, stageDir, remoteMetadata); if (updating) preserveConfiguredPaths(target, stageDir, remoteMetadata);
let movedCurrent = false; let movedCurrent = false;
let installedReplacement = false;
try { try {
if (updating) { if (updating) {
fs.renameSync(target, backup); fileOperation(() => fs.renameSync(target, backup), target, "tool_backup");
movedCurrent = true; movedCurrent = true;
} }
fs.renameSync(stageDir, target); fileOperation(() => fs.renameSync(stageDir, target), target, "tool_install");
installedReplacement = true;
if (typeof options.afterSwap === "function") await options.afterSwap({ target, backup, metadata: remoteMetadata });
} catch (error) { } catch (error) {
fs.rmSync(target, { recursive: true, force: true }); if (installedReplacement && fs.existsSync(target)) {
if (movedCurrent && fs.existsSync(backup)) fs.renameSync(backup, target); try { fileOperation(() => fs.rmSync(target, { recursive: true, force: true }), target, "tool_remove_failed_replacement"); }
catch (cleanupError) { error.message = `${error.message} The failed replacement remains at ${target}: ${cleanupError.message}`; }
}
if (movedCurrent && fs.existsSync(backup) && !fs.existsSync(target)) {
try {
fileOperation(() => fs.renameSync(backup, target), target, "tool_restore");
movedCurrent = false;
} catch (restoreError) {
error.message = `${error.message} The previous tool remains recoverable at ${backup}: ${restoreError.message}`;
}
}
throw error; throw error;
} }
fs.rmSync(backup, { recursive: true, force: true }); if (fs.existsSync(backup)) fileOperation(() => fs.rmSync(backup, { recursive: true, force: true }), backup, "tool_commit_cleanup");
return { tool_id: toolId, metadata: remoteMetadata, installed: true, updated: updating }; return { tool_id: toolId, metadata: remoteMetadata, installed: true, updated: updating };
} finally { } finally {
fs.rmSync(stageBase, { recursive: true, force: true }); fs.rmSync(stageBase, { recursive: true, force: true });
if (fs.existsSync(backup) && !fs.existsSync(target)) fs.renameSync(backup, target); if (fs.existsSync(backup) && !fs.existsSync(target)) {
else fs.rmSync(backup, { recursive: true, force: true }); try { fileOperation(() => fs.renameSync(backup, target), target, "tool_final_restore"); } catch {}
}
} }
} }
@ -94,6 +109,15 @@ class ToolInstaller {
} }
} }
function fileOperation(operation, target, operationName) {
return runFileOperationWithRetries(operation, {
target,
operation: operationName,
attempts: 8,
delayMs: 15
});
}
function validateToolDirectory(directory, folderName = path.basename(directory)) { function validateToolDirectory(directory, folderName = path.basename(directory)) {
const metadataFile = path.join(directory, "tool_info.json"); const metadataFile = path.join(directory, "tool_info.json");
const readmeFile = path.join(directory, "readme.md"); const readmeFile = path.join(directory, "readme.md");

View File

@ -1,5 +1,6 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
const { resolveData } = require("./paths"); const { resolveData } = require("./paths");
const { registerManagedTool } = require("./tool_registry"); const { registerManagedTool } = require("./tool_registry");
@ -272,15 +273,7 @@ class ToolLoader {
} }
writeState(state) { writeState(state) {
fs.mkdirSync(path.dirname(this.stateFile), { recursive: true }); writeJsonAtomicSync(this.stateFile, state);
const temporary = `${this.stateFile}.${process.pid}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`);
try { fs.renameSync(temporary, this.stateFile); }
catch (error) {
if (!["EEXIST", "EPERM"].includes(error.code)) throw error;
fs.rmSync(this.stateFile, { force: true });
fs.renameSync(temporary, this.stateFile);
}
} }
} }

View File

@ -93,8 +93,14 @@ class ToolManager {
const wasEnabled = this.loader.isEnabled(toolId); const wasEnabled = this.loader.isEnabled(toolId);
if (wasEnabled) await this.loader.disable(toolId, { persist: false }); if (wasEnabled) await this.loader.disable(toolId, { persist: false });
try { try {
const result = await this.installer.update(toolId); const result = await this.installer.update(toolId, {
if (wasEnabled) await this.loader.enable(toolId, { persist: false }); afterSwap: wasEnabled
? async () => {
const activated = await this.loader.enable(toolId, { persist: false });
if (!activated?.loaded) throw new Error(activated?.message || "The updated AI tool could not be loaded.");
}
: null
});
this.loader.setEnabled(toolId, wasEnabled); this.loader.setEnabled(toolId, wasEnabled);
return result; return result;
} catch (error) { } catch (error) {

View File

@ -1,5 +1,6 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
const { spawnSync } = require("child_process"); const { spawnSync } = require("child_process");
const { resolveData } = require("./paths"); const { resolveData } = require("./paths");
@ -191,10 +192,7 @@ class ToolRepoClient {
} }
writeCache(value) { writeCache(value) {
fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true }); writeJsonAtomicSync(this.cacheFile, value);
const temporary = `${this.cacheFile}.${process.pid}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
fs.renameSync(temporary, this.cacheFile);
} }
} }

View File

@ -1,5 +1,6 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
class ToolSettings { class ToolSettings {
constructor(options = {}) { constructor(options = {}) {
@ -41,11 +42,7 @@ class ToolSettings {
} }
} }
const file = settingsFile(local.dir); const file = settingsFile(local.dir);
fs.mkdirSync(path.dirname(file), { recursive: true }); writeJsonAtomicSync(file, next, { mode: 0o600 });
const temporary = `${file}.${process.pid}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
try { fs.chmodSync(temporary, 0o600); } catch {}
fs.renameSync(temporary, file);
return this.describe(toolId); return this.describe(toolId);
} }
@ -57,16 +54,7 @@ class ToolSettings {
Object.entries(schema).map(([key, field]) => [key, normalizeValue(field.default, field, key)]) Object.entries(schema).map(([key, field]) => [key, normalizeValue(field.default, field, key)])
); );
const file = settingsFile(local.dir); const file = settingsFile(local.dir);
fs.mkdirSync(path.dirname(file), { recursive: true }); writeJsonAtomicSync(file, values, { mode: 0o600 });
const temporary = `${file}.${process.pid}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(values, null, 2)}\n`, { mode: 0o600 });
try { fs.chmodSync(temporary, 0o600); } catch {}
try { fs.renameSync(temporary, file); }
catch (error) {
if (!["EEXIST", "EPERM"].includes(error.code)) throw error;
fs.rmSync(file, { force: true });
fs.renameSync(temporary, file);
}
return this.describe(toolId); return this.describe(toolId);
} }

View File

@ -1,6 +1,7 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { resolveData } = require("./paths"); const { resolveData } = require("./paths");
const { writeTextAtomicSync } = require("../../../src/services/safe-files");
class TrainingExporter { class TrainingExporter {
constructor({ feedback, corrections, outputDir }) { constructor({ feedback, corrections, outputDir }) {
@ -26,7 +27,7 @@ class TrainingExporter {
input: "", input: "",
output: entry.preferred_answer output: entry.preferred_answer
}); });
fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : "")); writeTextAtomicSync(file, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : ""));
return { file, filename: path.basename(file), count: rows.length, format }; return { file, filename: path.basename(file), count: rows.length, format };
} }
} }

View File

@ -1,6 +1,7 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const express = require("express"); const express = require("express");
const { writeJsonAtomicSync } = require("../../src/services/safe-files");
const { ensureDataDirs, resolveData } = require("./backend/paths"); const { ensureDataDirs, resolveData } = require("./backend/paths");
const { getConfig, saveConfig, getRuntimeState } = require("./backend/config_manager"); const { getConfig, saveConfig, getRuntimeState } = require("./backend/config_manager");
const { detectHardware, estimateAllocation, performanceTuningHints } = require("./backend/hardware"); const { detectHardware, estimateAllocation, performanceTuningHints } = require("./backend/hardware");
@ -871,6 +872,16 @@ module.exports = {
}); });
return res.download(file); return res.download(file);
}); });
router.get("/work-history/:id/export", (req, res) => {
if (!req.session.user?.isAdmin) return denied(res);
const payload = metrics.workHistoryExport(req.params.id);
if (!payload) return res.status(404).send("Work history item not found.");
const safeId = String(req.params.id || "work").replace(/[^a-z0-9_-]+/gi, "-").slice(0, 80) || "work";
res.set("Cache-Control", "private, no-store");
res.attachment(`lumi-ai-work-${safeId}.json`);
return res.send(`${JSON.stringify(payload, null, 2)}\n`);
});
router.post("/assistant/message", async (req, res) => { router.post("/assistant/message", async (req, res) => {
const permission = canUseAssistant({ const permission = canUseAssistant({
user: req.session.user, user: req.session.user,
@ -956,7 +967,8 @@ module.exports = {
origin: originContext.origin, origin: originContext.origin,
platform: originContext.platform, platform: originContext.platform,
model: result.model_id || requestConfig.selected_model_id, model: result.model_id || requestConfig.selected_model_id,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
context_snapshot: result.feedback_context_snapshot || null
} }
}; };
} catch (error) { } catch (error) {
@ -1005,9 +1017,11 @@ module.exports = {
platform: "webui", platform: "webui",
model: req.body.model, model: req.body.model,
timestamp: req.body.timestamp, timestamp: req.body.timestamp,
rating: req.body.rating,
feedback_tag: req.body.feedback_tag, feedback_tag: req.body.feedback_tag,
feedback_kind: req.body.feedback_kind, feedback_kind: req.body.feedback_kind,
optional_correction: req.body.optional_correction optional_correction: req.body.optional_correction,
context_snapshot: req.body.context_snapshot
}, req.session.user); }, req.session.user);
return res.status(201).json({ success: true, id: entry.id }); return res.status(201).json({ success: true, id: entry.id });
} catch (error) { } catch (error) {
@ -1269,7 +1283,8 @@ module.exports = {
reviews: feedbackStore.list({ reviews: feedbackStore.list({
page: req.query.review_page, page: req.query.review_page,
pageSize: 15, pageSize: 15,
status: cleanText(req.query.status, 30) status: cleanText(req.query.status, 30),
viewerRole: access.role
}), }),
corrections: correctionStore.list({ page: req.query.correction_page, pageSize: 15 }), corrections: correctionStore.list({ page: req.query.correction_page, pageSize: 15 }),
evalCases: evalStore.list({ page: req.query.eval_page, pageSize: 15 }), evalCases: evalStore.list({ page: req.query.eval_page, pageSize: 15 }),
@ -1298,6 +1313,8 @@ module.exports = {
const access = improvementAccess(req.session.user, config); const access = improvementAccess(req.session.user, config);
if (!access.allowed) return deniedImprovement(res); if (!access.allowed) return deniedImprovement(res);
try { try {
const review = feedbackStore.get(req.params.id);
if (!canReviewAiFeedback(review, access)) return deniedImprovement(res);
const action = cleanText(req.body.action, 30); const action = cleanText(req.body.action, 30);
if (action === "flag" && access.can_flag) { if (action === "flag" && access.can_flag) {
feedbackStore.setStatus(req.params.id, "flagged", req.session.user, req.body.review_notes); feedbackStore.setStatus(req.params.id, "flagged", req.session.user, req.body.review_notes);
@ -1307,12 +1324,12 @@ module.exports = {
feedbackStore.setStatus(req.params.id, "approved", req.session.user, req.body.review_notes); feedbackStore.setStatus(req.params.id, "approved", req.session.user, req.body.review_notes);
} else if (action === "reject" && access.can_approve) { } else if (action === "reject" && access.can_approve) {
feedbackStore.setStatus(req.params.id, "rejected", req.session.user, req.body.review_notes); feedbackStore.setStatus(req.params.id, "rejected", req.session.user, req.body.review_notes);
} else if (action === "reviewed" && access.can_approve) {
feedbackStore.setStatus(req.params.id, "reviewed", req.session.user, req.body.review_notes);
} else if (action === "edit" && access.can_edit) { } else if (action === "edit" && access.can_edit) {
feedbackStore.edit(req.params.id, req.body, req.session.user); feedbackStore.edit(req.params.id, req.body, req.session.user);
} else if (action === "export" && access.can_export) { } else if (action === "export" && access.can_export) {
feedbackStore.markExportApproved(req.params.id, req.session.user); feedbackStore.markExportApproved(req.params.id, req.session.user);
} else if (action === "delete" && access.can_delete) {
feedbackStore.delete(req.params.id);
} else { } else {
return deniedImprovement(res); return deniedImprovement(res);
} }
@ -1322,6 +1339,28 @@ module.exports = {
} }
}); });
router.post("/improvement_center/reviews/:id/delete", (req, res) => {
const access = improvementAccess(req.session.user, config);
if (!access.can_delete) return deniedImprovement(res);
try {
feedbackStore.delete(req.params.id);
return improvementFlash(req, res, "success", "Review delete completed.");
} catch (error) {
return improvementFlash(req, res, "error", error.message);
}
});
router.post("/improvement_center/reviews/:id/archive", (req, res) => {
const access = improvementAccess(req.session.user, config);
if (!access.can_delete) return deniedImprovement(res);
try {
feedbackStore.setStatus(req.params.id, "archived", req.session.user, req.body.review_notes);
return improvementFlash(req, res, "success", "Feedback record archived.");
} catch (error) {
return improvementFlash(req, res, "error", error.message);
}
});
router.post("/improvement_center/reviews/:id/implement", (req, res) => { router.post("/improvement_center/reviews/:id/implement", (req, res) => {
const access = improvementAccess(req.session.user, config); const access = improvementAccess(req.session.user, config);
if (!access.can_implement) return deniedImprovement(res); if (!access.can_implement) return deniedImprovement(res);
@ -1342,12 +1381,22 @@ module.exports = {
} else if (target === "training_export") { } else if (target === "training_export") {
feedbackStore.markExportApproved(review.id, req.session.user); feedbackStore.markExportApproved(review.id, req.session.user);
} else { } else {
correctionStore.createFromFeedback(review, { const correction = correctionStore.createFromFeedback(review, {
...req.body, ...req.body,
target, target,
explicitly_safe: req.body.explicitly_safe === "on", explicitly_safe: req.body.explicitly_safe === "on",
enabled: req.body.enabled === "on" enabled: req.body.enabled === "on"
}, req.session.user); }, req.session.user);
if (target === "correction") {
try {
const okfEntry = saveAiFeedbackOkfCorrection(review, correction, req.body, req.session.user);
correctionStore.linkOkf(correction.id, okfEntry.path);
feedbackStore.linkOkfCorrection(review.id, okfEntry.path, req.session.user);
} catch (error) {
correctionStore.delete(correction.id);
throw error;
}
}
} }
return improvementFlash(req, res, "success", "Approved feedback was promoted. Save Corrections before it becomes active."); return improvementFlash(req, res, "success", "Approved feedback was promoted. Save Corrections before it becomes active.");
} catch (error) { } catch (error) {
@ -1380,8 +1429,6 @@ module.exports = {
}); });
} else if (action === "toggle" && access.can_edit) { } else if (action === "toggle" && access.can_edit) {
correctionStore.setEnabled(req.params.id, req.body.enabled === "on"); correctionStore.setEnabled(req.params.id, req.body.enabled === "on");
} else if (action === "delete" && access.can_delete) {
correctionStore.delete(req.params.id);
} else { } else {
return deniedImprovement(res); return deniedImprovement(res);
} }
@ -1391,6 +1438,17 @@ module.exports = {
} }
}); });
router.post("/improvement_center/corrections/:id/delete", (req, res) => {
const access = improvementAccess(req.session.user, config);
if (!access.can_delete) return deniedImprovement(res);
try {
correctionStore.delete(req.params.id);
return improvementFlash(req, res, "success", "Correction delete completed. Save Corrections to activate changes.");
} catch (error) {
return improvementFlash(req, res, "error", error.message);
}
});
router.post("/improvement_center/evals", (req, res) => { router.post("/improvement_center/evals", (req, res) => {
const access = improvementAccess(req.session.user, config); const access = improvementAccess(req.session.user, config);
if (!access.can_run_evals) return deniedImprovement(res); if (!access.can_run_evals) return deniedImprovement(res);
@ -1555,6 +1613,42 @@ function boundedNumber(value, min, max, fallback) {
function cleanText(value, max) { function cleanText(value, max) {
return String(value || "").trim().slice(0, max); return String(value || "").trim().slice(0, max);
} }
function canReviewAiFeedback(review, access) {
if (!review || !access?.allowed) return false;
const rank = { user: 1, mod: 2, admin: 3 };
return (rank[access.role] || 0) >= (rank[review.role] || 1);
}
function saveAiFeedbackOkfCorrection(review, correction, values, actor) {
const saveCorrection = global.lumiFrameworks?.okf?.saveCorrection;
if (typeof saveCorrection !== "function") {
throw new Error("Enable the OKF plugin before creating a searchable correction file.");
}
const title = cleanText(values.okf_title, 180) || `Assistant correction: ${cleanText(review.user_message, 120)}`;
const visibility = ["user", "mod", "admin"].includes(correction.min_role) ? correction.min_role : "user";
const safePrompt = cleanText(review.user_message, 3000);
const safeAnswer = cleanText(correction.corrected_answer, 10000);
return saveCorrection({
slug: `lumi-ai-${review.id}`,
id: `correction.lumi_ai.${review.id}`,
title,
status: "active",
priority: 150,
visibility,
category: "Lumi AI correction",
tags: `lumi ai, assistant feedback, correction, ${review.feedback_tag || "reviewed"}`,
source_feedback_id: `lumi-ai:${review.id}`,
source_feedback_url: `/plugins/lumi_ai/improvement_center#review-${review.id}`,
body: [
"## When this applies",
safePrompt,
"",
"## Approved response guidance",
safeAnswer
].join("\n")
}, actor);
}
function flash(req, res, type, message) { function flash(req, res, type, message) {
req.session.flash = { type, message }; req.session.flash = { type, message };
return res.redirect(`/plugins/${PLUGIN_ID}`); return res.redirect(`/plugins/${PLUGIN_ID}`);
@ -2061,7 +2155,7 @@ function writeCommandsManifest(pluginDir, config) {
aliases: triggers.slice(1) aliases: triggers.slice(1)
}] }]
}; };
fs.writeFileSync(path.join(pluginDir, "cmds.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); writeJsonAtomicSync(path.join(pluginDir, "cmds.json"), manifest);
} }
module.exports.shouldAutoResume = shouldAutoResume; module.exports.shouldAutoResume = shouldAutoResume;

View File

@ -51,6 +51,8 @@
.lumi-ai-confirm { display: flex; gap: 8px; margin-top: 8px; } .lumi-ai-confirm { display: flex; gap: 8px; margin-top: 8px; }
.lumi-ai-confirm button { padding: 5px 9px; border-radius: 5px; border: 1px solid var(--border); cursor: pointer; } .lumi-ai-confirm button { padding: 5px 9px; border-radius: 5px; border: 1px solid var(--border); cursor: pointer; }
.lumi-ai-feedback { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; padding-top: 8px; border-top: 1px solid var(--border); color: var(--ink-soft); font-size: 11px; } .lumi-ai-feedback { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; padding-top: 8px; border-top: 1px solid var(--border); color: var(--ink-soft); font-size: 11px; }
.lumi-ai-feedback-quick-actions, .lumi-ai-feedback-details { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 6px; }
.lumi-ai-feedback-details[hidden] { display: none; }
.lumi-ai-feedback select, .lumi-ai-feedback input, .lumi-ai-feedback button { min-height: 29px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface-2); color: var(--ink); padding: 4px 7px; font: inherit; } .lumi-ai-feedback select, .lumi-ai-feedback input, .lumi-ai-feedback button { min-height: 29px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface-2); color: var(--ink); padding: 4px 7px; font: inherit; }
.lumi-ai-feedback input { flex: 1 1 180px; } .lumi-ai-feedback input { flex: 1 1 180px; }
.lumi-ai-feedback button { cursor: pointer; font-weight: 700; } .lumi-ai-feedback button { cursor: pointer; font-weight: 700; }

View File

@ -279,11 +279,29 @@
const controls = document.createElement("form"); const controls = document.createElement("form");
controls.className = "lumi-ai-feedback"; controls.className = "lumi-ai-feedback";
controls.setAttribute("aria-label", "Rate this Lumi Assistant reply"); controls.setAttribute("aria-label", "Rate this Lumi Assistant reply");
const quickActions = document.createElement("div");
quickActions.className = "lumi-ai-feedback-quick-actions";
const helpful = document.createElement("button");
helpful.type = "button";
helpful.className = "button subtle";
helpful.textContent = "Helpful";
helpful.setAttribute("aria-label", "Mark this reply as helpful");
const needsWork = document.createElement("button");
needsWork.type = "button";
needsWork.className = "button subtle";
needsWork.textContent = "Needs work";
needsWork.setAttribute("aria-label", "Tell Lumi what needs work");
quickActions.append(helpful, needsWork);
const details = document.createElement("div");
details.className = "lumi-ai-feedback-details";
details.hidden = true;
const select = document.createElement("select"); const select = document.createElement("select");
select.setAttribute("aria-label", "Feedback tag"); select.setAttribute("aria-label", "Feedback tag");
for (const tag of [ for (const tag of [
"good",
"bad", "bad",
"bad_retrieval",
"bad_response",
"missing_knowledge",
"wrong_link", "wrong_link",
"hallucinated", "hallucinated",
"too_generic", "too_generic",
@ -316,9 +334,11 @@
const submitFeedback = document.createElement("button"); const submitFeedback = document.createElement("button");
submitFeedback.type = "submit"; submitFeedback.type = "submit";
submitFeedback.textContent = "Send feedback"; submitFeedback.textContent = "Send feedback";
controls.append(select, kind, correction, submitFeedback); details.append(select, kind, correction, submitFeedback);
controls.addEventListener("submit", async (event) => { controls.append(quickActions, details);
event.preventDefault(); const sendFeedback = async ({ tag, rating, correctionText = "", feedbackKind = "instruction_based" }) => {
helpful.disabled = true;
needsWork.disabled = true;
submitFeedback.disabled = true; submitFeedback.disabled = true;
try { try {
const response = await trackedFetch(`${endpoint}/assistant/feedback`, { const response = await trackedFetch(`${endpoint}/assistant/feedback`, {
@ -326,18 +346,36 @@
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
...context, ...context,
feedback_tag: select.value, rating,
feedback_kind: kind.value, feedback_tag: tag,
optional_correction: correction.value.trim() feedback_kind: feedbackKind,
optional_correction: correctionText
}) })
}); });
const data = await readResponseJson(response); const data = await readResponseJson(response);
if (!response.ok) throw new Error(data.error || "Feedback could not be saved."); if (!response.ok) throw new Error(data.error || "Feedback could not be saved.");
controls.replaceChildren(document.createTextNode("Feedback saved.")); controls.replaceChildren(document.createTextNode("Thanks—feedback saved."));
} catch (error) { } catch (error) {
helpful.disabled = false;
needsWork.disabled = false;
submitFeedback.disabled = false; submitFeedback.disabled = false;
submitFeedback.textContent = error.message || "Try again"; submitFeedback.textContent = error.message || "Try again";
} }
};
helpful.addEventListener("click", () => sendFeedback({ tag: "good", rating: "up" }), { signal: listeners.signal });
needsWork.addEventListener("click", () => {
details.hidden = false;
needsWork.setAttribute("aria-expanded", "true");
select.focus();
}, { signal: listeners.signal });
controls.addEventListener("submit", async (event) => {
event.preventDefault();
await sendFeedback({
tag: select.value,
rating: "down",
correctionText: correction.value.trim(),
feedbackKind: kind.value
});
}, { signal: listeners.signal }); }, { signal: listeners.signal });
item.append(controls); item.append(controls);
}; };

View File

@ -9,9 +9,4 @@
if (closer) closer.closest("dialog")?.close(); if (closer) closer.closest("dialog")?.close();
}); });
document.addEventListener("submit", (event) => {
const form = event.target.closest("[data-improvement-confirm]");
if (!form) return;
if (!window.confirm(form.dataset.improvementConfirm || "Continue?")) event.preventDefault();
});
})(); })();

View File

@ -219,6 +219,7 @@
form.dataset.confirmMode = "modal"; form.dataset.confirmMode = "modal";
form.dataset.confirmTitle = `Delete ${tool.display_name || tool.tool_id}?`; form.dataset.confirmTitle = `Delete ${tool.display_name || tool.tool_id}?`;
form.dataset.confirmText = "The installed AI tool files will be removed. Shared Lumi AI data and unrelated plugins are not affected."; form.dataset.confirmText = "The installed AI tool files will be removed. Shared Lumi AI data and unrelated plugins are not affected.";
form.dataset.confirmLabel = "Delete tool";
const remove = button("Delete", "danger"); const remove = button("Delete", "danger");
remove.type = "submit"; remove.type = "submit";
form.append(remove); form.append(remove);

View File

@ -92,6 +92,37 @@ async function run() {
assert.equal(validateToolDirectory(path.join(pluginsDir, "lumi_ai_weather")).version, "1.1.0"); assert.equal(validateToolDirectory(path.join(pluginsDir, "lumi_ai_weather")).version, "1.1.0");
assert.equal(fs.readFileSync(path.join(pluginsDir, "lumi_ai_weather", "data", "local.json"), "utf8"), "preserve"); assert.equal(fs.readFileSync(path.join(pluginsDir, "lumi_ai_weather", "data", "local.json"), "utf8"), "preserve");
const installedWeather = path.join(pluginsDir, "lumi_ai_weather");
createTool(path.join(remoteDir, "lumi_ai_weather"), metadata("lumi_ai_weather", "1.2.0"), backendSource("updated again"));
remoteTools.set("lumi_ai_weather", metadata("lumi_ai_weather", "1.2.0"));
const originalRename = fs.renameSync;
fs.renameSync = (source, destination) => {
if (source === installedWeather && path.basename(destination).startsWith(".lumi_ai_weather.backup-")) {
const error = new Error("simulated locked tool directory");
error.code = "EPERM";
throw error;
}
return originalRename(source, destination);
};
try {
await assert.rejects(() => manager.update("lumi_ai_weather"), /locked tool directory/);
} finally {
fs.renameSync = originalRename;
}
assert.equal(validateToolDirectory(installedWeather).version, "1.1.0");
assert.equal(fs.readFileSync(path.join(installedWeather, "data", "local.json"), "utf8"), "preserve");
await manager.enable("lumi_ai_weather");
createTool(
path.join(remoteDir, "lumi_ai_weather"),
metadata("lumi_ai_weather", "1.2.0"),
'module.exports.register = () => { throw new Error("simulated activation failure"); };\n'
);
await assert.rejects(() => manager.update("lumi_ai_weather"), /simulated activation failure/);
assert.equal(validateToolDirectory(installedWeather).version, "1.1.0");
assert.equal(loader.isEnabled("lumi_ai_weather"), true);
assert.equal(registry.tools.has("lumi_ai_weather.lookup"), true);
repoClient.fail = true; repoClient.fail = true;
await assert.rejects(() => manager.update("lumi_ai_weather"), /required|missing/i); await assert.rejects(() => manager.update("lumi_ai_weather"), /required|missing/i);
assert.equal(validateToolDirectory(path.join(pluginsDir, "lumi_ai_weather")).version, "1.1.0"); assert.equal(validateToolDirectory(path.join(pluginsDir, "lumi_ai_weather")).version, "1.1.0");

View File

@ -7,7 +7,7 @@ const { ToolRegistry } = require("../backend/tool_router");
const { RequestQueue } = require("../backend/queue_manager"); const { RequestQueue } = require("../backend/queue_manager");
const { RuntimeManager, combinedResourceEstimate, runCaptured, buildRuntimeArgs } = require("../backend/runtime_manager"); const { RuntimeManager, combinedResourceEstimate, runCaptured, buildRuntimeArgs } = require("../backend/runtime_manager");
const { DEFAULT_CONFIG, getRuntimeState } = require("../backend/config_manager"); const { DEFAULT_CONFIG, getRuntimeState } = require("../backend/config_manager");
const { AiProvider, normalizeHistory, normalizeInferenceDiagnostics, resolveOutputBudget } = require("../backend/ai_provider"); const { AiProvider, normalizeHistory, normalizeInferenceDiagnostics, resolveOutputBudget, buildFeedbackContextSnapshot } = require("../backend/ai_provider");
const { GateProvider, similarity, stripForcePrefix, isSensitiveRequest, isSimpleKnowledgeLookup, classifyRequestType, withTimeout } = require("../backend/gate_provider"); const { GateProvider, similarity, stripForcePrefix, isSensitiveRequest, isSimpleKnowledgeLookup, classifyRequestType, withTimeout } = require("../backend/gate_provider");
const { const {
buildControllerDecision, buildControllerDecision,
@ -30,7 +30,7 @@ const modelManifest = require("../models_manifest.json");
const runtimeManifest = require("../runtime_manifest.json"); const runtimeManifest = require("../runtime_manifest.json");
const storage = require("../backend/storage"); const storage = require("../backend/storage");
const { formatBytes, bytesFromMb, sanityCheckSize } = require("../backend/size_utils"); const { formatBytes, bytesFromMb, sanityCheckSize } = require("../backend/size_utils");
const { record: recordMetric, paginateRows, summarizeMetrics, isValidTiming, workHistoryPage } = require("../backend/metrics"); const { record: recordMetric, paginateRows, summarizeMetrics, isValidTiming, workHistoryPage, workHistoryExport, groupWorkRows } = require("../backend/metrics");
const { AiAccessControl } = require("../backend/access_control"); const { AiAccessControl } = require("../backend/access_control");
const { AiRateLimiter, mergeLimits } = require("../backend/rate_limits"); const { AiRateLimiter, mergeLimits } = require("../backend/rate_limits");
const { PLATFORM_DEFAULTS, buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("../backend/commands"); const { PLATFORM_DEFAULTS, buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("../backend/commands");
@ -127,7 +127,7 @@ async function run() {
user_id: "history-user", user_id: "history-user",
role: "admin", role: "admin",
origin: "webui", origin: "webui",
prompt: "Tell me about doing a GET request to /setup/twitch", prompt: "Tell history-user about GET /setup/twitch using C:\\private\\notes.txt password=hunter2",
prompt_tokens: 12 prompt_tokens: 12
}); });
recordMetric({ recordMetric({
@ -172,6 +172,16 @@ async function run() {
assert.equal(workPage.entries[0].final_tokens, 40); assert.equal(workPage.entries[0].final_tokens, 40);
assert.equal(workPage.entries[0].delivered_tokens, 35); assert.equal(workPage.entries[0].delivered_tokens, 35);
assert(workPage.entries[0].events.some((event) => event.type === "prompt")); assert(workPage.entries[0].events.some((event) => event.type === "prompt"));
const workExport = workHistoryExport(workRequestId);
const workExportText = JSON.stringify(workExport);
assert.equal(workExport.schema, "lumi.ai.work.codex.v1");
assert.equal(workExportText.includes("history-user"), false);
assert.equal(workExportText.includes("hunter2"), false);
assert.equal(workExportText.includes("C:\\\\private"), false);
assert(workExport.privacy.removed.includes("raw diagnostics"));
const legacyGroups = groupWorkRows([{ timestamp: "2025-01-01T00:00:00.000Z", kind: "request", status: "failed", message: "legacy failure" }]);
assert.equal(legacyGroups.length, 1);
assert(legacyGroups[0].work_id.startsWith("legacy-"));
const panelTemplate = require("path").join(PLUGIN_ROOT, "views", "assistant-panel.ejs"); const panelTemplate = require("path").join(PLUGIN_ROOT, "views", "assistant-panel.ejs");
const panelDiagnostic = new AssistantPanelDiagnostics(panelTemplate); const panelDiagnostic = new AssistantPanelDiagnostics(panelTemplate);
@ -211,14 +221,20 @@ async function run() {
assert(settingsTemplate.includes("Improvement Center")); assert(settingsTemplate.includes("Improvement Center"));
assert(settingsTemplate.includes("work.has_truncation")); assert(settingsTemplate.includes("work.has_truncation"));
assert(settingsTemplate.includes("work.processed_tokens")); assert(settingsTemplate.includes("work.processed_tokens"));
assert(settingsTemplate.includes("Export for Codex"));
assert(settingsTemplate.includes("raw diagnostics, and tool payloads removed"));
const improvementTemplate = fs.readFileSync(path.join(PLUGIN_ROOT, "views", "improvement-center.ejs"), "utf8"); const improvementTemplate = fs.readFileSync(path.join(PLUGIN_ROOT, "views", "improvement-center.ejs"), "utf8");
for (const control of ["Review queue", "Save Corrections", "Run all evals", "Export instruction JSONL", "Export DPO JSONL"]) { for (const control of ["Review queue", "Save Corrections", "Run all evals", "Export instruction JSONL", "Export DPO JSONL"]) {
assert(improvementTemplate.includes(control)); assert(improvementTemplate.includes(control));
} }
assert(improvementTemplate.includes('data-confirm-title="Delete eval case"')); assert(improvementTemplate.includes('data-confirm-title="Delete eval case"'));
assert(improvementTemplate.includes("Sources used for this answer"));
assert(improvementTemplate.includes("Searchable OKF title"));
const assistantFeedbackScript = fs.readFileSync(path.join(PLUGIN_ROOT, "public", "assistant.js"), "utf8"); const assistantFeedbackScript = fs.readFileSync(path.join(PLUGIN_ROOT, "public", "assistant.js"), "utf8");
for (const tag of FEEDBACK_TAGS) assert(assistantFeedbackScript.includes(`"${tag}"`)); for (const tag of FEEDBACK_TAGS) assert(assistantFeedbackScript.includes(`"${tag}"`));
assert(assistantFeedbackScript.includes("/assistant/feedback")); assert(assistantFeedbackScript.includes("/assistant/feedback"));
assert(assistantFeedbackScript.includes('helpful.textContent = "Helpful"'));
assert(assistantFeedbackScript.includes('needsWork.textContent = "Needs work"'));
const settingsFrontendScript = fs.readFileSync(path.join(PLUGIN_ROOT, "public", "settings.js"), "utf8"); const settingsFrontendScript = fs.readFileSync(path.join(PLUGIN_ROOT, "public", "settings.js"), "utf8");
for (const marker of ["preparing_install", "installing", "Stopping runtimes", "Downloading dependency"]) { for (const marker of ["preparing_install", "installing", "Stopping runtimes", "Downloading dependency"]) {
assert(settingsFrontendScript.includes(marker)); assert(settingsFrontendScript.includes(marker));
@ -252,13 +268,28 @@ async function run() {
origin: "webui", origin: "webui",
platform: "webui", platform: "webui",
model: "test-model", model: "test-model",
rating: "down",
feedback_tag: "wrong_link", feedback_tag: "wrong_link",
optional_correction: "Use /admin/settings." optional_correction: "Use /admin/settings.",
context_snapshot: {
retrieval: { query: "settings", candidate_count: 5, selected_count: 1, depth: "light" },
okf: ["Verified settings context"],
repository: ["GET /admin/settings"],
tools: ["settings_lookup"]
}
}, { id: "reporter" }); }, { id: "reporter" });
assert.deepEqual( assert.equal(review.rating, "down");
Object.keys(review).filter((key) => /prompt|context|reasoning/i.test(key)), assert.equal(review.context_snapshot.retrieval.candidate_count, 5);
[] assert.deepEqual(review.context_snapshot.tools, ["settings_lookup"]);
); assert.equal(feedbackStore.list({ viewerRole: "mod" }).entries.length, 0);
assert.equal(feedbackStore.list({ viewerRole: "admin" }).entries.length, 1);
const safeSnapshot = buildFeedbackContextSnapshot({
contextBlocks: [`Open ${process.cwd()}\\private.md; password=secret`],
contextDiagnostics: [{ provider: "okf", query: "settings", candidate_count: 3 }],
controllerDecision: { okf_retrieval: "light" }
});
assert.equal(safeSnapshot.okf[0].includes(process.cwd()), false);
assert(safeSnapshot.okf[0].includes("[redacted]"));
feedbackStore.verify(review.id, { id: "trusted-mod" }); feedbackStore.verify(review.id, { id: "trusted-mod" });
assert.equal(feedbackStore.get(review.id).status, "verified"); assert.equal(feedbackStore.get(review.id).status, "verified");
feedbackStore.setStatus(review.id, "approved", { id: "admin" }); feedbackStore.setStatus(review.id, "approved", { id: "admin" });
@ -284,6 +315,10 @@ async function run() {
explicitly_safe: true, explicitly_safe: true,
enabled: true enabled: true
}, { id: "admin" }); }, { id: "admin" });
correctionStore.linkOkf(correction.id, "knowledge/corrections/lumi-ai-review.md");
feedbackStore.linkOkfCorrection(review.id, "knowledge/corrections/lumi-ai-review.md", { id: "admin" });
assert.equal(correctionStore.get(correction.id).linked_okf_path, "knowledge/corrections/lumi-ai-review.md");
assert.equal(feedbackStore.get(review.id).linked_okf_correction, "knowledge/corrections/lumi-ai-review.md");
assert.equal(correction.active, false); assert.equal(correction.active, false);
assert.equal(correctionStore.match({ assert.equal(correctionStore.match({
message: review.user_message, message: review.user_message,

View File

@ -32,18 +32,18 @@
<section class="ai-band" id="reviews"> <section class="ai-band" id="reviews">
<div class="ai-section-heading"> <div class="ai-section-heading">
<div><h2>Review queue</h2><p>Feedback records contain the user message, assistant answer, delivery metadata, tag, and optional correction only.</p></div> <div><h2>Review queue</h2><p>Review ratings, the answer, and a privacy-limited snapshot of the knowledge sources that informed it. Only admins can turn approved feedback into searchable OKF corrections.</p></div>
<div class="improvement-filters"> <div class="improvement-filters">
<% ["", "pending", "flagged", "verified", "approved", "rejected"].forEach((status) => { %> <% ["", "pending", "flagged", "verified", "approved", "reviewed", "rejected", "archived"].forEach((status) => { %>
<a class="button subtle" href="?status=<%= status %>#reviews"><%= status || "All" %></a> <a class="button subtle" href="?status=<%= status %>#reviews"><%= status || "All" %></a>
<% }) %> <% }) %>
</div> </div>
</div> </div>
<div class="improvement-list"> <div class="improvement-list">
<% reviews.entries.forEach((review) => { %> <% reviews.entries.forEach((review) => { %>
<article class="improvement-card"> <article class="improvement-card" id="review-<%= review.id %>">
<header> <header>
<div><strong><%= review.feedback_tag %></strong> <span class="ai-tag"><%= review.feedback_kind || "strict_correction" %></span> <span class="ai-tag"><%= review.status %></span></div> <div><strong><%= review.rating === "up" ? "Helpful" : "Needs work" %></strong> <span class="ai-tag"><%= review.feedback_tag.replaceAll("_", " ") %></span> <span class="ai-tag"><%= review.feedback_kind || "strict_correction" %></span> <span class="ai-tag"><%= review.status %></span></div>
<span><%= formatDate(review.timestamp) %> · <%= review.role %> · <%= review.platform %> · <%= review.route_used || "unknown route" %></span> <span><%= formatDate(review.timestamp) %> · <%= review.role %> · <%= review.platform %> · <%= review.route_used || "unknown route" %></span>
</header> </header>
<div class="improvement-pair"> <div class="improvement-pair">
@ -51,6 +51,18 @@
<div><span>Assistant answer</span><pre><%= review.assistant_answer %></pre></div> <div><span>Assistant answer</span><pre><%= review.assistant_answer %></pre></div>
</div> </div>
<% if (review.optional_correction) { %><div class="improvement-correction"><strong><%= review.feedback_kind === "instruction_based" ? "Instruction guidance" : "Suggested correction" %></strong><pre><%= review.optional_correction %></pre></div><% } %> <% if (review.optional_correction) { %><div class="improvement-correction"><strong><%= review.feedback_kind === "instruction_based" ? "Instruction guidance" : "Suggested correction" %></strong><pre><%= review.optional_correction %></pre></div><% } %>
<% const snapshot = review.context_snapshot || {}; const retrieval = snapshot.retrieval || {}; const snapshotRows = [...(snapshot.okf || []), ...(snapshot.corrections || []), ...(snapshot.repository || [])]; %>
<% if (snapshotRows.length || retrieval.query || (snapshot.tools || []).length) { %>
<details class="lumi-expandable-settings">
<summary><strong>Sources used for this answer</strong><span class="hint"><%= retrieval.selected_count || snapshotRows.length %> selected · <%= retrieval.candidate_count || 0 %> considered</span></summary>
<div class="lumi-expandable-body">
<% if (retrieval.query) { %><p class="hint"><strong>Lookup:</strong> <%= retrieval.query %> · depth <%= retrieval.depth || "unknown" %></p><% } %>
<% snapshotRows.forEach((source) => { %><pre><%= source %></pre><% }) %>
<% if ((snapshot.tools || []).length) { %><p class="hint"><strong>Available/selected tools:</strong> <%= snapshot.tools.join(", ") %></p><% } %>
</div>
</details>
<% } %>
<% if (review.linked_okf_correction) { %><p class="hint"><strong>Searchable correction:</strong> <a href="/plugins/okf?q=<%= encodeURIComponent(review.user_message.slice(0, 80)) %>"><%= review.linked_okf_correction %></a></p><% } %>
<% if (review.review_notes) { %><p class="hint"><strong>Review notes:</strong> <%= review.review_notes %></p><% } %> <% if (review.review_notes) { %><p class="hint"><strong>Review notes:</strong> <%= review.review_notes %></p><% } %>
<div class="improvement-actions"> <div class="improvement-actions">
<% if (access.can_flag) { %> <% if (access.can_flag) { %>
@ -66,6 +78,10 @@
</form> </form>
<% } %> <% } %>
<% if (access.can_approve) { %> <% if (access.can_approve) { %>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>">
<input type="hidden" name="action" value="reviewed" />
<button class="button subtle" type="submit">No action needed</button>
</form>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>"> <form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>">
<input type="hidden" name="action" value="approve" /> <input type="hidden" name="action" value="approve" />
<button class="button" type="submit">Approve</button> <button class="button" type="submit">Approve</button>
@ -80,10 +96,12 @@
<input type="hidden" name="action" value="export" /> <input type="hidden" name="action" value="export" />
<button class="button subtle" type="submit"><%= review.export_approved ? "Export approved" : "Approve for export" %></button> <button class="button subtle" type="submit"><%= review.export_approved ? "Export approved" : "Approve for export" %></button>
</form> </form>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" data-improvement-confirm="Delete this feedback record?"> <form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete feedback record" data-confirm-text="Delete this feedback record permanently?" data-confirm-label="Delete feedback">
<input type="hidden" name="action" value="delete" />
<button class="button danger" type="submit">Delete</button> <button class="button danger" type="submit">Delete</button>
</form> </form>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>/archive" data-confirm-mode="modal" data-confirm-title="Archive feedback record" data-confirm-text="Archive this feedback record and hide it from the active review queue?" data-confirm-label="Archive feedback">
<button class="button danger" type="submit">Archive</button>
</form>
<% } %> <% } %>
</div> </div>
</article> </article>
@ -107,6 +125,7 @@
<div class="field"><label>Promotion target</label><select name="target"><% promotionTargets.forEach((target) => { %><option value="<%= target %>"><%= target.replaceAll("_", " ") %></option><% }) %></select></div> <div class="field"><label>Promotion target</label><select name="target"><% promotionTargets.forEach((target) => { %><option value="<%= target %>"><%= target.replaceAll("_", " ") %></option><% }) %></select></div>
<div class="field"><label>Minimum role</label><select name="min_role"><% ["user", "mod", "admin"].forEach((role) => { %><option value="<%= role %>" <%= role === review.role ? "selected" : "" %>><%= role %></option><% }) %></select></div> <div class="field"><label>Minimum role</label><select name="min_role"><% ["user", "mod", "admin"].forEach((role) => { %><option value="<%= role %>" <%= role === review.role ? "selected" : "" %>><%= role %></option><% }) %></select></div>
<div class="field full"><label><%= review.feedback_kind === "instruction_based" ? "Instruction to apply" : "Corrected / expected answer" %></label><textarea name="corrected_answer" rows="7" required><%= review.optional_correction %></textarea></div> <div class="field full"><label><%= review.feedback_kind === "instruction_based" ? "Instruction to apply" : "Corrected / expected answer" %></label><textarea name="corrected_answer" rows="7" required><%= review.optional_correction %></textarea></div>
<div class="field full"><label>Searchable OKF title</label><input name="okf_title" placeholder="Short title for this correction" /><span class="hint">When the target is Correction, Lumi saves an editable, searchable OKF file after approval.</span></div>
<div class="field"><label>Origin scope</label><input name="permission_origin" value="<%= review.origin || "any" %>" /></div> <div class="field"><label>Origin scope</label><input name="permission_origin" value="<%= review.origin || "any" %>" /></div>
<div class="field"><label>Platform scope</label><input name="permission_platform" value="<%= review.platform || "any" %>" /></div> <div class="field"><label>Platform scope</label><input name="permission_platform" value="<%= review.platform || "any" %>" /></div>
<div class="field"><label>Route alias</label><input name="route_alias" /></div> <div class="field"><label>Route alias</label><input name="route_alias" /></div>
@ -146,7 +165,8 @@
<td><span class="ai-tag"><%= entry.active ? "active" : entry.enabled ? "staged" : "disabled" %></span></td> <td><span class="ai-tag"><%= entry.active ? "active" : entry.enabled ? "staged" : "disabled" %></span></td>
<td class="improvement-actions"> <td class="improvement-actions">
<% if (access.can_verify) { %><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>"><input type="hidden" name="action" value="verify" /><button class="button subtle" type="submit">Verify</button></form><% } %> <% if (access.can_verify) { %><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>"><input type="hidden" name="action" value="verify" /><button class="button subtle" type="submit">Verify</button></form><% } %>
<% if (access.can_edit) { %><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>"><input type="hidden" name="action" value="toggle" /><input type="hidden" name="enabled" value="<%= entry.enabled ? "off" : "on" %>" /><button class="button subtle" type="submit"><%= entry.enabled ? "Disable" : "Enable" %></button></form><button class="button subtle" type="button" data-open-dialog="correction-<%= entry.id %>">Edit</button><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>" data-improvement-confirm="Delete this correction?"><input type="hidden" name="action" value="delete" /><button class="button danger" type="submit">Delete</button></form><% } %> <% if (entry.linked_okf_path) { %><a class="button subtle" href="/plugins/okf?q=<%= encodeURIComponent(entry.prompt.slice(0, 80)) %>">Open OKF</a><% } %>
<% if (access.can_edit) { %><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>"><input type="hidden" name="action" value="toggle" /><input type="hidden" name="enabled" value="<%= entry.enabled ? "off" : "on" %>" /><button class="button subtle" type="submit"><%= entry.enabled ? "Disable" : "Enable" %></button></form><button class="button subtle" type="button" data-open-dialog="correction-<%= entry.id %>">Edit</button><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete correction" data-confirm-text="Delete this correction permanently?" data-confirm-label="Delete correction"><button class="button danger" type="submit">Delete</button></form><% } %>
</td> </td>
</tr> </tr>
<% if (access.can_edit) { %> <% if (access.can_edit) { %>

View File

@ -103,7 +103,7 @@
] ]
}) %> }) %>
</form> </form>
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/delete" data-confirm-title="Delete model" data-confirm-text="Delete <%= model.label %> and recover <%= formatBytes(model.installed_size || model.size) %>?" data-confirm-label="Delete model"> <form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete model" data-confirm-text="Delete <%= model.label %> and recover <%= formatBytes(model.installed_size || model.size) %>?" data-confirm-label="Delete model">
<input type="hidden" name="confirm" value="yes" /> <input type="hidden" name="confirm" value="yes" />
<button class="button danger" type="submit" <%= (model.id === config.selected_model_id && runtimeStatus.state === "running") || (model.id === config.gate.model_id && gateStatus.state === "running") ? "disabled title='Stop the AI runtimes before deleting an active model'" : "" %>>Delete</button> <button class="button danger" type="submit" <%= (model.id === config.selected_model_id && runtimeStatus.state === "running") || (model.id === config.gate.model_id && gateStatus.state === "running") ? "disabled title='Stop the AI runtimes before deleting an active model'" : "" %>>Delete</button>
</form> </form>
@ -258,7 +258,7 @@
<div><span><%= category.replace("_", " ") %></span><strong><%= formatBytes(bytes) %></strong></div> <div><span><%= category.replace("_", " ") %></span><strong><%= formatBytes(bytes) %></strong></div>
<% }) %> <% }) %>
</div> </div>
<form method="post" action="/plugins/lumi_ai/storage/cleanup" class="ai-cleanup-form" data-confirm-title="Clean AI storage" data-confirm-text="Delete the selected plugin-local storage categories?" data-confirm-label="Clean selected"> <form method="post" action="/plugins/lumi_ai/storage/cleanup" class="ai-cleanup-form" data-confirm-mode="modal" data-confirm-title="Clean AI storage" data-confirm-text="Delete the selected plugin-local storage categories?" data-confirm-label="Clean selected">
<label><input type="checkbox" name="categories" value="unused_models" /> Unused models</label> <label><input type="checkbox" name="categories" value="unused_models" /> Unused models</label>
<label><input type="checkbox" name="categories" value="runtime_archives" /> Runtime archives</label> <label><input type="checkbox" name="categories" value="runtime_archives" /> Runtime archives</label>
<label><input type="checkbox" name="categories" value="logs" /> Old logs</label> <label><input type="checkbox" name="categories" value="logs" /> Old logs</label>
@ -727,6 +727,8 @@
<div class="ai-work-prompt"> <div class="ai-work-prompt">
<strong>Original prompt</strong> <strong>Original prompt</strong>
<pre><%= work.prompt || "Prompt was not recorded for this legacy entry." %></pre> <pre><%= work.prompt || "Prompt was not recorded for this legacy entry." %></pre>
<a class="button subtle" href="/plugins/lumi_ai/work-history/<%= encodeURIComponent(work.work_id) %>/export">Export for Codex</a>
<span class="hint">Downloads a task-focused JSON file with identities, secrets, local paths, raw diagnostics, and tool payloads removed.</span>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="table ai-work-events"> <table class="table ai-work-events">
@ -773,7 +775,7 @@
<td class="ai-table-actions"> <td class="ai-table-actions">
<a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>">View</a> <a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>">View</a>
<a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/download">Download</a> <a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/download">Download</a>
<form method="post" action="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/delete" data-confirm-title="Delete AI runtime log" data-confirm-text="Delete <%= file.name %>?" data-confirm-label="Delete log"><button class="button danger" type="submit">Delete</button></form> <form method="post" action="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/delete" data-confirm-mode="modal" data-confirm-title="Delete AI runtime log" data-confirm-text="Delete <%= file.name %>?" data-confirm-label="Delete log"><button class="button danger" type="submit">Delete</button></form>
</td> </td>
</tr> </tr>
<% }) %> <% }) %>

View File

@ -2,6 +2,11 @@ const path = require("path");
const fs = require("fs"); const fs = require("fs");
const crypto = require("crypto"); const crypto = require("crypto");
const multer = require("multer"); const multer = require("multer");
const {
cleanupUploadedFiles,
safeDownloadFilename,
validateUploadedFiles
} = require("../../src/services/upload-security");
const PLUGIN_ID = "moderation"; const PLUGIN_ID = "moderation";
const EVIDENCE_DIR = path.join(__dirname, "..", "..", "data", "moderation", "evidence"); const EVIDENCE_DIR = path.join(__dirname, "..", "..", "data", "moderation", "evidence");
@ -34,7 +39,35 @@ module.exports = {
const ext = path.extname(file.originalname || ".png").slice(0, 10); const ext = path.extname(file.originalname || ".png").slice(0, 10);
cb(null, `${crypto.randomUUID()}${ext}`); cb(null, `${crypto.randomUUID()}${ext}`);
} }
}) }),
limits: { fileSize: 8 * 1024 * 1024, files: 4, fields: 40, parts: 45 },
fileFilter: (_req, file, cb) => {
if (["image/png", "image/jpeg", "image/webp"].includes(file.mimetype)) return cb(null, true);
cb(new Error("Evidence must be a PNG, JPEG, or WebP image."));
}
});
const evidenceUpload = (req, res, next) => upload.array("evidence_files", 4)(req, res, (error) => {
if (error) {
cleanupUploadedFiles(req.files);
req.files = [];
req.evidenceUploadError = error.code === "LIMIT_FILE_SIZE"
? "Each evidence image must be 8 MB or smaller."
: error.message;
return next();
}
try {
validateUploadedFiles(req.files, {
allowedTypes: ["png", "jpeg", "webp"],
maxBytes: 8 * 1024 * 1024,
requireExtension: true,
label: "evidence image"
});
} catch (validationError) {
cleanupUploadedFiles(req.files);
req.files = [];
req.evidenceUploadError = validationError.message;
}
next();
}); });
installGlobalGate(app, (req, res, next) => { installGlobalGate(app, (req, res, next) => {
@ -145,21 +178,37 @@ module.exports = {
message: "Evidence file not found." message: "Evidence file not found."
}); });
} }
res.download(row.file_path, row.file_name || path.basename(row.file_path)); const evidencePath = path.resolve(row.file_path);
const evidenceRoot = `${path.resolve(EVIDENCE_DIR)}${path.sep}`;
if (!evidencePath.startsWith(evidenceRoot) || !fs.existsSync(evidencePath)) {
return res.status(404).render("error", {
title: "Not found",
message: "Evidence file not found."
});
}
res.download(evidencePath, safeDownloadFilename(row.file_name, "evidence"));
}); });
router.post("/actions", upload.array("evidence_files", 4), async (req, res) => { router.post("/actions", (req, res, next) => {
if (!req.session.user) { if (!req.session.user) {
return res.redirect("/"); return res.redirect("/");
} }
const isAdmin = Boolean(req.session.user?.isAdmin);
const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod);
if (!isMod) { if (!isMod) {
return deny(res); return deny(res);
} }
next();
}, evidenceUpload, async (req, res) => {
const isAdmin = Boolean(req.session.user?.isAdmin);
const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod);
if (req.evidenceUploadError) {
req.session.flash = { type: "error", message: req.evidenceUploadError };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
const actionType = (req.body.action_type || "").toLowerCase(); const actionType = (req.body.action_type || "").toLowerCase();
if (actionType === "kick") { if (actionType === "kick") {
cleanupUploadedFiles(req.files);
req.session.flash = { req.session.flash = {
type: "info", type: "info",
message: "Kick actions are coming soon." message: "Kick actions are coming soon."
@ -169,6 +218,7 @@ module.exports = {
const target = resolveTarget(db, req.body); const target = resolveTarget(db, req.body);
if (!target) { if (!target) {
cleanupUploadedFiles(req.files);
req.session.flash = { type: "error", message: "Target not found." }; req.session.flash = { type: "error", message: "Target not found." };
return res.redirect(`/plugins/${PLUGIN_ID}`); return res.redirect(`/plugins/${PLUGIN_ID}`);
} }
@ -176,6 +226,7 @@ module.exports = {
const reasonShort = (req.body.reason_short || "").trim(); const reasonShort = (req.body.reason_short || "").trim();
const reasonDetail = (req.body.reason_detail || "").trim(); const reasonDetail = (req.body.reason_detail || "").trim();
if (!reasonShort || !reasonDetail) { if (!reasonShort || !reasonDetail) {
cleanupUploadedFiles(req.files);
req.session.flash = { req.session.flash = {
type: "error", type: "error",
message: "Both summary and detailed reasons are required." message: "Both summary and detailed reasons are required."

View File

@ -217,7 +217,8 @@
</div> </div>
<div class="field full"> <div class="field full">
<label>Evidence (optional)</label> <label>Evidence (optional)</label>
<input type="file" name="evidence_files" accept="image/*" multiple /> <input type="file" name="evidence_files" accept="image/png,image/jpeg,image/webp" multiple />
<span class="hint">Up to 4 PNG, JPEG, or WebP images, 8 MB each.</span>
</div> </div>
<button type="submit" class="button">Submit action</button> <button type="submit" class="button">Submit action</button>
</form> </form>
@ -309,7 +310,7 @@
<td><%= new Date(note.created_at).toLocaleString() %></td> <td><%= new Date(note.created_at).toLocaleString() %></td>
<% if (isAdmin) { %> <% if (isAdmin) { %>
<td> <td>
<form method="post" action="/plugins/moderation/notes/<%= note.id %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-text="Delete this moderation note? This removes the note permanently."> <form method="post" action="/plugins/moderation/notes/<%= note.id %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-title="Delete moderation note" data-confirm-text="Delete this moderation note? This removes the note permanently." data-confirm-label="Delete note">
<button type="submit" class="button danger">Delete</button> <button type="submit" class="button danger">Delete</button>
</form> </form>
</td> </td>

View File

@ -118,10 +118,10 @@
<span class="switch-track" aria-hidden="true"></span> <span class="switch-track" aria-hidden="true"></span>
<span class="switch-text">Permanent</span> <span class="switch-text">Permanent</span>
</label> </label>
<button type="submit" class="button subtle">Update</button> <button type="submit" class="button subtle">Update duration</button>
</form> </form>
<% } %> <% } %>
<form method="post" action="/plugins/moderation/actions/<%= sanction.id %>/revoke" class="inline-form"> <form method="post" action="/plugins/moderation/actions/<%= sanction.id %>/revoke" class="inline-form" data-confirm-mode="modal" data-confirm-title="Revoke moderation action" data-confirm-text="Revoke this moderation action?" data-confirm-label="Revoke action">
<button type="submit" class="button danger">Revoke</button> <button type="submit" class="button danger">Revoke</button>
</form> </form>
</div> </div>

View File

@ -1,5 +1,6 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { writeTextAtomicSync } = require("../../../src/services/safe-files");
const KNOWLEDGE_SCOPES = Object.freeze(["corrections", "community", "plugins", "core"]); const KNOWLEDGE_SCOPES = Object.freeze(["corrections", "community", "plugins", "core"]);
const SCOPE_PRIORITY = Object.freeze({ const SCOPE_PRIORITY = Object.freeze({
@ -26,8 +27,9 @@ const PLACEHOLDER_SUGGEST_RESERVED_KEYS = new Set([
"updated_at" "updated_at"
]); ]);
const SEARCH_STOPWORDS = new Set([ const SEARCH_STOPWORDS = new Set([
"a", "an", "and", "are", "about", "describe", "for", "identify", "is", "me", "a", "an", "and", "are", "about", "can", "could", "describe", "do", "does", "for",
"of", "please", "tell", "the", "to", "what", "who", "was", "were" "help", "how", "i", "identify", "is", "me", "my", "of", "please", "tell", "the", "to",
"use", "using", "what", "where", "who", "was", "were", "you", "your"
]); ]);
const knowledgeIndexCache = new Map(); const knowledgeIndexCache = new Map();
@ -102,6 +104,7 @@ function searchFileKnowledge({ query = "", user, limit = 5, rootDir = process.cw
const access = accessForUser(user); const access = accessForUser(user);
if (!access.authenticated) return []; if (!access.authenticated) return [];
const tokens = tokenSet(query); const tokens = tokenSet(query);
if (String(query || "").trim() && !tokens.size) return [];
const entries = resolveVisibleKnowledgePlaceholders(loadKnowledgeEntries(rootDir) const entries = resolveVisibleKnowledgePlaceholders(loadKnowledgeEntries(rootDir)
.filter((entry) => canSeeKnowledgeEntry(entry, access)) .filter((entry) => canSeeKnowledgeEntry(entry, access))
); );
@ -207,6 +210,7 @@ function listCommunityKnowledgeFiles(rootDir = process.cwd()) {
status: entry.status, status: entry.status,
visibility: entry.visibility, visibility: entry.visibility,
priority: entry.priority, priority: entry.priority,
category: entry.category,
tags: entry.tags, tags: entry.tags,
editable: entry.editable, editable: entry.editable,
generated: entry.generated, generated: entry.generated,
@ -216,6 +220,18 @@ function listCommunityKnowledgeFiles(rootDir = process.cwd()) {
.sort((a, b) => a.title.localeCompare(b.title)); .sort((a, b) => a.title.localeCompare(b.title));
} }
function listCorrectionKnowledgeFiles(rootDir = process.cwd()) {
return loadKnowledgeEntries(rootDir, { includeHidden: true })
.filter((entry) => entry.scope === "corrections")
.map((entry) => ({
...entry,
slug: entry.file_slug,
entry_slug: entry.slug,
path: `knowledge/${entry.path}`
}))
.sort((a, b) => b.priority - a.priority || a.title.localeCompare(b.title));
}
function getCommunityKnowledgeFile(rootDir = process.cwd(), slug) { function getCommunityKnowledgeFile(rootDir = process.cwd(), slug) {
const entry = loadKnowledgeEntries(rootDir, { includeHidden: true }) const entry = loadKnowledgeEntries(rootDir, { includeHidden: true })
.find((item) => item.scope === "community" && (item.file_slug === slug || item.slug === slug || item.id === slug)); .find((item) => item.scope === "community" && (item.file_slug === slug || item.slug === slug || item.id === slug));
@ -241,7 +257,7 @@ function saveCommunityKnowledgeFile(rootDir = process.cwd(), values = {}) {
} }
const metadata = normalizeCommunityFileValues(values, slug); const metadata = normalizeCommunityFileValues(values, slug);
const markdown = serializeKnowledgeFile(metadata, values.body || ""); const markdown = serializeKnowledgeFile(metadata, values.body || "");
fs.writeFileSync(filePath, markdown); writeTextAtomicSync(filePath, markdown);
knowledgeIndexCache.clear(); knowledgeIndexCache.clear();
parseKnowledgeFile(filePath, root, { includeHidden: true }); parseKnowledgeFile(filePath, root, { includeHidden: true });
return getCommunityKnowledgeFile(rootDir, slug); return getCommunityKnowledgeFile(rootDir, slug);
@ -253,7 +269,7 @@ function saveCorrectionKnowledgeFile(rootDir = process.cwd(), values = {}) {
const filePath = path.join(root, "corrections", `${slug}.md`); const filePath = path.join(root, "corrections", `${slug}.md`);
const metadata = normalizeCorrectionFileValues(values, slug); const metadata = normalizeCorrectionFileValues(values, slug);
const markdown = serializeKnowledgeFile(metadata, values.body || ""); const markdown = serializeKnowledgeFile(metadata, values.body || "");
fs.writeFileSync(filePath, markdown); writeTextAtomicSync(filePath, markdown);
knowledgeIndexCache.clear(); knowledgeIndexCache.clear();
const entry = parseKnowledgeFile(filePath, root, { includeHidden: true }); const entry = parseKnowledgeFile(filePath, root, { includeHidden: true });
return entry ? { return entry ? {
@ -518,7 +534,16 @@ function scoreKnowledgeChunk(entry, chunk, queryTokens) {
const text = [entry.title, entry.category, entry.tags.join(" "), chunk.heading, chunk.text].join(" "); const text = [entry.title, entry.category, entry.tags.join(" "), chunk.heading, chunk.text].join(" ");
const textTokens = tokenSet(text); const textTokens = tokenSet(text);
const overlap = queryTokens.size ? intersectionSize(queryTokens, textTokens) : 1; const overlap = queryTokens.size ? intersectionSize(queryTokens, textTokens) : 1;
const score = (overlap * 100) + SCOPE_PRIORITY[entry.scope] + Number(entry.priority || 0); const titleOverlap = queryTokens.size ? intersectionSize(queryTokens, tokenSet(`${entry.title} ${entry.tags.join(" ")}`)) : 0;
const headingOverlap = queryTokens.size ? intersectionSize(queryTokens, tokenSet(chunk.heading)) : 0;
const coverage = queryTokens.size ? overlap / queryTokens.size : 1;
const specificRouteBonus = /^(GET|POST|PUT|PATCH|DELETE|MOUNT)\s+\//i.test(chunk.heading) && headingOverlap
? 140
: 0;
const genericRoutePenalty = ["Routes", "Web Routes", "Route Reference"].includes(chunk.heading) ? 80 : 0;
const score = (overlap * 100) + (titleOverlap * 45) + (headingOverlap * 80) +
Math.round(coverage * 100) + specificRouteBonus - genericRoutePenalty +
SCOPE_PRIORITY[entry.scope] + Number(entry.priority || 0);
const excerpt = excerptForChunk(chunk.text, queryTokens); const excerpt = excerptForChunk(chunk.text, queryTokens);
return { return {
id: entry.id, id: entry.id,
@ -584,9 +609,14 @@ function tokenSet(value) {
const cleaned = cleanText(value, 4000); const cleaned = cleanText(value, 4000);
const expanded = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); const expanded = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
const tokens = new Set(); const tokens = new Set();
for (const token of `${cleaned} ${expanded}`.toLowerCase().split(/[^a-z0-9_]+/)) { for (const variant of [cleaned, expanded]) {
if (token.length < 2 || SEARCH_STOPWORDS.has(token)) continue; const words = variant.toLowerCase().split(/[^a-z0-9]+/)
tokens.add(token); .filter((token) => token.length >= 2 && !SEARCH_STOPWORDS.has(token));
words.forEach((token) => tokens.add(token));
for (let index = 0; index < words.length - 1; index += 1) {
const compact = `${words[index]}${words[index + 1]}`;
if (compact.length <= 80) tokens.add(compact);
}
} }
return tokens; return tokens;
} }
@ -663,6 +693,7 @@ module.exports = {
knowledgeRoot, knowledgeRoot,
listKnowledgeFiles, listKnowledgeFiles,
listCommunityKnowledgeFiles, listCommunityKnowledgeFiles,
listCorrectionKnowledgeFiles,
listKnowledgePlaceholders, listKnowledgePlaceholders,
loadKnowledgeEntries, loadKnowledgeEntries,
migrateSingleBracePlaceholders, migrateSingleBracePlaceholders,

View File

@ -2,19 +2,19 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const { ensureKnowledgeDirs, knowledgeRoot } = require("./file_knowledge"); const { ensureKnowledgeDirs, knowledgeRoot } = require("./file_knowledge");
const { writeTextAtomicSync } = require("../../../src/services/safe-files");
function generateKnowledgeFiles(rootDir = process.cwd()) { function generateKnowledgeFiles(rootDir = process.cwd()) {
const root = ensureKnowledgeDirs(rootDir); const root = ensureKnowledgeDirs(rootDir);
const generatedAt = new Date().toISOString();
const written = []; const written = [];
const coreFile = path.join(root, "core", "lumi-core.md"); const coreFile = path.join(root, "core", "lumi-core.md");
const coreMarkdown = buildCoreKnowledge(rootDir, generatedAt); const coreMarkdown = buildCoreKnowledge(rootDir);
if (writeGeneratedFile(coreFile, coreMarkdown)) { if (writeGeneratedFile(coreFile, coreMarkdown)) {
written.push(path.relative(rootDir, coreFile)); written.push(path.relative(rootDir, coreFile));
} }
for (const plugin of discoverPlugins(rootDir)) { for (const plugin of discoverPlugins(rootDir)) {
const pluginFile = path.join(root, "plugins", `${slugify(plugin.id)}.md`); const pluginFile = path.join(root, "plugins", `${slugify(plugin.id)}.md`);
const pluginMarkdown = buildPluginKnowledge(plugin, generatedAt); const pluginMarkdown = buildPluginKnowledge(plugin);
if (writeGeneratedFile(pluginFile, pluginMarkdown)) { if (writeGeneratedFile(pluginFile, pluginMarkdown)) {
written.push(path.relative(rootDir, pluginFile)); written.push(path.relative(rootDir, pluginFile));
} }
@ -22,7 +22,7 @@ function generateKnowledgeFiles(rootDir = process.cwd()) {
return written; return written;
} }
function buildCoreKnowledge(rootDir, generatedAt) { function buildCoreKnowledge(rootDir) {
const packageJson = readJson(path.join(rootDir, "package.json")); const packageJson = readJson(path.join(rootDir, "package.json"));
const readme = readText(path.join(rootDir, "README.md")); const readme = readText(path.join(rootDir, "README.md"));
const routes = discoverCoreRoutes(rootDir); const routes = discoverCoreRoutes(rootDir);
@ -37,8 +37,7 @@ function buildCoreKnowledge(rootDir, generatedAt) {
category: "Core", category: "Core",
tags: "core, routes, commands, settings", tags: "core, routes, commands, settings",
generated: true, generated: true,
editable: false, editable: false
updated_at: generatedAt
}) + [ }) + [
`# ${packageJson.name || "Lumi Core"}`, `# ${packageJson.name || "Lumi Core"}`,
"", "",
@ -64,7 +63,7 @@ function buildCoreKnowledge(rootDir, generatedAt) {
].filter((line) => line !== "").join("\n"); ].filter((line) => line !== "").join("\n");
} }
function buildPluginKnowledge(plugin, generatedAt) { function buildPluginKnowledge(plugin) {
const routeLines = plugin.routes.length const routeLines = plugin.routes.length
? plugin.routes.map((route) => `- ${route.method ? `${route.method.toUpperCase()} ` : ""}${route.path}`).join("\n") ? plugin.routes.map((route) => `- ${route.method ? `${route.method.toUpperCase()} ` : ""}${route.path}`).join("\n")
: "- No plugin routes detected."; : "- No plugin routes detected.";
@ -82,8 +81,7 @@ function buildPluginKnowledge(plugin, generatedAt) {
category: "Plugin", category: "Plugin",
tags: ["plugin", plugin.id, ...(plugin.keywords || [])].join(", "), tags: ["plugin", plugin.id, ...(plugin.keywords || [])].join(", "),
generated: true, generated: true,
editable: false, editable: false
updated_at: generatedAt
}) + [ }) + [
`# ${plugin.name || plugin.id}`, `# ${plugin.name || plugin.id}`,
"", "",
@ -221,8 +219,9 @@ function discoverCommandTriggers(source) {
function writeGeneratedFile(filePath, content) { function writeGeneratedFile(filePath, content) {
if (!canOverwriteGeneratedFile(filePath)) return false; if (!canOverwriteGeneratedFile(filePath)) return false;
fs.mkdirSync(path.dirname(filePath), { recursive: true }); const output = `${content.trim()}\n`;
fs.writeFileSync(filePath, `${content.trim()}\n`); if (fs.existsSync(filePath) && fs.readFileSync(filePath, "utf8") === output) return false;
writeTextAtomicSync(filePath, output);
return true; return true;
} }

View File

@ -4,6 +4,7 @@ const PERMISSION_LEVELS = Object.freeze(["edit", "edit_review", "edit_review_imp
const STATUS_VALUES = Object.freeze(["draft", "published", "archived"]); const STATUS_VALUES = Object.freeze(["draft", "published", "archived"]);
const VISIBILITY_VALUES = Object.freeze(["user", "mod", "admin"]); const VISIBILITY_VALUES = Object.freeze(["user", "mod", "admin"]);
const REVIEW_STATES = Object.freeze(["draft", "review_pending", "approved", "rejected"]); const REVIEW_STATES = Object.freeze(["draft", "review_pending", "approved", "rejected"]);
const VISIBILITY_RANK = Object.freeze({ user: 0, mod: 1, admin: 2 });
function ensureTables(db) { function ensureTables(db) {
db.exec(` db.exec(`
@ -80,29 +81,75 @@ function accessForUser(db, user) {
const okfPermission = okfPermissionForUser(db, user?.id); const okfPermission = okfPermissionForUser(db, user?.id);
const isAdmin = Boolean(user?.isAdmin); const isAdmin = Boolean(user?.isAdmin);
const isMod = Boolean(user?.isAdmin || user?.isMod); const isMod = Boolean(user?.isAdmin || user?.isMod);
const capabilities = Object.freeze({
read_user_fields: Boolean(user),
read_mod_fields: isMod,
read_admin_fields: isAdmin,
edit_content: isAdmin || ["edit", "edit_review", "edit_review_implement"].includes(okfPermission),
review: isAdmin || ["edit_review", "edit_review_implement"].includes(okfPermission),
implement: isAdmin || okfPermission === "edit_review_implement",
manage_permissions: isAdmin,
management_view: isAdmin || ["edit", "edit_review", "edit_review_implement"].includes(okfPermission)
});
return { return {
authenticated: Boolean(user), authenticated: Boolean(user),
isAdmin, isAdmin,
isMod, isMod,
okfPermission, okfPermission,
canEdit: isAdmin || ["edit", "edit_review", "edit_review_implement"].includes(okfPermission), capabilities,
canReview: isAdmin || ["edit_review", "edit_review_implement"].includes(okfPermission), canEdit: capabilities.edit_content,
canImplement: isAdmin || okfPermission === "edit_review_implement", canReview: capabilities.review,
canManagePermissions: isAdmin, canImplement: capabilities.implement,
maxVisibility: isAdmin ? "admin" : isMod ? "mod" : "user" canManagePermissions: capabilities.manage_permissions,
canViewManagement: capabilities.management_view,
maxVisibility: capabilities.read_admin_fields ? "admin" : capabilities.read_mod_fields ? "mod" : "user"
}; };
} }
function canSeeEntry(access, entry, { management = false } = {}) { function canSeeEntry(access, entry, { management = false } = {}) {
if (!access.authenticated || !entry || entry.deleted_at) return false; if (!access.authenticated || !entry || entry.deleted_at) return false;
if (access.isAdmin) return true; if (access.capabilities.read_admin_fields) return true;
if (management && access.canEdit) return true; if (!canReadVisibility(access, entry.visibility)) return false;
if (management && access.capabilities.management_view) return true;
if (entry.status !== "published") return false; if (entry.status !== "published") return false;
if (entry.visibility === "admin") return false;
if (entry.visibility === "mod" && !access.isMod) return false;
return true; return true;
} }
function canReadVisibility(access, visibility) {
const required = VISIBILITY_RANK[visibility] ?? VISIBILITY_RANK.user;
const maximum = VISIBILITY_RANK[access.maxVisibility] ?? VISIBILITY_RANK.user;
return required <= maximum;
}
function allowedVisibility(value, access, fallback = "user") {
const requested = normalizeChoice(value, VISIBILITY_VALUES, fallback);
if (canReadVisibility(access, requested)) return requested;
return canReadVisibility(access, fallback) ? fallback : access.maxVisibility;
}
function visibleSnapshot(snapshot, access) {
if (!snapshot || typeof snapshot !== "object") return {};
const visible = {
slug: snapshot.slug,
title: snapshot.title,
category: snapshot.category,
tags: Array.isArray(snapshot.tags) ? snapshot.tags : [],
aliases: Array.isArray(snapshot.aliases) ? snapshot.aliases : [],
summary: snapshot.summary,
user_markdown: snapshot.user_markdown,
visibility: snapshot.visibility,
status: snapshot.status,
review_state: snapshot.review_state
};
if (access.capabilities.read_mod_fields) visible.moderator_markdown = snapshot.moderator_markdown;
if (access.capabilities.read_admin_fields) {
visible.admin_markdown = snapshot.admin_markdown;
visible.ai_facts_markdown = snapshot.ai_facts_markdown;
visible.source_links = Array.isArray(snapshot.source_links) ? snapshot.source_links : [];
}
return visible;
}
function publicEntry(entry, access) { function publicEntry(entry, access) {
const base = { const base = {
id: entry.id, id: entry.id,
@ -119,10 +166,10 @@ function publicEntry(entry, access) {
updated_at: entry.updated_at, updated_at: entry.updated_at,
published_at: entry.published_at published_at: entry.published_at
}; };
if (access.isMod || access.isAdmin) { if (access.capabilities.read_mod_fields) {
base.moderator_markdown = entry.moderator_markdown; base.moderator_markdown = entry.moderator_markdown;
} }
if (access.isAdmin || access.canEdit) { if (access.capabilities.read_admin_fields) {
base.admin_markdown = entry.admin_markdown; base.admin_markdown = entry.admin_markdown;
base.ai_facts_markdown = entry.ai_facts_markdown; base.ai_facts_markdown = entry.ai_facts_markdown;
base.source_links = parseJsonArray(entry.source_links_json); base.source_links = parseJsonArray(entry.source_links_json);
@ -153,9 +200,10 @@ function getEntryBySlug(db, slug, user, options = {}) {
return publicEntry(entry, access); return publicEntry(entry, access);
} }
function getEditableEntry(db, slug) { function getEditableEntry(db, slug, user) {
const entry = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug)); const entry = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug));
return entry ? normalizeEntry(entry) : null; const access = accessForUser(db, user);
return canSeeEntry(access, entry, { management: true }) ? publicEntry(entry, access) : null;
} }
function createEntry(db, values, actor) { function createEntry(db, values, actor) {
@ -165,7 +213,7 @@ function createEntry(db, values, actor) {
const entry = normalizeValues(values, { const entry = normalizeValues(values, {
actor, actor,
existing: null, existing: null,
canImplement: access.canImplement, access,
now now
}); });
entry.id = crypto.randomUUID(); entry.id = crypto.randomUUID();
@ -179,19 +227,19 @@ function createEntry(db, values, actor) {
"VALUES (@id, @slug, @title, @category, @tags_json, @aliases_json, @summary, @user_markdown, @moderator_markdown, @admin_markdown, @ai_facts_markdown, @source_links_json, @visibility, @status, @review_state, @created_by, @updated_by, @reviewed_by, @published_by, @created_at, @updated_at, @reviewed_at, @published_at, @archived_at, @deleted_at)" "VALUES (@id, @slug, @title, @category, @tags_json, @aliases_json, @summary, @user_markdown, @moderator_markdown, @admin_markdown, @ai_facts_markdown, @source_links_json, @visibility, @status, @review_state, @created_by, @updated_by, @reviewed_by, @published_by, @created_at, @updated_at, @reviewed_at, @published_at, @archived_at, @deleted_at)"
).run(entry); ).run(entry);
addVersion(db, entry.id, "create", null, entry, actor, values.change_note || "Entry created."); addVersion(db, entry.id, "create", null, entry, actor, values.change_note || "Entry created.");
return normalizeEntry(entry); return publicEntry(entry, access);
} }
function updateEntry(db, slug, values, actor) { function updateEntry(db, slug, values, actor) {
const access = accessForUser(db, actor); const access = accessForUser(db, actor);
if (!access.canEdit) throw new Error("OKF edit permission is required."); if (!access.canEdit) throw new Error("OKF edit permission is required.");
const current = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug)); const current = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug));
if (!current) throw new Error("OKF entry was not found."); if (!canSeeEntry(access, current, { management: true })) throw new Error("OKF entry was not found.");
const now = Date.now(); const now = Date.now();
const next = normalizeValues(values, { const next = normalizeValues(values, {
actor, actor,
existing: current, existing: current,
canImplement: access.canImplement, access,
now now
}); });
next.id = current.id; next.id = current.id;
@ -210,13 +258,13 @@ function updateEntry(db, slug, values, actor) {
"UPDATE okf_entries SET slug = @slug, title = @title, category = @category, tags_json = @tags_json, aliases_json = @aliases_json, summary = @summary, user_markdown = @user_markdown, moderator_markdown = @moderator_markdown, admin_markdown = @admin_markdown, ai_facts_markdown = @ai_facts_markdown, source_links_json = @source_links_json, visibility = @visibility, status = @status, review_state = @review_state, updated_by = @updated_by, updated_at = @updated_at, reviewed_by = @reviewed_by, published_by = @published_by, reviewed_at = @reviewed_at, published_at = @published_at, archived_at = @archived_at, deleted_at = @deleted_at WHERE id = @id" "UPDATE okf_entries SET slug = @slug, title = @title, category = @category, tags_json = @tags_json, aliases_json = @aliases_json, summary = @summary, user_markdown = @user_markdown, moderator_markdown = @moderator_markdown, admin_markdown = @admin_markdown, ai_facts_markdown = @ai_facts_markdown, source_links_json = @source_links_json, visibility = @visibility, status = @status, review_state = @review_state, updated_by = @updated_by, updated_at = @updated_at, reviewed_by = @reviewed_by, published_by = @published_by, reviewed_at = @reviewed_at, published_at = @published_at, archived_at = @archived_at, deleted_at = @deleted_at WHERE id = @id"
).run(next); ).run(next);
addVersion(db, next.id, "update", current, next, actor, values.change_note || "Entry updated."); addVersion(db, next.id, "update", current, next, actor, values.change_note || "Entry updated.");
return normalizeEntry(next); return publicEntry(next, access);
} }
function setEntryWorkflow(db, slug, action, actor, note = "") { function setEntryWorkflow(db, slug, action, actor, note = "") {
const access = accessForUser(db, actor); const access = accessForUser(db, actor);
const entry = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug)); const entry = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug));
if (!entry) throw new Error("OKF entry was not found."); if (!canSeeEntry(access, entry, { management: true })) throw new Error("OKF entry was not found.");
const next = { ...entry }; const next = { ...entry };
const now = Date.now(); const now = Date.now();
if (action === "review") { if (action === "review") {
@ -251,25 +299,32 @@ function setEntryWorkflow(db, slug, action, actor, note = "") {
"UPDATE okf_entries SET status = @status, review_state = @review_state, reviewed_by = @reviewed_by, published_by = @published_by, updated_by = @updated_by, updated_at = @updated_at, reviewed_at = @reviewed_at, published_at = @published_at, archived_at = @archived_at, deleted_at = @deleted_at WHERE id = @id" "UPDATE okf_entries SET status = @status, review_state = @review_state, reviewed_by = @reviewed_by, published_by = @published_by, updated_by = @updated_by, updated_at = @updated_at, reviewed_at = @reviewed_at, published_at = @published_at, archived_at = @archived_at, deleted_at = @deleted_at WHERE id = @id"
).run(next); ).run(next);
addVersion(db, next.id, action, entry, next, actor, note || `Workflow action: ${action}.`); addVersion(db, next.id, action, entry, next, actor, note || `Workflow action: ${action}.`);
return normalizeEntry(next); return publicEntry(next, access);
} }
function listVersions(db, entryId) { function listVersions(db, entryId, user) {
const access = accessForUser(db, user);
const entry = db.prepare("SELECT * FROM okf_entries WHERE id = ? AND deleted_at IS NULL").get(entryId);
if (!canSeeEntry(access, entry, { management: true })) return [];
return db return db
.prepare("SELECT * FROM okf_versions WHERE entry_id = ? ORDER BY version_number DESC") .prepare("SELECT * FROM okf_versions WHERE entry_id = ? ORDER BY version_number DESC")
.all(entryId) .all(entryId)
.map((row) => ({ .map((row) => {
...row, const { previous_json, next_json, changed_by, ...metadata } = row;
previous: parseJsonObject(row.previous_json), return {
next: parseJsonObject(row.next_json) ...metadata,
})); ...(access.capabilities.read_admin_fields ? { changed_by } : {}),
previous: visibleSnapshot(parseJsonObject(previous_json), access),
next: visibleSnapshot(parseJsonObject(next_json), access)
};
});
} }
function restoreVersion(db, entryId, versionNumber, actor, note = "") { function restoreVersion(db, entryId, versionNumber, actor, note = "") {
const access = accessForUser(db, actor); const access = accessForUser(db, actor);
if (!access.canImplement) throw new Error("OKF restore permission is required."); if (!access.canImplement) throw new Error("OKF restore permission is required.");
const current = db.prepare("SELECT * FROM okf_entries WHERE id = ? AND deleted_at IS NULL").get(entryId); const current = db.prepare("SELECT * FROM okf_entries WHERE id = ? AND deleted_at IS NULL").get(entryId);
if (!current) throw new Error("OKF entry was not found."); if (!canSeeEntry(access, current, { management: true })) throw new Error("OKF entry was not found.");
const version = db const version = db
.prepare("SELECT * FROM okf_versions WHERE entry_id = ? AND version_number = ?") .prepare("SELECT * FROM okf_versions WHERE entry_id = ? AND version_number = ?")
.get(entryId, Number(versionNumber)); .get(entryId, Number(versionNumber));
@ -287,11 +342,11 @@ function restoreVersion(db, entryId, versionNumber, actor, note = "") {
aliases_json: JSON.stringify(splitLines(snapshot.aliases || [])), aliases_json: JSON.stringify(splitLines(snapshot.aliases || [])),
summary: cleanText(snapshot.summary, 800), summary: cleanText(snapshot.summary, 800),
user_markdown: cleanText(snapshot.user_markdown, 24000), user_markdown: cleanText(snapshot.user_markdown, 24000),
moderator_markdown: cleanText(snapshot.moderator_markdown, 24000), moderator_markdown: access.capabilities.read_mod_fields ? cleanText(snapshot.moderator_markdown, 24000) : current.moderator_markdown,
admin_markdown: cleanText(snapshot.admin_markdown, 24000), admin_markdown: access.capabilities.read_admin_fields ? cleanText(snapshot.admin_markdown, 24000) : current.admin_markdown,
ai_facts_markdown: cleanText(snapshot.ai_facts_markdown, 24000), ai_facts_markdown: access.capabilities.read_admin_fields ? cleanText(snapshot.ai_facts_markdown, 24000) : current.ai_facts_markdown,
source_links_json: JSON.stringify(cleanLinkList(snapshot.source_links || [])), source_links_json: access.capabilities.read_admin_fields ? JSON.stringify(cleanLinkList(snapshot.source_links || [])) : current.source_links_json,
visibility: normalizeChoice(snapshot.visibility, VISIBILITY_VALUES, "user"), visibility: allowedVisibility(snapshot.visibility, access, current.visibility),
status: normalizeChoice(snapshot.status, STATUS_VALUES, "draft"), status: normalizeChoice(snapshot.status, STATUS_VALUES, "draft"),
review_state: normalizeChoice(snapshot.review_state, REVIEW_STATES, "draft"), review_state: normalizeChoice(snapshot.review_state, REVIEW_STATES, "draft"),
updated_by: String(actor.id), updated_by: String(actor.id),
@ -315,7 +370,7 @@ function restoreVersion(db, entryId, versionNumber, actor, note = "") {
actor, actor,
note || `Restored version ${Number(versionNumber)}.` note || `Restored version ${Number(versionNumber)}.`
); );
return normalizeEntry(restored); return publicEntry(restored, access);
} }
function grantPermission(db, values, actor) { function grantPermission(db, values, actor) {
@ -336,7 +391,8 @@ function revokePermission(db, id, actor) {
db.prepare("UPDATE okf_permissions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(Date.now(), Number(id)); db.prepare("UPDATE okf_permissions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(Date.now(), Number(id));
} }
function listPermissions(db) { function listPermissions(db, user) {
if (!accessForUser(db, user).capabilities.manage_permissions) return [];
return db return db
.prepare( .prepare(
"SELECT okf_permissions.*, user_profiles.internal_username AS user_name, grantor.internal_username AS granted_by_name " + "SELECT okf_permissions.*, user_profiles.internal_username AS user_name, grantor.internal_username AS granted_by_name " +
@ -350,7 +406,7 @@ function listPermissions(db) {
function searchForAi(db, query, user, limit = 5) { function searchForAi(db, query, user, limit = 5) {
const access = accessForUser(db, user); const access = accessForUser(db, user);
if (!access.authenticated) return []; if (!access.capabilities.read_user_fields) return [];
return listEntries(db, { q: query }, user) return listEntries(db, { q: query }, user)
.filter((entry) => entry.status === "published" && entry.review_state === "approved") .filter((entry) => entry.status === "published" && entry.review_state === "approved")
.slice(0, limit) .slice(0, limit)
@ -361,7 +417,12 @@ function searchForAi(db, query, user, limit = 5) {
category: entry.category, category: entry.category,
visibility: entry.visibility, visibility: entry.visibility,
summary: entry.summary, summary: entry.summary,
facts: [entry.user_markdown, entry.moderator_markdown, entry.admin_markdown, entry.ai_facts_markdown] facts: [
entry.user_markdown,
access.capabilities.read_mod_fields ? entry.moderator_markdown : "",
access.capabilities.read_admin_fields ? entry.admin_markdown : "",
access.capabilities.read_admin_fields ? entry.ai_facts_markdown : ""
]
.filter(Boolean) .filter(Boolean)
.join("\n\n") .join("\n\n")
.slice(0, 4000), .slice(0, 4000),
@ -383,16 +444,16 @@ function matchesFilters(entry, filters, access, options) {
entry.tags_json, entry.tags_json,
entry.aliases_json entry.aliases_json
]; ];
if (access.isMod || access.isAdmin || options.management) visibleText.push(entry.moderator_markdown); if (access.capabilities.read_mod_fields) visibleText.push(entry.moderator_markdown);
if (access.isAdmin || options.management) visibleText.push(entry.admin_markdown, entry.ai_facts_markdown, entry.source_links_json); if (access.capabilities.read_admin_fields) visibleText.push(entry.admin_markdown, entry.ai_facts_markdown, entry.source_links_json);
return visibleText.join(" ").toLowerCase().includes(q); return visibleText.join(" ").toLowerCase().includes(q);
} }
function normalizeValues(values, { actor, existing, canImplement, now }) { function normalizeValues(values, { actor, existing, access, now }) {
const status = canImplement const status = access.capabilities.implement
? normalizeChoice(values.status, STATUS_VALUES, existing?.status || "draft") ? normalizeChoice(values.status, STATUS_VALUES, existing?.status || "draft")
: existing?.status || "draft"; : existing?.status || "draft";
const reviewState = canImplement const reviewState = access.capabilities.implement
? normalizeChoice(values.review_state, REVIEW_STATES, existing?.review_state || "draft") ? normalizeChoice(values.review_state, REVIEW_STATES, existing?.review_state || "draft")
: "review_pending"; : "review_pending";
const publishedAt = status === "published" ? (existing?.published_at || now) : existing?.published_at || null; const publishedAt = status === "published" ? (existing?.published_at || now) : existing?.published_at || null;
@ -404,11 +465,11 @@ function normalizeValues(values, { actor, existing, canImplement, now }) {
aliases_json: JSON.stringify(splitLines(values.aliases ?? parseJsonArray(existing?.aliases_json))), aliases_json: JSON.stringify(splitLines(values.aliases ?? parseJsonArray(existing?.aliases_json))),
summary: cleanText(values.summary ?? existing?.summary, 800), summary: cleanText(values.summary ?? existing?.summary, 800),
user_markdown: cleanText(values.user_markdown ?? existing?.user_markdown, 24000), user_markdown: cleanText(values.user_markdown ?? existing?.user_markdown, 24000),
moderator_markdown: cleanText(values.moderator_markdown ?? existing?.moderator_markdown, 24000), moderator_markdown: access.capabilities.read_mod_fields ? cleanText(values.moderator_markdown ?? existing?.moderator_markdown, 24000) : existing?.moderator_markdown || "",
admin_markdown: cleanText(values.admin_markdown ?? existing?.admin_markdown, 24000), admin_markdown: access.capabilities.read_admin_fields ? cleanText(values.admin_markdown ?? existing?.admin_markdown, 24000) : existing?.admin_markdown || "",
ai_facts_markdown: cleanText(values.ai_facts_markdown ?? existing?.ai_facts_markdown, 24000), ai_facts_markdown: access.capabilities.read_admin_fields ? cleanText(values.ai_facts_markdown ?? existing?.ai_facts_markdown, 24000) : existing?.ai_facts_markdown || "",
source_links_json: JSON.stringify(cleanLinkList(values.source_links ?? parseJsonArray(existing?.source_links_json))), source_links_json: access.capabilities.read_admin_fields ? JSON.stringify(cleanLinkList(values.source_links ?? parseJsonArray(existing?.source_links_json))) : existing?.source_links_json || "[]",
visibility: normalizeChoice(values.visibility, VISIBILITY_VALUES, existing?.visibility || "user"), visibility: allowedVisibility(values.visibility, access, existing?.visibility || "user"),
status, status,
review_state: reviewState, review_state: reviewState,
reviewed_by: existing?.reviewed_by || null, reviewed_by: existing?.reviewed_by || null,

View File

@ -24,10 +24,12 @@ const {
ensureKnowledgeDirs, ensureKnowledgeDirs,
getCommunityKnowledgeFile, getCommunityKnowledgeFile,
listCommunityKnowledgeFiles, listCommunityKnowledgeFiles,
listCorrectionKnowledgeFiles,
loadKnowledgeEntries, loadKnowledgeEntries,
migrateSingleBracePlaceholders, migrateSingleBracePlaceholders,
registerKnowledgePlaceholderDefinitions, registerKnowledgePlaceholderDefinitions,
saveCommunityKnowledgeFile, saveCommunityKnowledgeFile,
saveCorrectionKnowledgeFile,
searchFileKnowledge searchFileKnowledge
} = require("./backend/file_knowledge"); } = require("./backend/file_knowledge");
const { generateKnowledgeFiles } = require("./backend/generate_knowledge"); const { generateKnowledgeFiles } = require("./backend/generate_knowledge");
@ -39,6 +41,10 @@ const PLUGIN_ID = "okf";
module.exports = { module.exports = {
id: PLUGIN_ID, id: PLUGIN_ID,
init({ web, db, placeholders = placeholderService }) { init({ web, db, placeholders = placeholderService }) {
if (typeof web.auth?.requireAuth !== "function") {
throw new Error("OKF requires Lumi's shared WebUI authentication helpers.");
}
const requireLogin = web.auth.requireAuth;
ensureTables(db); ensureTables(db);
ensureKnowledgeDirs(process.cwd()); ensureKnowledgeDirs(process.cwd());
if (!getSetting("okf_single_brace_placeholder_migration_v1", false)) { if (!getSetting("okf_single_brace_placeholder_migration_v1", false)) {
@ -88,7 +94,11 @@ module.exports = {
global.lumiFrameworks.okf = { global.lumiFrameworks.okf = {
search: searchOkfForAi, search: searchOkfForAi,
context: okfContextProvider, context: okfContextProvider,
accessForUser: (user) => accessForUser(db, user) accessForUser: (user) => accessForUser(db, user),
saveCorrection(values, actor) {
if (!actor?.isAdmin) throw new Error("Only admins can create correction knowledge files.");
return saveCorrectionKnowledgeFile(process.cwd(), values);
}
}; };
global.lumiFrameworks.ai?.registerContext?.(PLUGIN_ID, okfContextProvider); global.lumiFrameworks.ai?.registerContext?.(PLUGIN_ID, okfContextProvider);
@ -119,12 +129,18 @@ module.exports = {
renderAdmin(req, res, db); renderAdmin(req, res, db);
}); });
router.post("/admin/preview", requireOkfManagement(db), (req, res) => {
const markdown = String(req.body?.markdown || "");
if (markdown.length > 24000) return res.status(400).json({ error: "Markdown preview is limited to 24,000 characters." });
return res.json({ html: renderMarkdown(markdown) });
});
router.post("/admin/community", requireOkfEdit(db), (req, res) => { router.post("/admin/community", requireOkfEdit(db), (req, res) => {
try { try {
validateOkfPlaceholderFields({ validateOkfPlaceholderFields({
body: req.body.body body: req.body.body
}, req.session.user, placeholders); }, req.session.user, placeholders);
const entry = saveCommunityKnowledgeFile(process.cwd(), req.body); const entry = saveCommunityKnowledgeFile(process.cwd(), communityFileValuesForUser(db, req.session.user, req.body));
registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() }); registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() });
req.session.flash = { type: "success", message: "Community OKF file saved." }; req.session.flash = { type: "success", message: "Community OKF file saved." };
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community&community=${encodeURIComponent(entry.slug)}#okf-community-files`); res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community&community=${encodeURIComponent(entry.slug)}#okf-community-files`);
@ -139,10 +155,11 @@ module.exports = {
validateOkfPlaceholderFields({ validateOkfPlaceholderFields({
body: req.body.body body: req.body.body
}, req.session.user, placeholders); }, req.session.user, placeholders);
const entry = saveCommunityKnowledgeFile(process.cwd(), { const existing = getCommunityKnowledgeFile(process.cwd(), req.params.slug);
const entry = saveCommunityKnowledgeFile(process.cwd(), communityFileValuesForUser(db, req.session.user, {
...req.body, ...req.body,
existing_slug: req.params.slug existing_slug: req.params.slug
}); }, existing));
registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() }); registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() });
req.session.flash = { type: "success", message: "Community OKF file updated." }; req.session.flash = { type: "success", message: "Community OKF file updated." };
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community&community=${encodeURIComponent(entry.slug)}#okf-community-files`); res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community&community=${encodeURIComponent(entry.slug)}#okf-community-files`);
@ -189,7 +206,7 @@ module.exports = {
router.post("/admin/entries/:slug/versions/:version/restore", requireOkfManagement(db), (req, res) => { router.post("/admin/entries/:slug/versions/:version/restore", requireOkfManagement(db), (req, res) => {
try { try {
const selected = getEditableEntry(db, req.params.slug); const selected = getEditableEntry(db, req.params.slug, req.session.user);
if (!selected) throw new Error("OKF entry was not found."); if (!selected) throw new Error("OKF entry was not found.");
const entry = restoreVersion( const entry = restoreVersion(
db, db,
@ -258,7 +275,15 @@ module.exports = {
role: "public", role: "public",
authRequired: true, authRequired: true,
section: "admin", section: "admin",
canAccess: (user) => accessForUser(db, user).canEdit canAccess: (user) => user?.isAdmin === true
});
web.addNavItem({
label: "Contribute to OKF",
path: `/plugins/${PLUGIN_ID}/admin`,
role: "public",
authRequired: true,
section: "community",
canAccess: (user) => user?.isAdmin !== true && accessForUser(db, user).canEdit
}); });
return () => { return () => {
global.lumiFrameworks?.ai?.unregisterContext?.(PLUGIN_ID); global.lumiFrameworks?.ai?.unregisterContext?.(PLUGIN_ID);
@ -392,7 +417,8 @@ async function resolveOkfEntryPlaceholders(entry, user, placeholders) {
} }
function renderAdmin(req, res, db) { function renderAdmin(req, res, db) {
const activeTab = resolveAdminTab(req.query); const access = accessForUser(db, req.session.user);
const activeTab = resolveAdminTab(req.query, access.canManagePermissions);
const filters = { const filters = {
q: req.query.q || "", q: req.query.q || "",
status: req.query.status || "", status: req.query.status || "",
@ -401,13 +427,18 @@ function renderAdmin(req, res, db) {
}; };
const entries = listEntries(db, filters, req.session.user, { management: true }); const entries = listEntries(db, filters, req.session.user, { management: true });
const allEntries = listEntries(db, {}, req.session.user, { management: true }); const allEntries = listEntries(db, {}, req.session.user, { management: true });
const selected = req.query.edit ? getEditableEntry(db, req.query.edit) : null; const selected = req.query.edit ? getEditableEntry(db, req.query.edit, req.session.user) : null;
const communityFiles = listCommunityKnowledgeFiles(process.cwd()); const communityFiles = listCommunityKnowledgeFiles(process.cwd()).filter((entry) => canReadOkfVisibility(access, entry.visibility));
const selectedCommunity = req.query.community ? getCommunityKnowledgeFile(process.cwd(), req.query.community) : null; const requestedCommunity = req.query.community ? getCommunityKnowledgeFile(process.cwd(), req.query.community) : null;
const systemFiles = listSystemKnowledgeFiles(process.cwd()); const selectedCommunity = requestedCommunity && canReadOkfVisibility(access, requestedCommunity.visibility) ? requestedCommunity : null;
const systemFiles = listSystemKnowledgeFiles(process.cwd()).filter((entry) => canReadOkfVisibility(access, entry.visibility));
const selectedSystemFile = req.query.system ? findSystemKnowledgeFile(systemFiles, req.query.system) : null; const selectedSystemFile = req.query.system ? findSystemKnowledgeFile(systemFiles, req.query.system) : null;
const correctionFiles = access.canManagePermissions
? listCorrectionKnowledgeFiles(process.cwd()).filter((entry) => canReadOkfVisibility(access, entry.visibility))
: [];
const selectedCorrection = req.query.correction ? findSystemKnowledgeFile(correctionFiles, req.query.correction) : null;
const suggestionEntries = [...allEntries, ...communityFiles]; const suggestionEntries = [...allEntries, ...communityFiles];
const versions = selected ? listVersions(db, selected.id) : []; const versions = selected ? listVersions(db, selected.id, req.session.user) : [];
res.render(view("admin"), { res.render(view("admin"), {
title: "OKF Management", title: "OKF Management",
activeTab, activeTab,
@ -418,11 +449,13 @@ function renderAdmin(req, res, db) {
selectedCommunity, selectedCommunity,
systemFiles, systemFiles,
selectedSystemFile, selectedSystemFile,
correctionFiles,
selectedCorrection,
versions, versions,
permissions: listPermissions(db), permissions: listPermissions(db, req.session.user),
levels: PERMISSION_LEVELS, levels: PERMISSION_LEVELS,
statuses: STATUS_VALUES, statuses: STATUS_VALUES,
visibilityValues: VISIBILITY_VALUES, visibilityValues: VISIBILITY_VALUES.filter((value) => canReadOkfVisibility(access, value)),
reviewStates: REVIEW_STATES, reviewStates: REVIEW_STATES,
categories: categoriesFor(suggestionEntries), categories: categoriesFor(suggestionEntries),
tags: tagsFor(suggestionEntries), tags: tagsFor(suggestionEntries),
@ -430,11 +463,13 @@ function renderAdmin(req, res, db) {
}); });
} }
function resolveAdminTab(query = {}) { function resolveAdminTab(query = {}, allowCorrections = false) {
if (query.edit) return "general"; if (query.edit) return "general";
if (query.community) return "community"; if (query.community) return "community";
if (query.system) return "system"; if (query.system) return "system";
return ["general", "community", "system"].includes(query.tab) ? query.tab : "general"; if (allowCorrections && query.correction) return "corrections";
const allowed = allowCorrections ? ["general", "community", "system", "corrections"] : ["general", "community", "system"];
return allowed.includes(query.tab) ? query.tab : "general";
} }
function listSystemKnowledgeFiles(rootDir = process.cwd()) { function listSystemKnowledgeFiles(rootDir = process.cwd()) {
@ -449,6 +484,24 @@ function listSystemKnowledgeFiles(rootDir = process.cwd()) {
.sort((a, b) => a.scope.localeCompare(b.scope) || a.title.localeCompare(b.title)); .sort((a, b) => a.scope.localeCompare(b.scope) || a.title.localeCompare(b.title));
} }
function canReadOkfVisibility(access, visibility) {
if (visibility === "admin") return access.capabilities.read_admin_fields;
if (visibility === "mod") return access.capabilities.read_mod_fields;
return access.capabilities.read_user_fields;
}
function communityFileValuesForUser(db, user, values, existing = null) {
const access = accessForUser(db, user);
if (existing && !canReadOkfVisibility(access, existing.visibility)) {
throw new Error("That community knowledge file is not available to your account.");
}
const requestedVisibility = VISIBILITY_VALUES.includes(values.visibility) ? values.visibility : existing?.visibility || "user";
if (!canReadOkfVisibility(access, requestedVisibility)) {
throw new Error(`You cannot create or edit ${requestedVisibility}-only knowledge.`);
}
return { ...values, visibility: requestedVisibility };
}
function findSystemKnowledgeFile(files, key) { function findSystemKnowledgeFile(files, key) {
const value = String(key || ""); const value = String(key || "");
return files.find((file) => return files.find((file) =>
@ -463,11 +516,6 @@ function view(name) {
return path.join(__dirname, "views", `${name}.ejs`); return path.join(__dirname, "views", `${name}.ejs`);
} }
function requireLogin(req, res, next) {
if (req.session.user) return next();
return res.redirect("/login");
}
function requireAdmin(req, res, next) { function requireAdmin(req, res, next) {
if (req.session.user?.isAdmin) return next(); if (req.session.user?.isAdmin) return next();
return denied(res); return denied(res);

View File

@ -8,6 +8,7 @@ const {
accessForUser, accessForUser,
createEntry, createEntry,
ensureTables, ensureTables,
getEditableEntry,
getEntryBySlug, getEntryBySlug,
grantPermission, grantPermission,
listEntries, listEntries,
@ -23,6 +24,7 @@ const {
ensureKnowledgeDirs, ensureKnowledgeDirs,
getCommunityKnowledgeFile, getCommunityKnowledgeFile,
listCommunityKnowledgeFiles, listCommunityKnowledgeFiles,
listCorrectionKnowledgeFiles,
loadKnowledgeEntries, loadKnowledgeEntries,
listKnowledgePlaceholders, listKnowledgePlaceholders,
saveCorrectionKnowledgeFile, saveCorrectionKnowledgeFile,
@ -49,11 +51,15 @@ db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, update
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("mod-1", "Mod", now, now); db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("mod-1", "Mod", now, now);
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("user-1", "User", now, now); db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("user-1", "User", now, now);
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("editor-1", "Editor", now, now); db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("editor-1", "Editor", now, now);
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("reviewer-1", "Reviewer", now, now);
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("implementer-1", "Implementer", now, now);
const admin = { id: "admin-1", isAdmin: true, isMod: true }; const admin = { id: "admin-1", isAdmin: true, isMod: true };
const mod = { id: "mod-1", isMod: true }; const mod = { id: "mod-1", isMod: true };
const user = { id: "user-1" }; const user = { id: "user-1" };
const editor = { id: "editor-1" }; const editor = { id: "editor-1" };
const reviewer = { id: "reviewer-1" };
const implementer = { id: "implementer-1" };
assert(PRESERVED_PATHS.includes("knowledge/community")); assert(PRESERVED_PATHS.includes("knowledge/community"));
assert(PRESERVED_PATHS.includes("knowledge/corrections")); assert(PRESERVED_PATHS.includes("knowledge/corrections"));
@ -69,16 +75,42 @@ assert(adminTemplate.includes("System-generated OKF") && !adminTemplate.includes
assert(adminTemplate.includes("data-okf-file-list")); assert(adminTemplate.includes("data-okf-file-list"));
assert(adminTemplate.includes("data-okf-file-search")); assert(adminTemplate.includes("data-okf-file-search"));
assert(adminTemplate.includes('data-okf-file-filter="category"')); assert(adminTemplate.includes('data-okf-file-filter="category"'));
assert(adminTemplate.includes('data-okf-file-filter="tags"'));
assert(adminTemplate.includes("data-okf-markdown-preview-panel"));
assert(adminTemplate.includes("/plugins/okf/admin/preview"));
assert(adminTemplate.includes("No community OKF files match these filters.")); assert(adminTemplate.includes("No community OKF files match these filters."));
assert(adminTemplate.includes("No system-generated OKF files match these filters.")); assert(adminTemplate.includes("No system-generated OKF files match these filters."));
assert(adminTemplate.includes("Feedback corrections"));
assert(adminTemplate.includes("selectedCorrection"));
const okfPluginSource = fs.readFileSync(path.join(__dirname, "..", "index.js"), "utf8"); const okfPluginSource = fs.readFileSync(path.join(__dirname, "..", "index.js"), "utf8");
assert(okfPluginSource.includes("preferSpecificKnowledgeChunks")); assert(okfPluginSource.includes("preferSpecificKnowledgeChunks"));
assert(okfPluginSource.includes("trimForContext")); assert(okfPluginSource.includes("trimForContext"));
assert(okfPluginSource.includes("remaining = 4200")); assert(okfPluginSource.includes("remaining = 4200"));
assert(okfPluginSource.includes("communityFileValuesForUser"));
assert(okfPluginSource.includes("canReadOkfVisibility(access, requestedCommunity.visibility)"));
assert(okfPluginSource.includes("VISIBILITY_VALUES.filter((value) => canReadOkfVisibility(access, value))"));
assert(okfPluginSource.includes("saveCorrection(values, actor)"));
assert(okfPluginSource.includes("Only admins can create correction knowledge files"));
assert(okfPluginSource.includes('router.post("/admin/preview", requireOkfManagement(db)'));
assert(okfPluginSource.includes('label: "Contribute to OKF"'));
const fileKnowledgeSource = fs.readFileSync(path.join(__dirname, "..", "backend", "file_knowledge.js"), "utf8");
assert(fileKnowledgeSource.includes("writeTextAtomicSync(filePath, markdown)"));
grantPermission(db, { user_id: "editor-1", level: "edit", notes: "Trusted writer" }, admin); grantPermission(db, { user_id: "editor-1", level: "edit", notes: "Trusted writer" }, admin);
grantPermission(db, { user_id: "reviewer-1", level: "edit_review", notes: "Trusted reviewer" }, admin);
grantPermission(db, { user_id: "implementer-1", level: "edit_review_implement", notes: "Trusted implementer" }, admin);
assert.equal(accessForUser(db, editor).canEdit, true); assert.equal(accessForUser(db, editor).canEdit, true);
assert.equal(listPermissions(db).filter((grant) => !grant.revoked_at).length, 1); assert.equal(accessForUser(db, editor).capabilities.read_admin_fields, false);
assert.equal(accessForUser(db, reviewer).capabilities.review, true);
assert.equal(accessForUser(db, reviewer).capabilities.implement, false);
assert.equal(accessForUser(db, implementer).capabilities.implement, true);
assert.equal(accessForUser(db, mod).capabilities.read_mod_fields, true);
assert.equal(accessForUser(db, user).capabilities.read_mod_fields, false);
assert.equal(accessForUser(db, admin).capabilities.manage_permissions, true);
assert.equal(accessForUser(db, null).capabilities.read_user_fields, false);
assert.equal(accessForUser(db, mod).capabilities.management_view, false);
assert.equal(listPermissions(db, editor).length, 0);
assert.equal(listPermissions(db, admin).filter((grant) => !grant.revoked_at).length, 3);
const entry = createEntry(db, { const entry = createEntry(db, {
title: "Currency basics", title: "Currency basics",
@ -104,6 +136,7 @@ assert.equal(listEntries(db, {}, mod).length, 1);
assert.equal(getEntryBySlug(db, "currency-basics", user), null); assert.equal(getEntryBySlug(db, "currency-basics", user), null);
assert.equal(getEntryBySlug(db, "currency-basics", mod).moderator_markdown.includes("Moderators"), true); assert.equal(getEntryBySlug(db, "currency-basics", mod).moderator_markdown.includes("Moderators"), true);
assert.equal(getEntryBySlug(db, "currency-basics", admin).admin_markdown.includes("Admin-only"), true); assert.equal(getEntryBySlug(db, "currency-basics", admin).admin_markdown.includes("Admin-only"), true);
assert.equal(listEntries(db, {}, editor, { management: true }).some((item) => item.slug === "currency-basics"), false);
assert(searchForAi(db, "currency", mod).some((item) => item.facts.includes("Moderators"))); assert(searchForAi(db, "currency", mod).some((item) => item.facts.includes("Moderators")));
assert.equal(searchForAi(db, "currency", user).length, 0); assert.equal(searchForAi(db, "currency", user).length, 0);
@ -125,6 +158,21 @@ assert(publicUserContext.some((item) => item.id === publicEntry.id));
assert.equal(publicUserContext.some((item) => item.facts.includes("Admin-only")), false); assert.equal(publicUserContext.some((item) => item.facts.includes("Admin-only")), false);
assert(publicAdminContext.some((item) => item.facts.includes("Admin-only implementation notes."))); assert(publicAdminContext.some((item) => item.facts.includes("Admin-only implementation notes.")));
assert(publicAdminContext.some((item) => item.facts.includes("Admin AI fact."))); assert(publicAdminContext.some((item) => item.facts.includes("Admin AI fact.")));
assert.equal(getEntryBySlug(db, "public-help", editor).admin_markdown, undefined);
assert.equal(getEditableEntry(db, "public-help", editor).ai_facts_markdown, undefined);
assert.equal(getEditableEntry(db, "public-help", reviewer).admin_markdown, undefined);
assert.equal(getEditableEntry(db, "public-help", implementer).ai_facts_markdown, undefined);
assert.equal(searchForAi(db, "Admin-only implementation", editor).length, 0);
updateEntry(db, "public-help", {
title: "Public help",
slug: "public-help",
summary: "Contributor-safe update.",
user_markdown: "Everyone can still read this.",
admin_markdown: "Attempted overwrite.",
ai_facts_markdown: "Attempted AI overwrite."
}, editor);
assert.equal(getEntryBySlug(db, "public-help", admin).admin_markdown, "Admin-only implementation notes.");
assert.equal(getEntryBySlug(db, "public-help", admin).ai_facts_markdown, "Admin AI fact.");
const proposed = createEntry(db, { const proposed = createEntry(db, {
title: "Draft from editor", title: "Draft from editor",
@ -144,12 +192,16 @@ updateEntry(db, "draft-from-editor", {
user_markdown: "Updated draft content.", user_markdown: "Updated draft content.",
change_note: "Editor update." change_note: "Editor update."
}, editor); }, editor);
assert.equal(listVersions(db, proposed.id).length, 2); assert.equal(listVersions(db, proposed.id, admin).length, 2);
const contributorVersions = listVersions(db, proposed.id, editor);
assert.equal(contributorVersions[0].next.admin_markdown, undefined);
assert.equal(contributorVersions[0].next_json, undefined);
assert.equal(contributorVersions[0].changed_by, undefined);
const restored = restoreVersion(db, proposed.id, 1, admin, "Test restore."); const restored = restoreVersion(db, proposed.id, 1, admin, "Test restore.");
assert.equal(restored.summary, "Editor draft"); assert.equal(restored.summary, "Editor draft");
assert.equal(restored.user_markdown, "Draft content."); assert.equal(restored.user_markdown, "Draft content.");
assert.equal(listVersions(db, proposed.id).length, 3); assert.equal(listVersions(db, proposed.id, admin).length, 3);
assert.equal(searchForAi(db, "draft", admin).length, 0); assert.equal(searchForAi(db, "draft", admin).length, 0);
setEntryWorkflow(db, "draft-from-editor", "review", admin); setEntryWorkflow(db, "draft-from-editor", "review", admin);
@ -255,6 +307,8 @@ assert(Number.isFinite(currencyResults[0].source_metadata.score));
const identityResults = searchFileKnowledge({ query: "Who is OokamiKunTV?", user, rootDir: tempRoot, limit: 5 }); const identityResults = searchFileKnowledge({ query: "Who is OokamiKunTV?", user, rootDir: tempRoot, limit: 5 });
assert.equal(identityResults[0].id, "community.people"); assert.equal(identityResults[0].id, "community.people");
assert(identityResults[0].facts.includes("OokamiKunTV is a known community contact")); assert(identityResults[0].facts.includes("OokamiKunTV is a known community contact"));
assert.equal(searchFileKnowledge({ query: "communitypeople", user, rootDir: tempRoot, limit: 5 })[0].id, "community.people");
assert.equal(searchFileKnowledge({ query: "Can you help me?", user, rootDir: tempRoot, limit: 5 }).length, 0);
const commandUserResult = searchFileKnowledge({ query: "commands", user, rootDir: tempRoot, limit: 5 })[0]; const commandUserResult = searchFileKnowledge({ query: "commands", user, rootDir: tempRoot, limit: 5 })[0];
assert(commandUserResult.facts.includes("The public currency is coins.")); assert(commandUserResult.facts.includes("The public currency is coins."));
assert(commandUserResult.facts.includes("[missing OKF reference]")); assert(commandUserResult.facts.includes("[missing OKF reference]"));
@ -317,6 +371,7 @@ const savedCorrection = saveCorrectionKnowledgeFile(tempRoot, {
assert.equal(savedCorrection.id, "correction.feedback-correction"); assert.equal(savedCorrection.id, "correction.feedback-correction");
assert.equal(savedCorrection.path, "knowledge/corrections/feedback-correction.md"); assert.equal(savedCorrection.path, "knowledge/corrections/feedback-correction.md");
assert.equal(savedCorrection.frontmatter.source_feedback_id, "feedback-1"); assert.equal(savedCorrection.frontmatter.source_feedback_id, "feedback-1");
assert.equal(listCorrectionKnowledgeFiles(tempRoot)[0].id, "correction.feedback-correction");
assert.equal(searchFileKnowledge({ query: "corrected behavior", user, rootDir: tempRoot, limit: 5 })[0].id, "correction.feedback-correction"); assert.equal(searchFileKnowledge({ query: "corrected behavior", user, rootDir: tempRoot, limit: 5 })[0].id, "correction.feedback-correction");
fs.writeFileSync(currencyKnowledgePath, [ fs.writeFileSync(currencyKnowledgePath, [
"---", "---",
@ -393,6 +448,12 @@ assert(generatedPlugin.includes("Test Plugin"));
assert(generatedPlugin.includes("### GET /plugins/test-plugin/settings")); assert(generatedPlugin.includes("### GET /plugins/test-plugin/settings"));
assert(generatedPlugin.includes("Inputs:")); assert(generatedPlugin.includes("Inputs:"));
assert(generatedPlugin.includes("test")); assert(generatedPlugin.includes("test"));
assert.equal(generatedCore.includes("updated_at:"), false);
assert.deepEqual(generateKnowledgeFiles(generatedRoot), []);
assert.equal(
searchFileKnowledge({ query: "testplugin settings", user, rootDir: generatedRoot, limit: 5 })[0].source_metadata.heading,
"GET /plugins/test-plugin/settings"
);
fs.writeFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), [ fs.writeFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), [
"---", "---",
"id: plugin.test-plugin", "id: plugin.test-plugin",
@ -406,7 +467,7 @@ generateKnowledgeFiles(generatedRoot);
assert(fs.readFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), "utf8").includes("Manual Test Plugin")); assert(fs.readFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), "utf8").includes("Manual Test Plugin"));
fs.rmSync(generatedRoot, { recursive: true, force: true }); fs.rmSync(generatedRoot, { recursive: true, force: true });
const grant = listPermissions(db).find((row) => row.user_id === "editor-1" && !row.revoked_at); const grant = listPermissions(db, admin).find((row) => row.user_id === "editor-1" && !row.revoked_at);
revokePermission(db, grant.id, admin); revokePermission(db, grant.id, admin);
assert.equal(accessForUser(db, editor).canEdit, false); assert.equal(accessForUser(db, editor).canEdit, false);

View File

@ -1,14 +1,17 @@
<%- include("../../../src/web/views/partials/layout-top", { title }) %> <%- include("../../../src/web/views/partials/layout-top", { title }) %>
<section class="card"> <section class="card">
<%- include("../../../src/web/views/partials/page-header", { <%- include("../../../src/web/views/partials/page-header", {
eyebrow: "Administration", eyebrow: okfAccess.canManagePermissions ? "Administration" : "Community contribution",
pageTitle: "OKF Management", pageTitle: okfAccess.canManagePermissions ? "OKF Management" : "Contribute to OKF",
description: "Manage role-gated knowledge entries, review state, version history, and OKF-specific editing permissions." description: okfAccess.canManagePermissions
? "Manage knowledge entries, reviews, publishing, history, and contributor access."
: `Your ${okfAccess.level || "editor"} access lets you contribute here without granting access to other admin areas.`
}) %> }) %>
<nav class="tabs" aria-label="OKF management sections"> <nav class="tabs" aria-label="OKF management sections">
<a href="/plugins/okf/admin?tab=general" <%= activeTab === "general" ? 'aria-current="page"' : "" %>>General OKF</a> <a href="/plugins/okf/admin?tab=general" <%= activeTab === "general" ? 'aria-current="page"' : "" %>>General OKF</a>
<a href="/plugins/okf/admin?tab=community" <%= activeTab === "community" ? 'aria-current="page"' : "" %>>Community OKF</a> <a href="/plugins/okf/admin?tab=community" <%= activeTab === "community" ? 'aria-current="page"' : "" %>>Community OKF</a>
<a href="/plugins/okf/admin?tab=system" <%= activeTab === "system" ? 'aria-current="page"' : "" %>>System-generated OKF</a> <a href="/plugins/okf/admin?tab=system" <%= activeTab === "system" ? 'aria-current="page"' : "" %>>System-generated OKF</a>
<% if (okfAccess.canManagePermissions) { %><a href="/plugins/okf/admin?tab=corrections" <%= activeTab === "corrections" ? 'aria-current="page"' : "" %>>Feedback corrections</a><% } %>
</nav> </nav>
<% if (activeTab === "general") { %> <% if (activeTab === "general") { %>
<form method="get" action="/plugins/okf/admin" class="log-controls"> <form method="get" action="/plugins/okf/admin" class="log-controls">
@ -49,8 +52,10 @@
</form> </form>
<% } else if (activeTab === "community") { %> <% } else if (activeTab === "community") { %>
<p class="hint">Community files are locally maintained OKF Markdown under <code>knowledge/community</code>.</p> <p class="hint">Community files are locally maintained OKF Markdown under <code>knowledge/community</code>.</p>
<% } else { %> <% } else if (activeTab === "system") { %>
<p class="hint">System-generated files are read-only OKF Markdown from core and plugin metadata.</p> <p class="hint">System-generated files are read-only OKF Markdown from core and plugin metadata.</p>
<% } else { %>
<p class="hint">Feedback corrections are reviewed, searchable knowledge fixes linked back to the core feedback that created them.</p>
<% } %> <% } %>
<datalist id="okf-category-suggestions"> <datalist id="okf-category-suggestions">
<% categories.forEach((category) => { %> <% categories.forEach((category) => { %>
@ -124,6 +129,7 @@
<label><span>Category</span><select data-okf-file-filter="category"><option value="">All categories</option><% [...new Set(communityFiles.map((file) => file.category).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label> <label><span>Category</span><select data-okf-file-filter="category"><option value="">All categories</option><% [...new Set(communityFiles.map((file) => file.category).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
<label><span>Status</span><select data-okf-file-filter="status"><option value="">All statuses</option><% [...new Set(communityFiles.map((file) => file.status).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label> <label><span>Status</span><select data-okf-file-filter="status"><option value="">All statuses</option><% [...new Set(communityFiles.map((file) => file.status).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
<label><span>Visibility</span><select data-okf-file-filter="visibility"><option value="">All visibility</option><% [...new Set(communityFiles.map((file) => file.visibility).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label> <label><span>Visibility</span><select data-okf-file-filter="visibility"><option value="">All visibility</option><% [...new Set(communityFiles.map((file) => file.visibility).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
<label><span>Tag</span><select data-okf-file-filter="tags"><option value="">All tags</option><% [...new Set(communityFiles.flatMap((file) => file.tags || []).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
<button class="button subtle" type="reset">Reset</button> <button class="button subtle" type="reset">Reset</button>
</form> </form>
<p class="hint" data-okf-file-count><%= communityFiles.length %> file<%= communityFiles.length === 1 ? "" : "s" %> shown.</p> <p class="hint" data-okf-file-count><%= communityFiles.length %> file<%= communityFiles.length === 1 ? "" : "s" %> shown.</p>
@ -135,7 +141,7 @@
<thead><tr><th>File</th><th>Category</th><th>Status</th><th>Visibility</th><th>Updated</th></tr></thead> <thead><tr><th>File</th><th>Category</th><th>Status</th><th>Visibility</th><th>Updated</th></tr></thead>
<tbody> <tbody>
<% communityFiles.forEach((file) => { %> <% communityFiles.forEach((file) => { %>
<tr data-okf-file-row data-category="<%= file.category %>" data-status="<%= file.status %>" data-visibility="<%= file.visibility %>" data-search="<%= [file.title, file.id, file.slug, file.category, ...(file.tags || [])].filter(Boolean).join(' ').toLowerCase() %>"> <tr data-okf-file-row data-category="<%= file.category %>" data-status="<%= file.status %>" data-visibility="<%= file.visibility %>" data-tags="|<%= (file.tags || []).join('|') %>|" data-search="<%= [file.title, file.id, file.slug, file.category, ...(file.tags || [])].filter(Boolean).join(' ').toLowerCase() %>">
<td><a href="/plugins/okf/admin?tab=community&community=<%= encodeURIComponent(file.slug) %>#okf-community-files"><strong><%= file.title %></strong></a><p class="hint"><%= file.id %> · <%= file.slug %>.md</p></td> <td><a href="/plugins/okf/admin?tab=community&community=<%= encodeURIComponent(file.slug) %>#okf-community-files"><strong><%= file.title %></strong></a><p class="hint"><%= file.id %> · <%= file.slug %>.md</p></td>
<td><%= file.category || "Community" %></td> <td><%= file.category || "Community" %></td>
<td><span class="badge"><%= file.status %></span></td> <td><span class="badge"><%= file.status %></span></td>
@ -185,6 +191,7 @@
<option value="<%= value %>" <%= selectedCommunity && selectedCommunity.visibility === value ? "selected" : "" %>><%= value %></option> <option value="<%= value %>" <%= selectedCommunity && selectedCommunity.visibility === value ? "selected" : "" %>><%= value %></option>
<% }) %> <% }) %>
</select> </select>
<span class="hint">Controls the minimum role allowed to retrieve or view this file.</span>
</div> </div>
<div class="field"> <div class="field">
<label>Priority</label> <label>Priority</label>
@ -197,8 +204,13 @@
</div> </div>
<div class="field full"> <div class="field full">
<label>Markdown body</label> <label>Markdown body</label>
<textarea name="body" rows="14" placeholder="# Community facts" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user"><%= selectedCommunity ? selectedCommunity.body : '' %></textarea> <textarea name="body" rows="14" placeholder="# Community facts" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user" data-okf-markdown-input><%= selectedCommunity ? selectedCommunity.body : '' %></textarea>
<span class="hint">You can reference visible frontmatter values with placeholders such as <code>{{community.currency.primary_name}}</code>.</span> <span class="hint">You can reference visible frontmatter values with placeholders such as <code>{{community.currency.primary_name}}</code>.</span>
<details class="feedback-metadata" data-okf-markdown-preview-panel>
<summary>Preview Markdown</summary>
<p class="hint" data-okf-markdown-preview-status>Open to preview the current text. The same safe renderer is used on the published page.</p>
<div class="feedback-copy-block" data-okf-markdown-preview></div>
</details>
</div> </div>
<% if (selectedCommunity && (selectedCommunity.generated || !selectedCommunity.editable)) { %> <% if (selectedCommunity && (selectedCommunity.generated || !selectedCommunity.editable)) { %>
<div class="field full"> <div class="field full">
@ -237,6 +249,7 @@
<label><span>Category</span><select data-okf-file-filter="category"><option value="">All categories</option><% [...new Set(systemFiles.map((file) => file.category).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label> <label><span>Category</span><select data-okf-file-filter="category"><option value="">All categories</option><% [...new Set(systemFiles.map((file) => file.category).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
<label><span>Scope</span><select data-okf-file-filter="scope"><option value="">All scopes</option><% [...new Set(systemFiles.map((file) => file.scope).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label> <label><span>Scope</span><select data-okf-file-filter="scope"><option value="">All scopes</option><% [...new Set(systemFiles.map((file) => file.scope).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
<label><span>Visibility</span><select data-okf-file-filter="visibility"><option value="">All visibility</option><% [...new Set(systemFiles.map((file) => file.visibility).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label> <label><span>Visibility</span><select data-okf-file-filter="visibility"><option value="">All visibility</option><% [...new Set(systemFiles.map((file) => file.visibility).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
<label><span>Tag</span><select data-okf-file-filter="tags"><option value="">All tags</option><% [...new Set(systemFiles.flatMap((file) => file.tags || []).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
<button class="button subtle" type="reset">Reset</button> <button class="button subtle" type="reset">Reset</button>
</form> </form>
<p class="hint" data-okf-file-count><%= systemFiles.length %> file<%= systemFiles.length === 1 ? "" : "s" %> shown. Generated files are rebuilt from repository metadata and are read-only here.</p> <p class="hint" data-okf-file-count><%= systemFiles.length %> file<%= systemFiles.length === 1 ? "" : "s" %> shown. Generated files are rebuilt from repository metadata and are read-only here.</p>
@ -248,7 +261,7 @@
<thead><tr><th>File</th><th>Scope</th><th>Category</th><th>Status</th><th>Visibility</th></tr></thead> <thead><tr><th>File</th><th>Scope</th><th>Category</th><th>Status</th><th>Visibility</th></tr></thead>
<tbody> <tbody>
<% systemFiles.forEach((file) => { %> <% systemFiles.forEach((file) => { %>
<tr data-okf-file-row data-category="<%= file.category %>" data-scope="<%= file.scope %>" data-visibility="<%= file.visibility %>" data-search="<%= [file.title, file.id, file.slug, file.scope, file.category, ...(file.tags || [])].filter(Boolean).join(' ').toLowerCase() %>"> <tr data-okf-file-row data-category="<%= file.category %>" data-scope="<%= file.scope %>" data-visibility="<%= file.visibility %>" data-tags="|<%= (file.tags || []).join('|') %>|" data-search="<%= [file.title, file.id, file.slug, file.scope, file.category, ...(file.tags || [])].filter(Boolean).join(' ').toLowerCase() %>">
<td><a href="/plugins/okf/admin?tab=system&system=<%= encodeURIComponent(file.slug) %>#okf-system-files"><strong><%= file.title %></strong></a><p class="hint"><%= file.id %> · <%= file.path %></p></td> <td><a href="/plugins/okf/admin?tab=system&system=<%= encodeURIComponent(file.slug) %>#okf-system-files"><strong><%= file.title %></strong></a><p class="hint"><%= file.id %> · <%= file.path %></p></td>
<td><%= file.scope %></td> <td><%= file.scope %></td>
<td><%= file.category || "General" %></td> <td><%= file.category || "General" %></td>
@ -277,6 +290,46 @@
</section> </section>
<% } %> <% } %>
<% if (activeTab === "corrections" && okfAccess.canManagePermissions) { %>
<section class="card" id="okf-corrections">
<div class="section-header">
<div>
<h2>Feedback corrections</h2>
<p class="hint">These files have retrieval priority over generated and community knowledge so reviewed fixes win when topics overlap.</p>
</div>
<a class="button subtle" href="/admin/feedback">Open feedback review</a>
</div>
<% if (!correctionFiles.length) { %>
<div class="empty-state">No feedback correction files have been created yet.</div>
<% } else { %>
<div class="table-wrap">
<table class="table">
<thead><tr><th>Correction</th><th>Category</th><th>Visibility</th><th>Priority</th><th>Source</th></tr></thead>
<tbody>
<% correctionFiles.forEach((file) => { %>
<tr>
<td><a href="/plugins/okf/admin?tab=corrections&correction=<%= encodeURIComponent(file.slug) %>#okf-corrections"><strong><%= file.title %></strong></a><p class="hint"><%= file.path %></p></td>
<td><%= file.category || "Correction" %></td>
<td><%= file.visibility %></td>
<td><%= file.priority %></td>
<td><% if (String(file.frontmatter.source_feedback_url || '').startsWith('/admin/feedback')) { %><a href="<%= file.frontmatter.source_feedback_url %>">View feedback</a><% } else { %><span class="hint">Not linked</span><% } %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
<% if (selectedCorrection) { %>
<details class="feedback-metadata" open>
<summary><%= selectedCorrection.title %></summary>
<p class="hint"><%= selectedCorrection.path %> · priority <%= selectedCorrection.priority %> · <%= selectedCorrection.visibility %></p>
<% if (String(selectedCorrection.frontmatter.source_feedback_url || '').startsWith('/admin/feedback')) { %><p><a class="button subtle" href="<%= selectedCorrection.frontmatter.source_feedback_url %>">Open source feedback</a></p><% } %>
<div class="feedback-copy-block"><%- renderMarkdown(selectedCorrection.body || "_No correction body available._") %></div>
</details>
<% } %>
</section>
<% } %>
<% if (selected) { %> <% if (selected) { %>
<div class="modal-backdrop okf-edit-modal is-open" id="okf-editor" data-okf-edit-modal aria-hidden="false"> <div class="modal-backdrop okf-edit-modal is-open" id="okf-editor" data-okf-edit-modal aria-hidden="false">
<div class="modal okf-entry-modal-dialog okf-entry-fullscreen-modal" role="dialog" aria-modal="true" aria-labelledby="okf-edit-title"> <div class="modal okf-entry-modal-dialog okf-entry-fullscreen-modal" role="dialog" aria-modal="true" aria-labelledby="okf-edit-title">
@ -309,29 +362,32 @@
<span class="hint">Use comma-separated tags. Existing tags appear as suggestions while typing.</span> <span class="hint">Use comma-separated tags. Existing tags appear as suggestions while typing.</span>
</div> </div>
<div class="field"> <div class="field">
<label>Visibility</label> <label>Who can see this entry?</label>
<select name="visibility"> <select name="visibility">
<% visibilityValues.forEach((value) => { %> <% visibilityValues.forEach((value) => { %>
<option value="<%= value %>" <%= selected && selected.visibility === value ? "selected" : "" %>><%= value %></option> <option value="<%= value %>" <%= selected && selected.visibility === value ? "selected" : "" %>><%= value %></option>
<% }) %> <% }) %>
</select> </select>
<span class="hint">Sets the minimum role that can find, view, or retrieve this entry.</span>
</div> </div>
<div class="field"> <div class="field">
<label>Status</label> <label>Publishing status</label>
<select name="status" <%= okfAccess.canImplement ? "" : "disabled" %>> <select name="status" <%= okfAccess.canImplement ? "" : "disabled" %>>
<% statuses.forEach((status) => { %> <% statuses.forEach((status) => { %>
<option value="<%= status %>" <%= selected && selected.status === status ? "selected" : "" %>><%= status %></option> <option value="<%= status %>" <%= selected && selected.status === status ? "selected" : "" %>><%= status %></option>
<% }) %> <% }) %>
</select> </select>
<span class="hint">Drafts stay in the contributor workflow. Published entries can appear in Knowledge after approval.</span>
<% if (!okfAccess.canImplement) { %><span class="hint">Editors can propose changes; publishing requires implement permission.</span><% } %> <% if (!okfAccess.canImplement) { %><span class="hint">Editors can propose changes; publishing requires implement permission.</span><% } %>
</div> </div>
<div class="field"> <div class="field">
<label>Review state</label> <label>Review progress</label>
<select name="review_state" <%= okfAccess.canImplement ? "" : "disabled" %>> <select name="review_state" <%= okfAccess.canImplement ? "" : "disabled" %>>
<% reviewStates.forEach((state) => { %> <% reviewStates.forEach((state) => { %>
<option value="<%= state %>" <%= selected && selected.review_state === state ? "selected" : "" %>><%= state %></option> <option value="<%= state %>" <%= selected && selected.review_state === state ? "selected" : "" %>><%= state %></option>
<% }) %> <% }) %>
</select> </select>
<span class="hint">Tracks editorial approval separately from whether an entry is published.</span>
</div> </div>
<div class="field"> <div class="field">
<label>Aliases / related questions</label> <label>Aliases / related questions</label>
@ -344,25 +400,34 @@
</div> </div>
<div class="field full"> <div class="field full">
<label>User-facing Markdown answer</label> <label>User-facing Markdown answer</label>
<textarea name="user_markdown" rows="8" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user"><%= selected ? selected.user_markdown : '' %></textarea> <textarea name="user_markdown" rows="8" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user" data-okf-markdown-input><%= selected ? selected.user_markdown : '' %></textarea>
<details class="feedback-metadata" data-okf-markdown-preview-panel><summary>Preview user answer</summary><p class="hint" data-okf-markdown-preview-status></p><div class="feedback-copy-block" data-okf-markdown-preview></div></details>
</div> </div>
<% if (okfAccess.capabilities.read_mod_fields) { %>
<div class="field full"> <div class="field full">
<label>Moderator/support Markdown details</label> <label>Moderator/support Markdown details</label>
<textarea name="moderator_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="mod"><%= selected ? selected.moderator_markdown : '' %></textarea> <textarea name="moderator_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="mod" data-okf-markdown-input><%= selected ? selected.moderator_markdown : '' %></textarea>
<details class="feedback-metadata" data-okf-markdown-preview-panel><summary>Preview moderator details</summary><p class="hint" data-okf-markdown-preview-status></p><div class="feedback-copy-block" data-okf-markdown-preview></div></details>
</div> </div>
<% } %>
<% if (okfAccess.capabilities.read_admin_fields) { %>
<div class="field full"> <div class="field full">
<label>Admin/internal Markdown details</label> <label>Admin/internal Markdown details</label>
<textarea name="admin_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"><%= selected ? selected.admin_markdown : '' %></textarea> <textarea name="admin_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin" data-okf-markdown-input><%= selected ? selected.admin_markdown : '' %></textarea>
<details class="feedback-metadata" data-okf-markdown-preview-panel><summary>Preview admin details</summary><p class="hint" data-okf-markdown-preview-status></p><div class="feedback-copy-block" data-okf-markdown-preview></div></details>
</div> </div>
<div class="field full"> <div class="field full">
<label>AI-facing facts/context</label> <label>AI-facing facts/context</label>
<textarea name="ai_facts_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"><%= selected ? selected.ai_facts_markdown : '' %></textarea> <textarea name="ai_facts_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin" data-okf-markdown-input><%= selected ? selected.ai_facts_markdown : '' %></textarea>
<span class="hint">Stored now for future AI retrieval integration. The public UI only shows this to admins/editors.</span> <span class="hint">Concise grounding facts for Lumi AI. These remain permission-filtered and are never sent to roles below this entry's visibility.</span>
<details class="feedback-metadata" data-okf-markdown-preview-panel><summary>Preview AI facts</summary><p class="hint" data-okf-markdown-preview-status></p><div class="feedback-copy-block" data-okf-markdown-preview></div></details>
</div> </div>
<div class="field full"> <div class="field full">
<label>Source links / references</label> <label>Source links / references</label>
<textarea name="source_links" rows="3" placeholder="One URL or local path per line"><%= selected ? selected.source_links.join('\n') : '' %></textarea> <textarea name="source_links" rows="3" placeholder="One URL or local path per line"><%= selected ? selected.source_links.join('\n') : '' %></textarea>
<span class="hint">Add one supporting web link, in-app path, or section link per line. Unsafe URL schemes are ignored.</span>
</div> </div>
<% } %>
<div class="field full"> <div class="field full">
<label>Change note</label> <label>Change note</label>
<input name="change_note" placeholder="Optional note for version history" /> <input name="change_note" placeholder="Optional note for version history" />
@ -377,6 +442,7 @@
<details class="feedback-metadata"> <details class="feedback-metadata">
<summary>Role preview</summary> <summary>Role preview</summary>
<div class="stats-grid"> <div class="stats-grid">
<% if (okfAccess.capabilities.read_mod_fields) { %>
<article class="stat-card"> <article class="stat-card">
<span class="stat-label">User view</span> <span class="stat-label">User view</span>
<strong><%= selected.title %></strong> <strong><%= selected.title %></strong>
@ -390,14 +456,17 @@
<div class="feedback-copy-block"><%- renderMarkdown(selected.user_markdown || "_No user-facing answer yet._") %></div> <div class="feedback-copy-block"><%- renderMarkdown(selected.user_markdown || "_No user-facing answer yet._") %></div>
<div class="feedback-copy-block"><%- renderMarkdown(selected.moderator_markdown || "_No moderator/support details yet._") %></div> <div class="feedback-copy-block"><%- renderMarkdown(selected.moderator_markdown || "_No moderator/support details yet._") %></div>
</article> </article>
<% } %>
<% if (okfAccess.capabilities.read_admin_fields) { %>
<article class="stat-card"> <article class="stat-card">
<span class="stat-label">Admin/editor view</span> <span class="stat-label">Admin view</span>
<strong><%= selected.title %></strong> <strong><%= selected.title %></strong>
<p><%= selected.summary || "No summary provided." %></p> <p><%= selected.summary || "No summary provided." %></p>
<div class="feedback-copy-block"><%- renderMarkdown(selected.user_markdown || "_No user-facing answer yet._") %></div> <div class="feedback-copy-block"><%- renderMarkdown(selected.user_markdown || "_No user-facing answer yet._") %></div>
<div class="feedback-copy-block"><%- renderMarkdown(selected.moderator_markdown || "_No moderator/support details yet._") %></div> <div class="feedback-copy-block"><%- renderMarkdown(selected.moderator_markdown || "_No moderator/support details yet._") %></div>
<div class="feedback-copy-block"><%- renderMarkdown(selected.admin_markdown || "_No admin/internal details yet._") %></div> <div class="feedback-copy-block"><%- renderMarkdown(selected.admin_markdown || "_No admin/internal details yet._") %></div>
</article> </article>
<% } %>
</div> </div>
<p class="hint">AI facts are stored separately for future role-aware retrieval and are not shown in normal user/mod previews.</p> <p class="hint">AI facts are stored separately for future role-aware retrieval and are not shown in normal user/mod previews.</p>
</details> </details>
@ -412,13 +481,13 @@
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/publish" class="inline-form"> <form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/publish" class="inline-form">
<button class="button subtle" type="submit">Publish</button> <button class="button subtle" type="submit">Publish</button>
</form> </form>
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/archive" class="inline-form"> <form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/archive" class="inline-form" data-confirm-mode="modal" data-confirm-title="Archive OKF entry" data-confirm-text="Archive this OKF entry?" data-confirm-label="Archive entry">
<button class="button subtle" type="submit">Archive</button> <button class="button danger" type="submit">Archive</button>
</form> </form>
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/restore" class="inline-form"> <form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/restore" class="inline-form">
<button class="button subtle" type="submit">Restore as draft</button> <button class="button subtle" type="submit">Restore as draft</button>
</form> </form>
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-text="Delete this OKF entry? The version history is kept for audit, but the entry will no longer be visible."> <form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-title="Delete OKF entry" data-confirm-text="Delete this OKF entry? The version history is kept for audit, but the entry will no longer be visible." data-confirm-label="Delete entry">
<button class="button danger" type="submit">Delete</button> <button class="button danger" type="submit">Delete</button>
</form> </form>
<% } %> <% } %>
@ -432,7 +501,7 @@
<% versions.forEach((version) => { %> <% versions.forEach((version) => { %>
<article class="feedback-copy-block"> <article class="feedback-copy-block">
<strong>Version <%= version.version_number %> · <%= version.change_type %></strong> <strong>Version <%= version.version_number %> · <%= version.change_type %></strong>
<p class="hint"><%= new Date(version.created_at).toLocaleString() %> · <%= version.changed_by || "system" %> · <%= version.note || "No note." %></p> <p class="hint"><%= new Date(version.created_at).toLocaleString() %> · <%= version.changed_by || "Contributor" %> · <%= version.note || "No note." %></p>
<details> <details>
<summary>Snapshot</summary> <summary>Snapshot</summary>
<pre><%= JSON.stringify(version.next, null, 2) %></pre> <pre><%= JSON.stringify(version.next, null, 2) %></pre>
@ -516,10 +585,13 @@
<label>User-facing Markdown answer</label> <label>User-facing Markdown answer</label>
<textarea name="user_markdown" rows="8" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user"></textarea> <textarea name="user_markdown" rows="8" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user"></textarea>
</div> </div>
<% if (okfAccess.capabilities.read_mod_fields) { %>
<div class="field full"> <div class="field full">
<label>Moderator/support Markdown details</label> <label>Moderator/support Markdown details</label>
<textarea name="moderator_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="mod"></textarea> <textarea name="moderator_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="mod"></textarea>
</div> </div>
<% } %>
<% if (okfAccess.capabilities.read_admin_fields) { %>
<div class="field full"> <div class="field full">
<label>Admin/internal Markdown details</label> <label>Admin/internal Markdown details</label>
<textarea name="admin_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"></textarea> <textarea name="admin_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"></textarea>
@ -527,12 +599,13 @@
<div class="field full"> <div class="field full">
<label>AI-facing facts/context</label> <label>AI-facing facts/context</label>
<textarea name="ai_facts_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"></textarea> <textarea name="ai_facts_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"></textarea>
<span class="hint">Stored now for future AI retrieval integration. The public UI only shows this to admins/editors.</span> <span class="hint">Used only for admin-visible AI context.</span>
</div> </div>
<div class="field full"> <div class="field full">
<label>Source links / references</label> <label>Source links / references</label>
<textarea name="source_links" rows="3" placeholder="One URL or local path per line"></textarea> <textarea name="source_links" rows="3" placeholder="One URL or local path per line"></textarea>
</div> </div>
<% } %>
<div class="field full"> <div class="field full">
<label>Change note</label> <label>Change note</label>
<input name="change_note" placeholder="Optional note for version history" /> <input name="change_note" placeholder="Optional note for version history" />
@ -600,8 +673,8 @@
<td><%= grant.revoked_at ? `Revoked ${new Date(grant.revoked_at).toLocaleString()}` : "Active" %></td> <td><%= grant.revoked_at ? `Revoked ${new Date(grant.revoked_at).toLocaleString()}` : "Active" %></td>
<td> <td>
<% if (!grant.revoked_at) { %> <% if (!grant.revoked_at) { %>
<form method="post" action="/plugins/okf/admin/permissions/<%= grant.id %>/revoke" class="inline-form"> <form method="post" action="/plugins/okf/admin/permissions/<%= grant.id %>/revoke" class="inline-form" data-confirm-mode="modal" data-confirm-title="Revoke OKF permission" data-confirm-text="Revoke this permission grant?" data-confirm-label="Revoke permission">
<button class="button subtle" type="submit">Revoke</button> <button class="button danger" type="submit">Revoke</button>
</form> </form>
<% } %> <% } %>
</td> </td>
@ -626,7 +699,13 @@
let visible = 0; let visible = 0;
rows.forEach((row) => { rows.forEach((row) => {
const matchesSearch = !query || String(row.dataset.search || "").includes(query); const matchesSearch = !query || String(row.dataset.search || "").includes(query);
const matchesFilters = filters.every((field) => !field.value || row.dataset[field.dataset.okfFileFilter] === field.value); const matchesFilters = filters.every((field) => {
if (!field.value) return true;
const rowValue = String(row.dataset[field.dataset.okfFileFilter] || "");
return field.dataset.okfFileFilter === "tags"
? rowValue.includes(`|${field.value}|`)
: rowValue === field.value;
});
row.hidden = !(matchesSearch && matchesFilters); row.hidden = !(matchesSearch && matchesFilters);
if (!row.hidden) visible += 1; if (!row.hidden) visible += 1;
}); });
@ -639,6 +718,45 @@
form?.addEventListener("reset", () => window.setTimeout(applyFilters, 0)); form?.addEventListener("reset", () => window.setTimeout(applyFilters, 0));
}); });
document.querySelectorAll("[data-okf-markdown-preview-panel]").forEach((panel) => {
const field = panel.closest(".field")?.querySelector("[data-okf-markdown-input]");
const output = panel.querySelector("[data-okf-markdown-preview]");
const status = panel.querySelector("[data-okf-markdown-preview-status]");
if (!field || !output) return;
let timer = null;
let request = 0;
const update = async () => {
const current = ++request;
if (status) status.textContent = "Rendering preview...";
try {
const response = await fetch("/plugins/okf/admin/preview", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ markdown: field.value || "" })
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "Preview unavailable.");
if (current !== request) return;
output.innerHTML = payload.html;
if (status) status.textContent = "Safe preview of the current unsaved text.";
} catch (error) {
if (current !== request) return;
output.textContent = "";
if (status) status.textContent = error.message;
}
};
const schedule = () => {
window.clearTimeout(timer);
timer = window.setTimeout(update, 180);
};
panel.addEventListener("toggle", () => {
if (panel.open) update();
});
field.addEventListener("input", () => {
if (panel.open) schedule();
});
});
(() => { (() => {
const modal = document.querySelector("[data-okf-create-modal]"); const modal = document.querySelector("[data-okf-create-modal]");
const openButtons = document.querySelectorAll("[data-okf-create-open]"); const openButtons = document.querySelectorAll("[data-okf-create-open]");

View File

@ -183,7 +183,7 @@
<button type="submit" class="button">Restore</button> <button type="submit" class="button">Restore</button>
</form> </form>
<% } else { %> <% } else { %>
<form method="post" action="/plugins/quotes/quotes/<%= quote.id %>/archive" class="inline-form"> <form method="post" action="/plugins/quotes/quotes/<%= quote.id %>/archive" class="inline-form" data-confirm-mode="modal" data-confirm-title="Archive quote" data-confirm-text="Archive this quote?" data-confirm-label="Archive quote">
<button type="submit" class="button danger">Archive</button> <button type="submit" class="button danger">Archive</button>
</form> </form>
<% } %> <% } %>

View File

@ -90,8 +90,8 @@
</td> </td>
<td> <td>
<div class="action-row"> <div class="action-row">
<button type="button" class="button subtle" data-confirm-open data-confirm-title="Renew endpoint?" data-confirm-text="The current Throne URL will stop working immediately." data-confirm-action="/plugins/throne_wishlist/endpoints/<%= endpoint.id %>/renew">Renew</button> <button type="button" class="button subtle" data-confirm-mode="modal" data-confirm-title="Renew endpoint?" data-confirm-text="The current Throne URL will stop working immediately." data-confirm-label="Renew endpoint" data-confirm-action="/plugins/throne_wishlist/endpoints/<%= endpoint.id %>/renew">Renew</button>
<button type="button" class="button subtle danger" data-confirm-open data-confirm-title="Remove endpoint?" data-confirm-text="This endpoint will stop accepting Throne payloads." data-confirm-action="/plugins/throne_wishlist/endpoints/<%= endpoint.id %>/remove">Remove</button> <button type="button" class="button subtle danger" data-confirm-mode="modal" data-confirm-title="Remove endpoint?" data-confirm-text="This endpoint will stop accepting Throne payloads." data-confirm-label="Remove endpoint" data-confirm-action="/plugins/throne_wishlist/endpoints/<%= endpoint.id %>/remove">Remove</button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -45,7 +45,7 @@
</div> </div>
<div class="field full"> <div class="field full">
<% if (!message.archived) { %> <% if (!message.archived) { %>
<button type="submit" class="button">Save</button> <button type="submit" class="button">Save message</button>
<% } %> <% } %>
</div> </div>
</form> </form>
@ -60,7 +60,7 @@
<button type="submit" class="button">Restore</button> <button type="submit" class="button">Restore</button>
</form> </form>
<% } else { %> <% } else { %>
<form method="post" action="/plugins/welcome_messages/messages/<%= message.id %>/archive" class="inline-form"> <form method="post" action="/plugins/welcome_messages/messages/<%= message.id %>/archive" class="inline-form" data-confirm-mode="modal" data-confirm-title="Archive welcome message" data-confirm-text="Archive this welcome message?" data-confirm-label="Archive message">
<input type="hidden" name="pool" value="<%= pool %>" /> <input type="hidden" name="pool" value="<%= pool %>" />
<button type="submit" class="button danger">Archive</button> <button type="submit" class="button danger">Archive</button>
</form> </form>

View File

@ -0,0 +1,82 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { performance } = require("perf_hooks");
const { ensureKnowledgeDirs, searchFileKnowledge } = require("../plugins/okf/backend/file_knowledge");
const fixtureCount = Math.max(100, Math.min(Number(process.argv[2]) || 1000, 10000));
const queryCount = 120;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-okf-benchmark-"));
const user = { id: "benchmark-user" };
const admin = { id: "benchmark-admin", isAdmin: true, isMod: true };
try {
ensureKnowledgeDirs(root);
const community = path.join(root, "knowledge", "community");
for (let index = 0; index < fixtureCount; index += 1) {
const visibility = index % 20 === 0 ? "admin" : "user";
const secret = visibility === "admin" ? "sealedterm " : "";
fs.writeFileSync(path.join(community, `fixture-${index}.md`), [
"---",
`id: benchmark.fixture-${index}`,
`title: Benchmark topic ${index}`,
"scope: community",
"status: active",
`visibility: ${visibility}`,
`priority: ${index % 7}`,
`tags: benchmark, topic-${index % 50}`,
"---",
`# Topic ${index}`,
`${secret}Support guidance for fixture ${index}, group ${index % 50}, and setting ${index % 17}.`,
"This paragraph adds enough ordinary text to approximate a small knowledge entry."
].join("\n"));
}
const coldStart = performance.now();
const coldResults = searchFileKnowledge({ query: "topic 42 setting", user, rootDir: root, limit: 5 });
const coldMs = performance.now() - coldStart;
assert(coldResults.length > 0);
const timings = [];
for (let index = 0; index < queryCount; index += 1) {
const started = performance.now();
const results = searchFileKnowledge({
query: `topic ${index % 50} setting ${index % 17}`,
user,
rootDir: root,
limit: 5
});
timings.push(performance.now() - started);
assert(results.length > 0);
}
assert.equal(searchFileKnowledge({ query: "sealedterm", user, rootDir: root, limit: 25 }).length, 0);
assert(searchFileKnowledge({ query: "sealedterm", user: admin, rootDir: root, limit: 25 }).length > 0);
const changedPath = path.join(community, "fixture-1.md");
fs.appendFileSync(changedPath, "\nuniquelychangedterm\n");
assert(searchFileKnowledge({ query: "uniquelychangedterm", user, rootDir: root, limit: 5 }).length > 0);
fs.rmSync(changedPath);
assert.equal(searchFileKnowledge({ query: "uniquelychangedterm", user, rootDir: root, limit: 5 }).length, 0);
timings.sort((a, b) => a - b);
const percentile = (fraction) => timings[Math.min(timings.length - 1, Math.floor(timings.length * fraction))];
const result = {
fixtures: fixtureCount,
queries: queryCount,
cold_ms: round(coldMs),
warm_median_ms: round(percentile(0.5)),
warm_p95_ms: round(percentile(0.95)),
warm_max_ms: round(timings[timings.length - 1])
};
console.log(JSON.stringify(result, null, 2));
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
function round(value) {
return Math.round(value * 100) / 100;
}

56
scripts/verify-all.js Normal file
View File

@ -0,0 +1,56 @@
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const root = path.join(__dirname, "..");
const checks = [
"scripts/verify-preflight.js",
"scripts/verify-webui.js",
"scripts/verify-web-auth.js",
"scripts/verify-feedback-system.js",
"scripts/verify-placeholders.js",
"scripts/verify-plugin-update-preserves-data.js",
"scripts/verify-safe-files.js",
"scripts/verify-upload-security.js",
"plugins/okf/tests/verify.js",
"plugins/lumi_ai/tests/verify.js",
"plugins/lumi_ai/tests/verify-tools.js",
"plugins/lumi_ai_web_search/tests/verify.js",
"scripts/verify-assistant-panels.js",
"scripts/verify-command-preview-confirmations.js",
"scripts/verify-destructive-actions.js",
"scripts/verify-overlays.js",
"scripts/verify-webhooks.js"
];
function listJavaScript(directory, output = []) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (["node_modules", ".git"].includes(entry.name) || entry.name.startsWith(".tmp-")) continue;
const target = path.join(directory, entry.name);
if (entry.isDirectory()) listJavaScript(target, output);
else if (entry.name.endsWith(".js")) output.push(target);
}
return output;
}
function run(label, args) {
console.log(`\n[verify] ${label}`);
const result = spawnSync(process.execPath, args, { cwd: root, stdio: "inherit" });
if (result.error) {
console.error(`[verify] Unable to start ${label}: ${result.error.message}`);
process.exit(result.status || 1);
}
if (result.status !== 0) {
console.error(`[verify] ${label} failed with exit code ${result.status}.`);
process.exit(result.status || 1);
}
}
run("native dependency preflight", [path.join(root, "scripts", "verify-preflight.js")]);
const javaScriptFiles = ["src", "plugins", "scripts"]
.flatMap((directory) => listJavaScript(path.join(root, directory)));
for (const file of javaScriptFiles) {
run(`syntax: ${path.relative(root, file)}`, ["--check", file]);
}
for (const check of checks.slice(1)) run(check, [path.join(root, check)]);
console.log(`\nAll Lumi verification passed (${javaScriptFiles.length} JavaScript files, ${checks.length - 1} focused suites).`);

View File

@ -7,6 +7,12 @@ const {
normalizePreviewText, normalizePreviewText,
previewParts previewParts
} = require("../src/services/command-preview"); } = require("../src/services/command-preview");
const { runAdvancedCommand } = require("../src/services/commands");
const {
conditionMatches,
selectRandomReply,
validateRandomConfig
} = require("../src/services/command-random");
const { const {
DELAY_MS, DELAY_MS,
issueConfirmation, issueConfirmation,
@ -23,7 +29,11 @@ async function run() {
"preview_status", "preview_status",
"preview_error", "preview_error",
"preview_generated_at", "preview_generated_at",
"preview_dynamic_segments" "preview_dynamic_segments",
"random_replies_json",
"rng_enabled",
"rng_min",
"rng_max"
]) { ]) {
assert(columns.includes(column), `Missing custom command preview column: ${column}`); assert(columns.includes(column), `Missing custom command preview column: ${column}`);
} }
@ -47,6 +57,78 @@ async function run() {
assert(previewParts(preview.preview_text, segments).some((part) => part.dynamic)); assert(previewParts(preview.preview_text, segments).some((part) => part.dynamic));
assert.equal(normalizePreviewText("Some User and some-user"), "some_user and some_user"); assert.equal(normalizePreviewText("Some User and some-user"), "some_user and some_user");
assert(findDynamicSegments("some_user 42 user_123 2026-01-02T12:34:56.000Z").length, 4); assert(findDynamicSegments("some_user 42 user_123 2026-01-02T12:34:56.000Z").length, 4);
const punctuationPreview = "Fun fact for some_user:\nExample";
assert.equal(
previewParts(punctuationPreview, findDynamicSegments(punctuationPreview)).map((part) => part.text).join(""),
punctuationPreview
);
const snippetPreview = await generateCommandPreview({
language: "js",
code: 'return "Snippet for " + ctx.user.username;'
});
assert.equal(snippetPreview.preview_status, "ready");
assert.equal(snippetPreview.preview_text, "Snippet for some_user");
const exportedPreview = await generateCommandPreview({
language: "js",
code: 'module.exports = (ctx) => "Export for " + ctx.user.username;'
});
assert.equal(exportedPreview.preview_status, "ready");
assert.equal(exportedPreview.preview_text, "Export for some_user");
const safeBuiltInsPreview = await generateCommandPreview({
language: "js",
code: `
const facts = ["using a process called respiration"];
const selected = facts[Math.floor(Math.random() * facts.length)];
const url = new URL("https://example.com/facts?count=1");
return JSON.stringify({ selected, host: url.hostname, count: url.searchParams.get("count") });
`
});
assert.equal(safeBuiltInsPreview.preview_status, "ready");
assert.deepEqual(JSON.parse(safeBuiltInsPreview.preview_text), {
selected: "using a process called respiration",
host: "example.com",
count: "1"
});
for (const code of [
'return Math.constructor.constructor("return pro" + "cess")().cwd();',
'return ctx.constructor.constructor("return pro" + "cess")().cwd();',
'return URL.constructor.constructor("return pro" + "cess")().cwd();'
]) {
const constructorEscape = await generateCommandPreview({ language: "js", code });
assert.equal(constructorEscape.preview_status, "unavailable");
assert.match(constructorEscape.preview_error, /code generation from strings disallowed/i);
}
const pythonSnippetPreview = await generateCommandPreview({
language: "python",
code: 'return "Python snippet"'
});
assert.equal(pythonSnippetPreview.preview_status, "ready");
assert.equal(pythonSnippetPreview.preview_text, "Python snippet");
assert.equal(await runAdvancedCommand({ language: "js", code: 'return "wrapped";' }, {}), "wrapped");
const randomConfig = {
replies: [
{ text: "Low {{core.command.rng}}", weight: 1, condition: "<20" },
{ text: "High", weight: 2, condition: ">=20" }
],
rngEnabled: true,
rngMin: 1,
rngMax: 100
};
const rolls = [0.1, 0.9];
const selectedRandom = selectRandomReply(randomConfig, () => rolls.shift());
assert.equal(selectedRandom.rng, 11);
assert.equal(selectedRandom.reply.text, "Low {{core.command.rng}}");
assert.equal(conditionMatches(">=10", 10), true);
assert.equal(conditionMatches("<20", 20), false);
assert.equal(validateRandomConfig({ replies: [{ text: "Hi", weight: 1000 }], rngMin: 1, rngMax: 100 }).ok, false);
assert.equal(validateRandomConfig({ replies: [{ text: "Hi", weight: 1, condition: "maybe" }], rngEnabled: true }).ok, false);
const noSideEffects = await generateCommandPreview({ const noSideEffects = await generateCommandPreview({
language: "js", language: "js",
@ -77,6 +159,18 @@ async function run() {
assert.equal(readOnlyFetch.preview_status, "ready"); assert.equal(readOnlyFetch.preview_status, "ready");
assert.equal(readOnlyFetch.preview_text, "preview"); assert.equal(readOnlyFetch.preview_text, "preview");
const blockedNetworkFetch = await generateCommandPreview({
language: "js",
code: `
async function run() {
await fetch("http://127.0.0.1:3000/admin/settings");
return "no";
}
`
});
assert.equal(blockedNetworkFetch.preview_status, "unavailable");
assert.match(blockedNetworkFetch.preview_error, /does not send external network requests/i);
const blockedWriteFetch = await generateCommandPreview({ const blockedWriteFetch = await generateCommandPreview({
language: "js", language: "js",
code: ` code: `
@ -142,8 +236,16 @@ async function run() {
assert(layout.includes("data-destructive-confirm disabled")); assert(layout.includes("data-destructive-confirm disabled"));
assert(commandView.includes("Preview unavailable")); assert(commandView.includes("Preview unavailable"));
assert(commandView.includes("preview-dynamic")); assert(commandView.includes("preview-dynamic"));
assert(commandView.includes('data-collapsed-label="Read more"'));
assert(commandView.includes('data-expanded-label="Read less"'));
assert.equal(commandView.includes("Full preview"), false);
assert.equal(commandView.includes("command-preview-full"), false);
assert(appScript.includes('[data-expand-text]'));
assert(commandView.includes(">Static<")); assert(commandView.includes(">Static<"));
assert(commandView.includes(">Random Reply<"));
assert(commandView.includes(">Dynamic<")); assert(commandView.includes(">Dynamic<"));
assert(commandView.includes("random_weight"));
assert(commandView.includes("{{core.command.rng}}"));
assert.equal(commandView.includes('"Advanced (" + command.language + ")"'), false); assert.equal(commandView.includes('"Advanced (" + command.language + ")"'), false);
console.log("Command preview and destructive confirmation verification passed."); console.log("Command preview and destructive confirmation verification passed.");

View File

@ -0,0 +1,103 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const {
issueConfirmation,
consumeConfirmation,
isDestructivePath
} = require("../src/services/destructive-confirm");
const root = path.join(__dirname, "..");
const destructivePath = /(?:^|\/)(?:delete|remove|clear|reset|renew|uninstall|cleanup|archive|revoke|unlink|unset|revert)(?:\/|$)/i;
function filesBelow(directory, extension, output = []) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) filesBelow(target, extension, output);
else if (target.endsWith(extension)) output.push(target);
}
return output;
}
function maskEjs(source) {
return source.replace(/<%[\s\S]*?%>/g, "EJS");
}
function attribute(tag, name) {
return tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i"))?.[1] || "";
}
function requireConfirmationCopy(tag, file) {
for (const attributeName of ["data-confirm-mode", "data-confirm-title", "data-confirm-text", "data-confirm-label"]) {
assert(new RegExp(`\\b${attributeName}(?:\\s*=|\\s|>)`, "i").test(tag), `${path.relative(root, file)} has an unconnected destructive control missing ${attributeName}: ${tag}`);
}
assert.equal(attribute(tag, "data-confirm-mode"), "modal", `${path.relative(root, file)} must use the shared timed modal`);
}
function verifyViews() {
const files = [
...filesBelow(path.join(root, "src", "web", "views"), ".ejs"),
...filesBelow(path.join(root, "plugins"), ".ejs")
];
let controls = 0;
for (const file of files) {
const source = maskEjs(fs.readFileSync(file, "utf8"));
assert(!/name\s*=\s*["']action["'][^>]*value\s*=\s*["']delete["']/i.test(source), `${path.relative(root, file)} hides a delete behind a generic POST route; use an explicit /delete endpoint`);
for (const tag of source.match(/<(?:form|button)\b[^>]*>/gi) || []) {
const action = attribute(tag, "action") || attribute(tag, "formaction");
const confirmAction = attribute(tag, "data-confirm-action");
if (!destructivePath.test(action) && !destructivePath.test(confirmAction)) continue;
requireConfirmationCopy(tag, file);
controls += 1;
}
}
assert(controls >= 30, `Expected broad destructive-control coverage, found only ${controls}`);
return controls;
}
function verifySharedInfrastructure() {
for (const action of [
"/admin/commands/1/delete",
"/admin/navigation/reset",
"/admin/updates/core/revert",
"/plugins/auto-vc/settings/remove"
]) {
assert.equal(isDestructivePath(action), true, `Server does not protect ${action}`);
}
const req = {
session: { user: { id: "admin-user", isAdmin: true } },
body: {},
get() { return null; }
};
const issued = issueConfirmation(req, "/admin/updates/core/revert");
req.session.destructive_confirmations[issued.token].not_before = Date.now() - 1;
assert.equal(consumeConfirmation(req, "/admin/updates/core/revert", issued.token).valid, true);
const appScript = fs.readFileSync(path.join(root, "src", "web", "public", "app.js"), "utf8");
assert(appScript.includes("submitter?.dataset?.confirmMode"), "Submit-button confirmation metadata is ignored");
assert(appScript.includes("submitter?.dataset?.confirmLabel || form.dataset.confirmLabel"), "The clicked submit button cannot override generic confirmation copy");
assert(appScript.includes('form.querySelector(\'input[name="confirmation_token"]\')'), "Timed form confirmation tokens are not attached to submitted forms");
assert(appScript.includes("const response = await fetch(action, requestOptions)"), "Async update requests do not submit through the shared confirmed form payload");
assert(!appScript.includes("window.LumiConfirm.destructiveFetch(form.action"), "Timed update forms request a second confirmation instead of reusing the form token");
assert(appScript.includes('state.confirmButton.removeEventListener("click", state.onConfirm)'), "Cancelled or expired confirmations leave stale modal submissions behind");
assert(appScript.includes("state.onConfirm = onConfirm"), "Timed form confirmations do not retain their listener for cleanup");
const toolManagerScript = fs.readFileSync(path.join(root, "plugins", "lumi_ai", "public", "tool-manager.js"), "utf8");
for (const field of ["confirmMode", "confirmTitle", "confirmText", "confirmLabel"]) {
assert(toolManagerScript.includes(`form.dataset.${field}`), `Dynamic AI tool deletion is missing ${field}`);
}
const autoVcView = fs.readFileSync(path.join(root, "plugins", "auto-vc", "views", "auto-vc.ejs"), "utf8");
assert(autoVcView.includes('settingsForm.action = "/plugins/auto-vc/settings/remove"'), "Auto VC lobby deletion does not switch to its protected route");
const improvementView = fs.readFileSync(path.join(root, "plugins", "lumi_ai", "views", "improvement-center.ejs"), "utf8");
assert(improvementView.includes("/reviews/<%= review.id %>/delete"));
assert(improvementView.includes("/corrections/<%= entry.id %>/delete"));
const improvementScript = fs.readFileSync(path.join(root, "plugins", "lumi_ai", "public", "improvement-center.js"), "utf8");
assert.equal(improvementScript.includes("window.confirm"), false, "Improvement Center still uses the legacy browser confirmation");
}
const controls = verifyViews();
verifySharedInfrastructure();
console.log(`Destructive action verification passed (${controls} controls).`);

View File

@ -42,6 +42,7 @@ try {
current_url: "http://localhost/admin/settings", current_url: "http://localhost/admin/settings",
page_title: "Settings", page_title: "Settings",
target_metadata: { target_metadata: {
path: "/admin/settings; Settings; Save settings",
selector: "#save-settings", selector: "#save-settings",
tag: "button", tag: "button",
text: "Save settings", text: "Save settings",
@ -64,12 +65,15 @@ try {
}); });
assert.equal(entry.status, "new"); assert.equal(entry.status, "new");
assert.equal(entry.scope_label, "Clicked element: Save settings"); assert.equal(entry.scope_label, "/admin/settings;Settings;Save settings");
assert.equal(entry.target_metadata.secret, undefined); assert.equal(entry.target_metadata.secret, undefined);
assert.equal(entry.diagnostics.hidden, undefined); assert.equal(entry.diagnostics.hidden, undefined);
assert.equal(entry.diagnostics.similar_feedback_confirmation, "distinct_or_additional_context"); assert.equal(entry.diagnostics.similar_feedback_confirmation, "distinct_or_additional_context");
assert.equal(entry.diagnostics.similar_feedback_ids, "12345678-abcd"); assert.equal(entry.diagnostics.similar_feedback_ids, "12345678-abcd");
assert.equal(entry.screenshot.mime, "image/png"); assert.equal(entry.screenshot.mime, "image/png");
assert.equal(feedback.listFeedbackForAdmin({ route: "/admin/settings" })[0].id, entry.id);
assert.equal(feedback.listFeedbackForAdmin({ target: "save-settings" })[0].id, entry.id);
assert.equal(feedback.listFeedbackForAdmin({ area: "#save-settings" })[0].id, entry.id);
assert.equal(feedback.listPublicFeedback({ userId: "user-1" })[0].is_mine, true); assert.equal(feedback.listPublicFeedback({ userId: "user-1" })[0].is_mine, true);
assert.equal(feedback.listMyFeedback("user-1").length, 1); assert.equal(feedback.listMyFeedback("user-1").length, 1);
assert.equal(feedback.supportFeedback(entry.id, { id: "user-2" }), 1); assert.equal(feedback.supportFeedback(entry.id, { id: "user-2" }), 1);
@ -124,6 +128,31 @@ try {
scope_type: "element", scope_type: "element",
current_url: "http://localhost/admin/settings" current_url: "http://localhost/admin/settings"
}, { userId: "user-2" }).some((match) => match.id === entry.id)); }, { userId: "user-2" }).some((match) => match.id === entry.id));
const duplicate = feedback.createFeedback({
summary: "Settings save still fails",
category: "bug",
severity: "broken",
scope_type: "element",
scope_label: "Save settings button",
description: "The same settings save control reloads without applying my change.",
expected_behavior: "The setting should persist.",
current_url: "http://localhost/admin/settings"
}, { id: "user-2" });
feedback.supportFeedback(duplicate.id, { id: "user-1" });
const merged = feedback.mergeFeedback(duplicate.id, entry.id, {
reason: "Both reports describe the same settings save failure."
}, { id: "admin-1" });
assert.equal(merged.source.status, "duplicate");
assert.equal(merged.source.merged_into.id, entry.id);
assert.equal(merged.target.merged_sources[0].id, duplicate.id);
assert.equal(merged.target.reporter_count, 2);
assert.equal(merged.target.support_count, 2);
assert.equal(feedback.getFeedbackForViewer(duplicate.id, "user-2").merged_into.summary, entry.summary);
assert.equal(feedback.getFeedbackForViewer(entry.id, "user-1").merged_sources[0].submitter_id, undefined);
feedback.unmergeFeedback(duplicate.id, { reason: "Verification undo." }, { id: "admin-1" });
assert.equal(feedback.getFeedbackForAdmin(duplicate.id).status, "reviewed");
feedback.mergeFeedback(duplicate.id, entry.id, {}, { id: "admin-1" });
assert(feedback.findSimilarFeedback({ assert(feedback.findSimilarFeedback({
summary: "Controls are weird", summary: "Controls are weird",
description: "Clicking the save button does not apply changes after editing settings.", description: "Clicking the save button does not apply changes after editing settings.",
@ -246,6 +275,7 @@ try {
userInitial: "A", userInitial: "A",
title: "Feedback review", title: "Feedback review",
feedbackItems: feedback.listFeedbackForAdmin({}), feedbackItems: feedback.listFeedbackForAdmin({}),
feedbackExportAiAvailable: false,
filters: { status: "", category: "", severity: "", scope: "", area: "", submitter: "", date_from: "", date_to: "", needs_action: "", sort: "last_activity" } filters: { status: "", category: "", severity: "", scope: "", area: "", submitter: "", date_from: "", date_to: "", needs_action: "", sort: "last_activity" }
}, { filename: adminView }); }, { filename: adminView });
assert(adminRendered.includes("Feedback queue")); assert(adminRendered.includes("Feedback queue"));

221
scripts/verify-overlays.js Normal file
View File

@ -0,0 +1,221 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.join(__dirname, "..");
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-overlays-"));
const serviceDir = path.join(sandbox, "src", "services");
fs.mkdirSync(serviceDir, { recursive: true });
for (const file of [
"config.js",
"db.js",
"settings.js",
"web-events.js",
"overlay-secrets.js",
"overlay-modules.js",
"overlays.js",
"overlay-permissions.js",
"overlay-connectors.js"
]) {
fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file));
}
let database = null;
try {
database = require(path.join(serviceDir, "db.js"));
database.migrate();
const settings = require(path.join(serviceDir, "settings.js"));
settings.ensureDefaults();
settings.setSetting("session_secret", "overlay-verification-session-secret");
const overlays = require(path.join(serviceDir, "overlays.js"));
const overlayModules = require(path.join(serviceDir, "overlay-modules.js"));
const permissions = require(path.join(serviceDir, "overlay-permissions.js"));
const connectors = require(path.join(serviceDir, "overlay-connectors.js"));
const providerList = connectors.listOverlayConnectorProviders();
assert(providerList.some((provider) => provider.id === "local_obs_websocket" && provider.available !== false));
assert(providerList.some((provider) => provider.id === "obs_browser_bridge" && provider.available !== false));
assert(providerList.some((provider) => provider.id === "trusted_lumi_client" && provider.available === false));
const bridge = new connectors.ObsBrowserBridgeConnector({ overlay_id: "bridge-test", sync_direction: "obs_to_overlay" });
const bridgeReport = bridge.acceptReport({
instance_id: "obs-browser-test",
plugin_version: "2.24.0",
control_level: 2,
current_scene: "Starting Soon",
scenes: ["Starting Soon", "Live"],
transitions: ["Cut", "Fade"],
current_transition: "Fade",
canvas_width: 1920,
canvas_height: 1080,
outputs: { streaming: true },
source_active: true,
source_visible: true
});
assert.strictEqual(bridgeReport.accepted, true);
assert.strictEqual(bridgeReport.current_scene, "Starting Soon");
assert.deepStrictEqual(bridgeReport.scenes, ["Starting Soon", "Live"]);
assert.strictEqual(bridgeReport.control_level_label, "Read scenes and transitions");
assert.strictEqual(bridgeReport.profiles_supported, false);
bridge.disconnect();
const sourceTypes = overlayModules.listOverlayModuleTypes();
assert(sourceTypes.some((type) => type.id === "video"));
assert(sourceTypes.some((type) => type.id === "audio"));
const created = overlays.createOverlay({ name: "Main Stream", sceneName: "Live" });
assert.strictEqual(created.scenes.length, 1, "new overlays must receive a first scene");
assert.strictEqual(created.canvas_width, 1920);
assert.strictEqual(created.canvas_height, 1080);
assert(created.public_token.length >= 40, "overlay token must be high entropy");
assert(created.scenes[0].public_token.length >= 40, "scene token must be high entropy");
const resolved = overlays.resolvePublicOverlay(created.public_token);
assert.strictEqual(resolved.overlay.id, created.id);
const fixed = overlays.resolvePublicOverlay(created.public_token, created.scenes[0].public_token);
assert.strictEqual(fixed.fixedScene.id, created.scenes[0].id);
const headlineId = overlays.addModule(created.scenes[0].id, {
name: "Headline",
type: "text",
enabled: true,
config: { text: "Starting soon", x: 5, y: 10, width: 90, height: 20 }
});
const state = overlays.buildPublicState(created.id);
assert.strictEqual(state.scene.modules.length, 1);
assert.strictEqual(state.scene.modules[0].config.text, "Starting soon");
overlays.updateModuleTransform(headlineId, { x: 50, y: 50, width: 40, height: 15, anchor: "center" });
const transformed = overlays.getOverlay(created.id).scenes[0].modules[0];
assert.strictEqual(transformed.config.anchor, "center");
assert.strictEqual(transformed.config.text, "Starting soon", "canvas transforms must preserve source content");
const textConfig = overlayModules.normalizeModuleConfig("text", {
horizontalAlign: "right",
verticalAlign: "bottom",
overflow: "shrink",
anchor: "bottom-right"
});
assert.strictEqual(textConfig.horizontalAlign, "right");
assert.strictEqual(textConfig.verticalAlign, "bottom");
assert.strictEqual(textConfig.overflow, "shrink");
assert.strictEqual(textConfig.anchor, "bottom-right");
for (const supportedColor of ["#fff", "#ffff", "#ffffff", "#ffffffff"]) {
assert.strictEqual(overlayModules.normalizeModuleConfig("text", { color: supportedColor }).color, supportedColor);
}
assert.strictEqual(overlayModules.normalizeModuleConfig("text", { color: "#fffff" }).color, "#ffffff");
const webConfig = overlayModules.normalizeModuleConfig("web", {
url: "https://example.com/alerts",
autoRefresh: true,
healthCheck: true,
refreshIntervalSeconds: 10,
retryLimit: 9
});
assert.strictEqual(webConfig.autoRefresh, true);
assert.strictEqual(webConfig.healthCheck, true);
assert.strictEqual(webConfig.refreshIntervalSeconds, 30);
assert.strictEqual(webConfig.retryLimit, 5);
const videoConfig = overlayModules.normalizeModuleConfig("video", {
url: "https://example.com/alert.webm",
playBehavior: "loop",
muted: false,
volume: 0.65,
playbackRate: 1.25,
startAt: 2.5,
fit: "cover"
});
assert.strictEqual(videoConfig.playBehavior, "loop");
assert.strictEqual(videoConfig.volume, 0.65);
assert.strictEqual(videoConfig.playbackRate, 1.25);
assert.strictEqual(videoConfig.startAt, 2.5);
assert.strictEqual(videoConfig.fit, "cover");
const audioConfig = overlayModules.normalizeModuleConfig("audio", {
url: "https://example.com/alert.ogg",
playBehavior: "manual",
volume: 5
});
assert.strictEqual(audioConfig.playBehavior, "manual");
assert.strictEqual(audioConfig.showControls, true);
assert.strictEqual(audioConfig.volume, 1);
const styledWebConfig = overlayModules.normalizeModuleConfig("web", {
url: "https://example.com/widget",
cropLeft: 12.5,
zoom: 1.4,
customCss: "iframe { filter: grayscale(1); }"
});
assert.strictEqual(styledWebConfig.cropLeft, 12.5);
assert.strictEqual(styledWebConfig.zoom, 1.4);
assert.strictEqual(styledWebConfig.customCss, "iframe { filter: grayscale(1); }");
overlays.updateOverlay(created.id, { canvasWidth: 1280, canvasHeight: 720 });
assert.deepStrictEqual(overlays.buildPublicState(created.id).canvas, { width: 1280, height: 720 });
const second = overlays.addScene(created.id, { name: "Break", obsSceneName: "BRB" });
overlays.setActiveScene(created.id, second.id, { source: "verify" });
assert.strictEqual(overlays.buildPublicState(created.id).scene.id, second.id);
assert.strictEqual(overlays.buildPublicState(created.id, created.scenes[0].id).scene.name, "Live");
const oldOverlayToken = created.public_token;
const newOverlayToken = overlays.regenerateOverlayToken(created.id);
assert.strictEqual(overlays.resolvePublicOverlay(oldOverlayToken), null, "regeneration must revoke old overlay tokens");
assert(overlays.resolvePublicOverlay(newOverlayToken));
const refreshed = overlays.getOverlay(created.id, { includeSecrets: true });
const firstScene = refreshed.scenes.find((scene) => scene.name === "Live");
const oldSceneToken = firstScene.public_token;
const newSceneToken = overlays.regenerateSceneToken(firstScene.id);
assert.strictEqual(overlays.resolvePublicOverlay(newOverlayToken, oldSceneToken), null, "regeneration must revoke old fixed scene tokens");
assert(overlays.resolvePublicOverlay(newOverlayToken, newSceneToken));
overlays.deleteScene(second.id);
assert.throws(() => overlays.deleteScene(firstScene.id), /at least one scene/);
const duplicate = overlays.duplicateOverlay(created.id);
assert.notStrictEqual(duplicate.public_token, newOverlayToken);
assert.strictEqual(duplicate.scenes.length, 1);
assert.strictEqual(duplicate.scenes[0].modules.length, 1);
overlays.saveObsSettings(created.id, {
enabled: false,
provider: "local_obs_websocket",
endpoint: "ws://127.0.0.1:4455",
password: "verification-secret",
syncDirection: "bidirectional",
reconnect: true
});
const storedObs = database.db.prepare("SELECT password_encrypted FROM overlay_obs_settings WHERE overlay_id = ?").get(created.id);
assert(storedObs.password_encrypted && !storedObs.password_encrypted.includes("verification-secret"));
assert.strictEqual(overlays.getObsSettings(created.id, { includeSecret: true }).password, "verification-secret");
assert.strictEqual(permissions.canAccessOverlay({ isAdmin: true }, created.id, "view_secrets"), true);
assert.strictEqual(permissions.canAccessOverlay({ isMod: true }, created.id, "view_secrets"), false);
const unregister = permissions.registerOverlayAccessProvider({
allows: ({ overlayId, capability }) => overlayId === created.id && capability === "manage"
});
assert.strictEqual(permissions.canAccessOverlay({ isMod: true }, created.id, "manage"), true);
assert.strictEqual(permissions.canAccessOverlay({ isMod: true }, created.id, "view_secrets"), false);
unregister();
const editorView = fs.readFileSync(path.join(root, "src", "web", "views", "admin-overlay-detail.ejs"), "utf8");
const editorScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-management.js"), "utf8");
const runtimeScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-runtime.js"), "utf8");
const rendererScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-renderer.js"), "utf8");
const connectorScript = fs.readFileSync(path.join(root, "src", "services", "overlay-connectors.js"), "utf8");
const routeScript = fs.readFileSync(path.join(root, "src", "services", "overlay-routes.js"), "utf8");
assert(editorView.includes("data-overlay-canvas"), "management page must include a visual canvas");
assert(editorView.includes('data-module-fields="text"') && editorView.includes('data-module-fields="web"'));
assert(editorView.includes('data-module-fields="video"') && editorView.includes('data-module-fields="audio"'));
assert(editorView.includes('name="custom_css"') && editorView.includes('name="crop_left"'));
assert(editorView.includes("data-color-picker") && editorView.includes("overlay-transform-disclosure"));
assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow");
assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets"));
assert(editorScript.includes("--overlay-settings-max-height"), "settings height must follow its viewport position");
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
assert(runtimeScript.includes("window.obsstudio") && runtimeScript.includes("overlay:obs-browser-command"));
assert(rendererScript.includes("buildMedia") && rendererScript.includes("buildWebsite"));
assert(rendererScript.includes('frame.setAttribute("allow", "autoplay; fullscreen")'));
assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report"));
assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import"));
console.log("Overlay verification passed: CRUD, visual editor hooks, transforms, layout controls, external recovery, token revocation, encrypted OBS credentials, and permissions.");
} finally {
database?.db.close();
fs.rmSync(sandbox, { recursive: true, force: true });
}

View File

@ -31,6 +31,26 @@ let catalog = placeholders.catalog({
}); });
assert(catalog.placeholders.some((entry) => entry.token === "{{core.main.bot_name}}")); assert(catalog.placeholders.some((entry) => entry.token === "{{core.main.bot_name}}"));
assert(catalog.placeholders.some((entry) => entry.token === "{{user.public.display_name}}")); assert(catalog.placeholders.some((entry) => entry.token === "{{user.public.display_name}}"));
assert(catalog.placeholders.some((entry) => entry.token === "{{core.command.rng}}"));
const customValidation = placeholders.validateCustomPlaceholders([
{ name: "stream.title", value: "Friday stream", visibility: "user", description: "Current stream title" },
{ name: "staff.note", value: "Internal note", visibility: "mod" },
{ name: "admin.internal_note", value: "Admin only", visibility: "admin" }
]);
assert.equal(customValidation.ok, true);
assert.equal(placeholders.validateCustomPlaceholders([{ name: "Bad Name", value: "no" }]).ok, false);
assert.equal(placeholders.validateCustomPlaceholders([{ name: "same", value: "one" }, { name: "same", value: "two" }]).ok, false);
assert.equal(placeholders.validateCustomPlaceholders([{ name: "service.api_key", value: "do-not-store", visibility: "admin" }]).ok, false);
assert.equal(placeholders.validateCustomPlaceholders([{ name: "missing.visibility", value: "no" }]).ok, false);
placeholders.registerCustomPlaceholders(customValidation.definitions);
catalog = placeholders.catalog({
fieldId: "core.custom_commands.static_response",
user: mod,
outputAudience: "user"
});
assert(catalog.placeholders.some((entry) => entry.token === "{{custom.stream.title}}"));
assert.equal(catalog.placeholders.some((entry) => entry.token === "{{custom.staff.note}}"), false);
placeholders.registerPlaceholder({ placeholders.registerPlaceholder({
id: "core.main.admin_note", id: "core.main.admin_note",
@ -73,6 +93,33 @@ assert.equal(invalid.errors[0].reason, "viewer_role_forbidden");
assert.equal(rendered.ok, true); assert.equal(rendered.ok, true);
assert.match(rendered.rendered, /^Hi SammyCat, prefix is .+\.$/); assert.match(rendered.rendered, /^Hi SammyCat, prefix is .+\.$/);
const randomRendered = await placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",
template: "Rolled {{core.command.rng}}",
user: mod,
outputAudience: "user",
runtimeContext: { runtime: true, command: { rng: 42 } }
});
assert.equal(randomRendered.rendered, "Rolled 42");
const customRendered = await placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",
template: "Now: {{custom.stream.title}}",
user: mod,
outputAudience: "user",
runtimeContext: { runtime: true }
});
assert.equal(customRendered.rendered, "Now: Friday stream");
const hiddenCustom = await placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",
template: "{{custom.staff.note}}",
user: admin,
outputAudience: "user",
runtimeContext: { runtime: true },
fallback: "[hidden]"
});
assert.equal(hiddenCustom.rendered, "[hidden]");
const previousGuildId = getSetting("discord_guild_id", ""); const previousGuildId = getSetting("discord_guild_id", "");
try { try {
setSetting("discord_guild_id", "guild-1"); setSetting("discord_guild_id", "guild-1");
@ -102,15 +149,25 @@ assert.equal(invalid.errors[0].reason, "viewer_role_forbidden");
outputAudience: "user" outputAudience: "user"
}); });
assert(platformCatalog.placeholders.some((entry) => entry.token === "{{platform.discord.guild.member_count}}")); assert(platformCatalog.placeholders.some((entry) => entry.token === "{{platform.discord.guild.member_count}}"));
assert(platformCatalog.placeholders.some((entry) => entry.token === "{{platform.discord.guild.data_status}}"));
const platformRendered = await placeholders.renderTemplate({ const platformRendered = await placeholders.renderTemplate({
fieldId: "okf.markdown", fieldId: "okf.markdown",
template: "Discord has {{platform.discord.guild.member_count}} members.", template: "Discord has {{platform.discord.guild.member_count}} members ({{platform.discord.guild.data_status}}).",
user: admin, user: admin,
outputAudience: "user", outputAudience: "user",
runtimeContext: { runtime: true } runtimeContext: { runtime: true }
}); });
assert.equal(platformRendered.rendered, "Discord has 42 members."); assert.equal(platformRendered.rendered, "Discord has 42 members (live_cache).");
const missingPlatformRendered = await placeholders.renderTemplate({
fieldId: "okf.markdown",
template: "Twitch: {{platform.twitch.channel.data_status}}; YouTube: {{platform.youtube.channel.data_status}}.",
user: admin,
outputAudience: "user",
runtimeContext: { runtime: true }
});
assert.match(missingPlatformRendered.rendered, /^Twitch: (unavailable|configured_not_connected); YouTube: (unavailable|configured_not_connected)\.$/);
} finally { } finally {
setSetting("discord_guild_id", previousGuildId); setSetting("discord_guild_id", previousGuildId);
} }
@ -133,6 +190,58 @@ assert.equal(invalid.errors[0].reason, "viewer_role_forbidden");
}); });
assert.equal(userVisibleAdminPlaceholder.ok, false); assert.equal(userVisibleAdminPlaceholder.ok, false);
assert.equal(userVisibleAdminPlaceholder.errors[0].reason, "viewer_role_forbidden"); assert.equal(userVisibleAdminPlaceholder.errors[0].reason, "viewer_role_forbidden");
placeholders.registerFieldPolicy({
field_id: "test.async",
label: "Async renderer test",
field_type: "test_text",
output_audience: "user",
min_editor_role: "user",
allowed_namespaces: ["test"],
max_sensitivity: "public_safe"
});
let duplicateCalls = 0;
placeholders.registerPlaceholder({
id: "test.duplicate",
namespace: "test",
label: "Duplicate token",
allowed_field_types: ["test_text"],
resolver: async () => (++duplicateCalls === 1 ? "{{test.duplicate}}" : "second")
});
placeholders.registerPlaceholder({
id: "test.failed",
namespace: "test",
label: "Failed token",
allowed_field_types: ["test_text"],
resolver: async () => { throw new Error("simulated failure"); }
});
placeholders.registerPlaceholder({
id: "test.slow",
namespace: "test",
label: "Slow token",
allowed_field_types: ["test_text"],
resolver: () => new Promise(() => {})
});
placeholders.registerPlaceholder({
id: "test.unavailable",
namespace: "test",
label: "Unavailable token",
allowed_field_types: ["test_text"],
available: () => false,
resolver: () => "must-not-render"
});
const deterministic = await placeholders.renderTemplate({
fieldId: "test.async",
template: "{{test.duplicate}}{{test.duplicate}}|{{test.failed}}|{{test.unavailable}}|{{test.slow}}",
user,
outputAudience: "user",
runtimeContext: { runtime: true, placeholder_timeout_ms: 50 },
fallback: "[safe]"
});
assert.equal(deterministic.rendered, "{{test.duplicate}}second|[safe]|[safe]|[safe]");
assert(deterministic.errors.some((entry) => entry.reason === "resolver_failed"));
assert(deterministic.errors.some((entry) => entry.reason === "placeholder_unavailable"));
assert(deterministic.errors.some((entry) => entry.reason === "resolver_timeout"));
console.log("Placeholder verification passed."); console.log("Placeholder verification passed.");
})().catch((error) => { })().catch((error) => {
console.error(error); console.error(error);

View File

@ -2,7 +2,10 @@ const assert = require("assert");
const fs = require("fs"); const fs = require("fs");
const os = require("os"); const os = require("os");
const path = require("path"); const path = require("path");
const { replacePluginDirectory } = require("../src/services/update-manager"); const {
replacePluginDirectory,
resetDirectoryForFullUpdate
} = require("../src/services/update-manager");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-update-test-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-update-test-"));
try { try {
@ -26,7 +29,83 @@ try {
assert.equal(fs.existsSync(path.join(target, "stale.js")), false); assert.equal(fs.existsSync(path.join(target, "stale.js")), false);
assert.equal(fs.statSync(model).size, 3 * 1024 * 1024 * 1024); assert.equal(fs.statSync(model).size, 3 * 1024 * 1024 * 1024);
assert.equal(fs.existsSync(path.join(target, "data", "config", "default.json")), false); assert.equal(fs.existsSync(path.join(target, "data", "config", "default.json")), false);
console.log("Plugin update preservation verification passed.");
const failedSource = path.join(root, "failed-source");
fs.mkdirSync(failedSource, { recursive: true });
fs.writeFileSync(path.join(failedSource, "plugin.json"), '{"id":"test"}');
fs.writeFileSync(path.join(failedSource, "index.js"), "module.exports = 'must-not-land';");
const originalRename = fs.renameSync;
fs.renameSync = (from, to) => {
if (String(from).includes(".target.update-") && to === target) {
throw Object.assign(new Error("simulated install lock"), { code: "EPERM" });
}
return originalRename(from, to);
};
try {
assert.throws(
() => replacePluginDirectory(failedSource, target, { preserveData: true }),
/simulated install lock/
);
} finally {
fs.renameSync = originalRename;
}
assert.equal(fs.readFileSync(path.join(target, "index.js"), "utf8"), "module.exports = 'new';");
assert.equal(fs.statSync(model).size, 3 * 1024 * 1024 * 1024);
assert.equal(fs.readdirSync(root).some((name) => name.includes(".target.update-") || name.includes(".target.backup-")), false);
const install = path.join(root, "install");
for (const relative of [
"data/feedback/report.json",
"data/lumi_ai/models/model.gguf",
"plugins/example/data/settings.json",
"knowledge/community/local.md",
"knowledge/corrections/fix.md",
"knowledge/core/generated.md",
"knowledge/core/local.md",
"knowledge/plugins/generated.md",
"config/local.json",
"src/stale.js"
]) {
const file = path.join(install, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, relative);
}
fs.writeFileSync(
path.join(install, "knowledge", "core", "generated.md"),
"---\ngenerated: true\neditable: false\n---\ngenerated"
);
fs.writeFileSync(
path.join(install, "knowledge", "plugins", "generated.md"),
"---\ngenerated: true\neditable: false\n---\ngenerated"
);
fs.writeFileSync(
path.join(install, "knowledge", "core", "local.md"),
"---\ngenerated: false\neditable: true\n---\nlocal"
);
resetDirectoryForFullUpdate(install);
for (const relative of [
"data/feedback/report.json",
"data/lumi_ai/models/model.gguf",
"plugins/example/data/settings.json",
"knowledge/community/local.md",
"knowledge/corrections/fix.md",
"knowledge/core/local.md",
"config/local.json"
]) {
assert.equal(fs.existsSync(path.join(install, relative)), true, `${relative} should be preserved`);
}
assert.equal(fs.existsSync(path.join(install, "knowledge/core/generated.md")), false);
assert.equal(fs.existsSync(path.join(install, "knowledge/plugins/generated.md")), false);
assert.equal(fs.existsSync(path.join(install, "src/stale.js")), false);
const managerSource = fs.readFileSync(path.join(__dirname, "..", "src", "services", "update-manager.js"), "utf8");
const updateDocs = fs.readFileSync(path.join(__dirname, "..", "docs", "updates.md"), "utf8");
assert(managerSource.includes("Automatic rollback also failed"));
assert.equal((managerSource.match(/restoreSnapshot\(record\.id/g) || []).length >= 2, true);
assert(updateDocs.includes("Preserved Local Data"));
assert(updateDocs.includes("knowledge/community/"));
assert(updateDocs.includes("AI models/runtimes"));
console.log("Update preservation verification passed: core paths, plugin data, transactional replacement, and failed-update rollback wiring.");
} finally { } finally {
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(root, { recursive: true, force: true });
} }

View File

@ -0,0 +1,36 @@
const path = require("path");
const root = path.join(__dirname, "..");
const minimumNodeMajor = 18;
function fail(message, details = []) {
console.error(`Verification preflight failed: ${message}`);
for (const detail of details) console.error(`- ${detail}`);
process.exitCode = 1;
}
const nodeMajor = Number.parseInt(process.versions.node.split(".")[0], 10);
if (!Number.isFinite(nodeMajor) || nodeMajor < minimumNodeMajor) {
fail(`Lumi requires Node.js ${minimumNodeMajor} or newer; this process is ${process.version}.`, [
"Install a supported Node.js release, reopen the terminal, then run npm install."
]);
} else {
try {
const BetterSqlite3 = require("better-sqlite3");
const database = new BetterSqlite3(":memory:");
database.prepare("SELECT 1 AS ready").get();
database.close();
} catch (error) {
fail("The better-sqlite3 native module is missing or incompatible with this Node.js installation.", [
`Node: ${process.version} (${process.platform} ${process.arch})`,
`Module error: ${error.message}`,
`From ${root}, run npm install. Do not use --ignore-scripts because native modules must be built or downloaded.`,
"If Node.js was upgraded after installation, run npm rebuild better-sqlite3 or remove node_modules and run npm install again.",
"On Linux, ensure a compiler toolchain and Python are available if no prebuilt binary exists."
]);
}
}
if (!process.exitCode) {
console.log(`Verification preflight passed: Node ${process.version}, better-sqlite3 loaded (${process.platform} ${process.arch}).`);
}

View File

@ -0,0 +1,87 @@
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { Worker } = require("worker_threads");
const {
writeJsonAtomicSync,
clearSafeFileDiagnostics,
getSafeFileDiagnostics
} = require("../src/services/safe-files");
async function run() {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-safe-files-"));
const target = path.join(directory, "runtime_state.json");
const originalRename = fs.renameSync;
try {
fs.writeFileSync(target, '{"state":"original"}\n');
const stale = `${target}.tmp`;
fs.writeFileSync(stale, "stale");
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
fs.utimesSync(stale, old, old);
clearSafeFileDiagnostics();
let transientFailures = 0;
fs.renameSync = (from, to) => {
if (to === target && String(from).endsWith(".tmp") && transientFailures < 2) {
transientFailures += 1;
throw Object.assign(new Error("temporarily locked"), { code: transientFailures === 1 ? "EPERM" : "EACCES" });
}
return originalRename(from, to);
};
writeJsonAtomicSync(target, { state: "recovered" }, { attempts: 4, delayMs: 1, staleTempAgeMs: 1000 });
assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), { state: "recovered" });
assert.equal(fs.existsSync(stale), false);
assert(getSafeFileDiagnostics().filter((entry) => entry.operation === "rename").length >= 2);
fs.writeFileSync(target, '{"state":"must-survive"}\n');
fs.renameSync = (from, to) => {
if (to === target && String(from).endsWith(".tmp")) {
throw Object.assign(new Error("replacement locked"), { code: "EPERM" });
}
return originalRename(from, to);
};
assert.throws(
() => writeJsonAtomicSync(target, { state: "must-not-land" }, { attempts: 2, delayMs: 1 }),
/replacement locked/
);
assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), { state: "must-survive" });
fs.renameSync = originalRename;
await Promise.all(Array.from({ length: 4 }, (_, worker) => runWorker(target, worker)));
const final = JSON.parse(fs.readFileSync(target, "utf8"));
assert(Number.isInteger(final.worker));
assert(Number.isInteger(final.write));
assert.equal(fs.readdirSync(directory).some((name) => name.endsWith(".tmp")), false);
} finally {
fs.renameSync = originalRename;
fs.rmSync(directory, { recursive: true, force: true });
}
console.log("Safe atomic file verification passed: transient lock retry, rollback, stale cleanup, and concurrent writes.");
}
function runWorker(target, worker) {
const helper = require.resolve("../src/services/safe-files");
const source = `
const { parentPort, workerData } = require("worker_threads");
const { writeJsonAtomicSync } = require(workerData.helper);
try {
for (let write = 0; write < 12; write += 1) {
writeJsonAtomicSync(workerData.target, { worker: workerData.worker, write });
}
parentPort.postMessage({ ok: true });
} catch (error) {
parentPort.postMessage({ ok: false, error: error.stack || error.message });
}
`;
return new Promise((resolve, reject) => {
const instance = new Worker(source, { eval: true, workerData: { helper, target, worker } });
instance.once("message", (message) => message.ok ? resolve() : reject(new Error(message.error)));
instance.once("error", reject);
});
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -0,0 +1,64 @@
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const {
cleanupUploadedFiles,
safeDownloadFilename,
validateUploadedFile,
validateUploadedFiles
} = require("../src/services/upload-security");
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-upload-security-"));
function upload(name, mime, bytes) {
const filePath = path.join(sandbox, `${Date.now()}-${Math.random()}`);
fs.writeFileSync(filePath, bytes);
return { path: filePath, originalname: name, mimetype: mime, size: bytes.length };
}
try {
const png = upload("icon.png", "image/png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]));
validateUploadedFile(png, { allowedTypes: ["png"], maxBytes: 1024, requireExtension: true });
assert.equal(png.detectedType, "png");
const disguised = upload("not-really.png", "image/png", Buffer.from("not an image"));
assert.throws(() => validateUploadedFiles([disguised], { allowedTypes: ["png"], maxBytes: 1024 }), /contents/);
assert.equal(fs.existsSync(disguised.path), false);
const mismatch = upload("photo.jpg", "image/jpeg", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
assert.throws(() => validateUploadedFiles([mismatch], { allowedTypes: ["png", "jpeg"] }), /browser|extension/);
assert.equal(fs.existsSync(mismatch.path), false);
const unsafeSvg = upload("icon.svg", "image/svg+xml", Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'));
assert.throws(() => validateUploadedFiles([unsafeSvg], { allowedTypes: ["svg"] }), /contents/);
assert.equal(fs.existsSync(unsafeSvg.path), false);
const safeSvg = upload("icon.svg", "image/svg+xml", Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0h1v1z"/></svg>'));
validateUploadedFile(safeSvg, { allowedTypes: ["svg"], requireExtension: true });
const oversized = upload("large.txt", "text/plain", Buffer.from("12345"));
assert.throws(() => validateUploadedFiles([oversized], { allowedTypes: ["text"], maxBytes: 4 }), /larger/);
assert.equal(fs.existsSync(oversized.path), false);
assert.equal(safeDownloadFilename('../../bad\r\n"name.txt'), "badname.txt");
assert.equal(safeDownloadFilename("..\\..\\secret.txt"), "secret.txt");
const serverSource = fs.readFileSync(path.join(__dirname, "..", "src", "web", "server.js"), "utf8");
const moderationSource = fs.readFileSync(path.join(__dirname, "..", "plugins", "moderation", "index.js"), "utf8");
const economySource = fs.readFileSync(path.join(__dirname, "..", "plugins", "economy-framework", "index.js"), "utf8");
assert(serverSource.includes('allowedTypes: ["zip"]'));
assert(serverSource.includes('allowedTypes: ["png", "svg"]'));
assert(serverSource.includes('safeDownloadFilename(attachment.original_name'));
assert(serverSource.includes('"X-Content-Type-Options", "nosniff"'));
assert(moderationSource.includes("validateUploadedFiles(req.files"));
assert(moderationSource.indexOf('router.post("/actions", (req, res, next)') < moderationSource.indexOf("evidenceUpload, async"));
assert(economySource.includes("currencyIconUpload"));
assert(economySource.indexOf('router.post("/settings/icon", (req, res, next)') < economySource.indexOf("currencyIconUpload, (req, res)"));
cleanupUploadedFiles([png, safeSvg]);
console.log("Upload security verification passed.");
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}

View File

@ -0,0 +1,47 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const { createWebAuth } = require("../src/services/web-auth");
function request({ hostname = "example.test", user = null, path = "/plugins/okf" } = {}) {
return { hostname, path, session: { user }, get: () => hostname };
}
function response() {
return {
redirectedTo: null,
redirect(target) { this.redirectedTo = target; return target; }
};
}
const noPlatforms = createWebAuth({ getPlatformStatus: () => [] });
let res = response();
noPlatforms.requireAuth(request(), res, () => assert.fail("Anonymous request should not continue"));
assert.equal(res.redirectedTo, "/setup");
res = response();
noPlatforms.requireAuth(request({ hostname: "127.0.0.1" }), res, () => assert.fail("Anonymous request should not continue"));
assert.equal(res.redirectedTo, "/auth/localhost");
const discordAuth = createWebAuth({ getPlatformStatus: () => [{
supported: true, enabled: true, configured: true, supportsLogin: true, loginPath: "/auth/discord"
}] });
res = response();
discordAuth.requireAuth(request(), res, () => assert.fail("Anonymous request should not continue"));
assert.equal(res.redirectedTo, "/auth/discord");
let continued = false;
discordAuth.requireAuth(request({ user: { id: "user-1" } }), response(), () => { continued = true; });
assert.equal(continued, true);
res = response();
noPlatforms.requireConfigured(request(), res, () => assert.fail("Unconfigured remote plugin request should not continue"));
assert.equal(res.redirectedTo, "/setup");
const root = path.join(__dirname, "..");
const okf = fs.readFileSync(path.join(root, "plugins", "okf", "index.js"), "utf8");
const server = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
assert(okf.includes("const requireLogin = web.auth.requireAuth"));
assert.equal(okf.includes('res.redirect("/login")'), false);
assert(server.includes("app.use(mountPath, webAuth.requireConfigured, router)"));
console.log("Shared WebUI authentication verification passed: setup, localhost, configured provider, and OKF wiring.");

View File

@ -188,7 +188,55 @@ function verifyUpdateCachePreserved() {
); );
} }
function verifySharedUpdateActions() {
const appSource = fs.readFileSync(path.join(root, "src", "web", "public", "app.js"), "utf8");
const serverSource = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
const dashboard = fs.readFileSync(path.join(root, "src", "web", "views", "admin-dashboard.ejs"), "utf8");
const settings = fs.readFileSync(path.join(root, "src", "web", "views", "admin-settings.ejs"), "utf8");
assert(appSource.includes('button[data-update-action]'));
assert(appSource.includes("submitter?.formAction || form.action"));
assert(appSource.includes('actionPath.endsWith("/check")'));
assert(dashboard.includes('action="/admin/check-update" class="inline-form" data-update-action'));
assert(settings.includes('formaction=\\"/admin/check-update\\" formmethod=\\"post\\" data-update-action'));
assert(serverSource.includes('sendUpdateResult(req, res, {\n status,'));
assert(serverSource.includes('message: "Core update applied. Lumi will restart in a few seconds."'));
}
function verifyHomepageEmbeds() {
const server = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
const builder = fs.readFileSync(path.join(root, "src", "web", "public", "homepage-builder.js"), "utf8");
const home = fs.readFileSync(path.join(root, "src", "web", "views", "home.ejs"), "utf8");
const youtube = fs.readFileSync(path.join(root, "src", "services", "youtube.js"), "utf8");
assert(server.includes("function twitchParentDomains"));
assert(server.includes("function twitchChatEmbedUrl"));
assert(server.includes("function youtubeChatEmbedUrl"));
assert(server.includes('item.availability_mode === "live_only" && item.live_now !== true'));
assert(builder.includes('twitch_layout: "stream_only"'));
assert(builder.includes('youtube_show_chat: false'));
assert(builder.includes('additional_parent_domains: ""'));
assert(home.includes("homepageHero.secondary_embed_url"));
assert(home.includes("homepage-hero-embed-grid"));
assert(youtube.includes("liveStateUpdatedAt"));
assert(youtube.includes("activeVideoId"));
}
function verifyCustomPlaceholderSettings() {
const view = fs.readFileSync(path.join(root, "src", "web", "views", "admin-settings.ejs"), "utf8");
const client = fs.readFileSync(path.join(root, "src", "web", "public", "custom-placeholders.js"), "utf8");
const server = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
assert(view.includes("Reusable text values"));
assert(view.includes("custom_placeholder_visibility"));
assert(view.includes("/custom-placeholders.js"));
assert(client.includes("data-custom-placeholder-row"));
assert(server.includes("validateCustomPlaceholders(customPlaceholdersFromBody(req.body))"));
assert(server.includes('setSetting("custom_placeholders"'));
}
const viewCount = verifyViews(); const viewCount = verifyViews();
verifyThemeService(); verifyThemeService();
verifyUpdateCachePreserved(); verifyUpdateCachePreserved();
verifySharedUpdateActions();
verifyHomepageEmbeds();
verifyCustomPlaceholderSettings();
console.log(`WebUI verification passed: ${viewCount} EJS views, theme CRUD, and update-cache preservation.`); console.log(`WebUI verification passed: ${viewCount} EJS views, theme CRUD, and update-cache preservation.`);

View File

@ -16,6 +16,7 @@ const { registerTopCommand } = require("./services/top");
const logger = require("./services/logger"); const logger = require("./services/logger");
const { isPlatformEnabled } = require("./services/platforms"); const { isPlatformEnabled } = require("./services/platforms");
const { registerCorePlaceholders } = require("./services/placeholders"); const { registerCorePlaceholders } = require("./services/placeholders");
const { overlayConnectorManager } = require("./services/overlay-connectors");
const { const {
isSafeModeRequested, isSafeModeRequested,
markStartupVerification markStartupVerification
@ -80,6 +81,12 @@ async function main() {
} }
}); });
if (!safeModeRequested) {
overlayConnectorManager.start().catch((error) => {
console.error("OBS overlay connectors failed to start", error);
});
}
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
app.listen(port, () => { app.listen(port, () => {
console.log(`WebUI listening on http://localhost:${port}`); console.log(`WebUI listening on http://localhost:${port}`);
@ -114,6 +121,7 @@ async function main() {
return; return;
} }
shuttingDown = true; shuttingDown = true;
await overlayConnectorManager.stop();
await stopPlugins(); await stopPlugins();
await stopBot(); await stopBot();
await stopTwitchBot(); await stopTwitchBot();

View File

@ -0,0 +1,48 @@
"use strict";
function hasJavaScriptHandler(source) {
const code = String(source || "");
return /\bfunction\s+run\s*\(|\b(?:const|let|var)\s+run\s*=|\bmodule\.exports\s*=|\bexports(?:\.run)?\s*=/.test(code);
}
function prepareJavaScriptCommand(source) {
const code = String(source || "");
if (hasJavaScriptHandler(code)) return code;
return `async function run(ctx) {\n${indent(code)}\n}`;
}
function resolveJavaScriptHandler(context) {
const candidates = [
context?.run,
context?.module?.exports,
context?.module?.exports?.run,
context?.exports,
context?.exports?.run
];
return candidates.find((candidate) => typeof candidate === "function") || null;
}
function hasPythonHandler(source) {
return /^\s*(?:async\s+)?def\s+run\s*\(/m.test(String(source || ""));
}
function preparePythonCommand(source) {
const code = String(source || "");
if (hasPythonHandler(code)) return code;
return `def run(ctx):\n${indent(code || "return None")}`;
}
function indent(value) {
return String(value || "")
.split(/\r?\n/)
.map((line) => ` ${line}`)
.join("\n");
}
module.exports = {
hasJavaScriptHandler,
hasPythonHandler,
prepareJavaScriptCommand,
preparePythonCommand,
resolveJavaScriptHandler
};

View File

@ -2,9 +2,14 @@
const vm = require("vm"); const vm = require("vm");
const { spawn } = require("child_process"); const { spawn } = require("child_process");
const {
prepareJavaScriptCommand,
preparePythonCommand,
resolveJavaScriptHandler
} = require("./command-code");
const FIXED_NOW = Date.parse("2026-01-02T12:34:56.000Z"); const FIXED_NOW = Date.parse("2026-01-02T12:34:56.000Z");
const BLOCKED_JS = /\b(?:require|process|child_process|fs|XMLHttpRequest|WebSocket|import\s*\(|import\s+|Deno|Bun)\b/; const BLOCKED_JS = /\b(?:require|process|child_process|fs|XMLHttpRequest|WebSocket|Deno|Bun)\b|\bimport\s*(?:\(|[\w*{])/;
const BLOCKED_PYTHON = /(?:\b(?:import|open|exec|eval|compile|globals|locals|getattr|setattr|delattr|vars|input|breakpoint|help)\b|__)/; const BLOCKED_PYTHON = /(?:\b(?:import|open|exec|eval|compile|globals|locals|getattr|setattr|delattr|vars|input|breakpoint|help)\b|__)/;
const MAX_CODE_LENGTH = 20000; const MAX_CODE_LENGTH = 20000;
const MAX_PREVIEW_FETCH_BYTES = 128 * 1024; const MAX_PREVIEW_FETCH_BYTES = 128 * 1024;
@ -33,59 +38,248 @@ process.stdin.on("end", async () => {
async function previewJavaScript(code) { async function previewJavaScript(code) {
const source = String(code || ""); const source = String(code || "");
if (source.length > MAX_CODE_LENGTH) throw new Error("Command preview code is too large."); if (source.length > MAX_CODE_LENGTH) throw new Error("Command preview code is too large.");
if (BLOCKED_JS.test(source)) { if (BLOCKED_JS.test(maskJavaScriptLiteralsAndComments(source))) {
throw new Error("Preview blocks filesystem, process, browser socket, and module access."); throw new Error("Preview blocks filesystem, process, browser socket, and module access.");
} }
const replies = [];
const contextValue = createMockContext((value) => {
replies.push(normalizeResult(value));
});
const safeMath = Object.create(Math);
Object.defineProperty(safeMath, "random", { value: () => 0.424242, enumerable: true });
Object.freeze(safeMath);
class PreviewDate extends Date {
constructor(...args) {
super(args.length ? args[0] : FIXED_NOW);
}
static now() {
return FIXED_NOW;
}
}
const sandbox = Object.create(null); const sandbox = Object.create(null);
Object.assign(sandbox, { sandbox.__previewFetchBridge = readOnlyPreviewFetchBridge;
ctx: contextValue, sandbox.__previewParseUrlBridge = parsePreviewUrl;
console: Object.freeze({ log() {}, warn() {}, error() {} }),
Math: safeMath,
Date: PreviewDate,
JSON,
Promise,
URL,
URLSearchParams,
fetch: readOnlyPreviewFetch,
module: { exports: {} },
exports: {}
});
const context = vm.createContext(sandbox, { const context = vm.createContext(sandbox, {
codeGeneration: { strings: false, wasm: false } codeGeneration: { strings: false, wasm: false }
}); });
const script = new vm.Script(`"use strict";\n${source}`, { initializeJavaScriptSandbox(context);
const contextValue = context.ctx;
const script = new vm.Script(`"use strict";\n${prepareJavaScriptCommand(source)}`, {
filename: "command-preview.js" filename: "command-preview.js"
}); });
script.runInContext(context, { timeout: 250 }); script.runInContext(context, { timeout: 250 });
const handler = context.run || context.module.exports || context.exports; const handler = resolveJavaScriptHandler(context);
if (typeof handler !== "function") { if (typeof handler !== "function") {
throw new Error("Define a run(ctx) function."); throw new Error("Dynamic command did not create a runnable handler.");
} }
const returned = await withTimeout(Promise.resolve(handler(contextValue)), PREVIEW_FETCH_TIMEOUT_MS + 500); const returned = await withTimeout(Promise.resolve(handler(contextValue)), PREVIEW_FETCH_TIMEOUT_MS + 500);
const output = [...replies]; const output = Array.from(context.__previewReplies || [], normalizeResult);
const normalizedReturn = normalizeResult(returned); const normalizedReturn = normalizeResult(returned);
if (normalizedReturn && !output.includes(normalizedReturn)) output.push(normalizedReturn); if (normalizedReturn && !output.includes(normalizedReturn)) output.push(normalizedReturn);
return output.filter(Boolean).join("\n") || "Command returned no output."; return output.filter(Boolean).join("\n") || "Command returned no output.";
} }
function initializeJavaScriptSandbox(context) {
const serializedContext = JSON.stringify(createSerializableContext());
const bootstrap = new vm.Script(`
"use strict";
{
const fetchBridge = globalThis.__previewFetchBridge;
const parseUrlBridge = globalThis.__previewParseUrlBridge;
delete globalThis.__previewFetchBridge;
delete globalThis.__previewParseUrlBridge;
const replies = [];
const noOpMutation = async () => ({ ok: true, preview: true });
const contextValue = ${serializedContext};
Object.defineProperty(Math, "random", {
value: () => 0.424242,
configurable: false,
writable: false
});
Object.freeze(Math);
const NativeDate = Date;
class PreviewDate extends NativeDate {
constructor(...args) {
super(args.length ? args[0] : ${FIXED_NOW});
}
static now() {
return ${FIXED_NOW};
}
}
Object.freeze(PreviewDate);
class PreviewURLSearchParams {
constructor(input = "") {
this._pairs = [];
const value = String(input || "").replace(/^\\?/, "");
for (const part of value.split("&")) {
if (!part) continue;
const separator = part.indexOf("=");
const key = separator >= 0 ? part.slice(0, separator) : part;
const item = separator >= 0 ? part.slice(separator + 1) : "";
this._pairs.push([decodeURIComponent(key.replace(/\\+/g, " ")), decodeURIComponent(item.replace(/\\+/g, " "))]);
}
}
append(key, value) { this._pairs.push([String(key), String(value)]); }
delete(key) { const target = String(key); this._pairs = this._pairs.filter(([name]) => name !== target); }
get(key) { const row = this._pairs.find(([name]) => name === String(key)); return row ? row[1] : null; }
getAll(key) { return this._pairs.filter(([name]) => name === String(key)).map(([, value]) => value); }
has(key) { return this._pairs.some(([name]) => name === String(key)); }
set(key, value) { this.delete(key); this.append(key, value); }
entries() { return this._pairs[Symbol.iterator](); }
keys() { return this._pairs.map(([key]) => key)[Symbol.iterator](); }
values() { return this._pairs.map(([, value]) => value)[Symbol.iterator](); }
forEach(callback, thisArg) { for (const [key, value] of this._pairs) callback.call(thisArg, value, key, this); }
toString() { return this._pairs.map(([key, value]) => encodeURIComponent(key) + "=" + encodeURIComponent(value)).join("&"); }
[Symbol.iterator]() { return this.entries(); }
}
class PreviewURL {
constructor(input, base) {
const parsed = parseUrlBridge(String(input), base === undefined ? undefined : String(base));
for (const key of ["href", "origin", "protocol", "username", "password", "host", "hostname", "port", "pathname", "search", "hash"]) {
this[key] = parsed[key];
}
this.searchParams = new PreviewURLSearchParams(parsed.search);
}
toString() { return this.href; }
toJSON() { return this.href; }
}
async function previewFetch(resource, options = {}) {
const method = String(options && options.method || "GET").toUpperCase();
const raw = await fetchBridge(String(resource), { method });
const headers = Object.freeze({
get(name) { return raw.headers[String(name).toLowerCase()] ?? null; }
});
return Object.freeze({
ok: Boolean(raw.ok),
status: Number(raw.status),
statusText: String(raw.statusText || ""),
url: String(raw.url || ""),
preview: true,
readOnly: true,
truncated: Boolean(raw.truncated),
headers,
text: async () => String(raw.text || ""),
json: async () => JSON.parse(String(raw.text || "null")),
arrayBuffer: async () => Uint8Array.from(raw.bytes || []).buffer
});
}
contextValue.reply = async (value) => {
replies.push(value);
return { ok: true, preview: true };
};
contextValue.economy = Object.freeze({
getBalance: async () => 1234,
balance: async () => 1234,
add: noOpMutation,
take: noOpMutation,
transfer: noOpMutation
});
contextValue.inventory = Object.freeze({
list: async () => [{ id: "item_12345", name: "Example item", quantity: 2 }],
add: noOpMutation,
remove: noOpMutation
});
contextValue.moderation = Object.freeze({
warn: noOpMutation,
timeout: noOpMutation,
ban: noOpMutation,
unban: noOpMutation
});
contextValue.db = Object.freeze({
prepare: () => Object.freeze({
run: () => ({ changes: 0, preview: true }),
get: () => null,
all: () => []
})
});
contextValue.files = Object.freeze({
read: async () => null,
write: noOpMutation,
remove: noOpMutation
});
contextValue.api = Object.freeze({
request: async (resource, options = {}) => {
const response = await previewFetch(resource, options);
const text = await response.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch {}
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
url: response.url,
preview: true,
readOnly: true,
truncated: response.truncated,
text,
data
};
}
});
globalThis.Date = PreviewDate;
globalThis.URL = PreviewURL;
globalThis.URLSearchParams = PreviewURLSearchParams;
globalThis.fetch = previewFetch;
globalThis.console = Object.freeze({ log() {}, warn() {}, error() {} });
globalThis.ctx = Object.freeze(contextValue);
globalThis.module = { exports: {} };
globalThis.exports = {};
globalThis.__previewReplies = replies;
}
`, { filename: "command-preview-bootstrap.js" });
bootstrap.runInContext(context, { timeout: 100 });
}
function maskJavaScriptLiteralsAndComments(source) {
const value = String(source || "");
let output = "";
let state = "code";
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
const next = value[index + 1];
if (state === "code") {
if (char === "'" || char === '"' || char === "`") {
state = char;
output += " ";
} else if (char === "/" && next === "/") {
state = "line-comment";
output += " ";
index += 1;
} else if (char === "/" && next === "*") {
state = "block-comment";
output += " ";
index += 1;
} else {
output += char;
}
continue;
}
if (state === "line-comment") {
if (char === "\n" || char === "\r") {
state = "code";
output += char;
} else {
output += " ";
}
continue;
}
if (state === "block-comment") {
if (char === "*" && next === "/") {
state = "code";
output += " ";
index += 1;
} else {
output += char === "\n" || char === "\r" ? char : " ";
}
continue;
}
if (char === "\\") {
output += " ";
index += 1;
} else if (char === state) {
state = "code";
output += " ";
} else {
output += char === "\n" || char === "\r" ? char : " ";
}
}
return output;
}
function previewPython(code) { function previewPython(code) {
const source = String(code || ""); const source = String(code || "");
if (source.length > MAX_CODE_LENGTH) throw new Error("Command preview code is too large."); if (source.length > MAX_CODE_LENGTH) throw new Error("Command preview code is too large.");
@ -165,80 +359,13 @@ print(json.dumps(replies))
} }
}); });
child.stdin.end(JSON.stringify({ child.stdin.end(JSON.stringify({
code: source, code: preparePythonCommand(source),
ctx: createSerializableContext() ctx: createSerializableContext()
})); }));
}); });
} }
function createMockContext(reply) { async function readOnlyPreviewFetchBridge(resource, options = {}) {
const context = createSerializableContext();
const noOpMutation = async () => ({ ok: true, preview: true });
return {
...context,
reply: async (value) => {
reply(value);
return { ok: true, preview: true };
},
economy: Object.freeze({
getBalance: async () => 1234,
balance: async () => 1234,
add: noOpMutation,
take: noOpMutation,
transfer: noOpMutation
}),
inventory: Object.freeze({
list: async () => [{ id: "item_12345", name: "Example item", quantity: 2 }],
add: noOpMutation,
remove: noOpMutation
}),
moderation: Object.freeze({
warn: noOpMutation,
timeout: noOpMutation,
ban: noOpMutation,
unban: noOpMutation
}),
db: Object.freeze({
prepare: () => Object.freeze({
run: () => ({ changes: 0, preview: true }),
get: () => null,
all: () => []
})
}),
files: Object.freeze({
read: async () => null,
write: noOpMutation,
remove: noOpMutation
}),
api: Object.freeze({
request: readOnlyApiRequest
})
};
}
async function readOnlyApiRequest(resource, options = {}) {
const response = await readOnlyPreviewFetch(resource, options);
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
url: response.url,
preview: true,
readOnly: true,
truncated: response.truncated,
text,
data
};
}
async function readOnlyPreviewFetch(resource, options = {}) {
if (typeof fetch !== "function") { if (typeof fetch !== "function") {
throw new Error("Preview fetch is unavailable in this runtime."); throw new Error("Preview fetch is unavailable in this runtime.");
} }
@ -246,10 +373,14 @@ async function readOnlyPreviewFetch(resource, options = {}) {
if (!["GET", "HEAD"].includes(method)) { if (!["GET", "HEAD"].includes(method)) {
throw new Error("Preview fetch only allows read-only GET and HEAD requests."); throw new Error("Preview fetch only allows read-only GET and HEAD requests.");
} }
const target = new URL(String(resource || ""));
if (target.protocol !== "data:") {
throw new Error("Preview does not send external network requests.");
}
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PREVIEW_FETCH_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), PREVIEW_FETCH_TIMEOUT_MS);
try { try {
const response = await fetch(resource, { const response = await fetch(target, {
...options, ...options,
method, method,
body: undefined, body: undefined,
@ -262,11 +393,11 @@ async function readOnlyPreviewFetch(resource, options = {}) {
? buffer.subarray(0, MAX_PREVIEW_FETCH_BYTES) ? buffer.subarray(0, MAX_PREVIEW_FETCH_BYTES)
: buffer; : buffer;
const text = limited.toString("utf8"); const text = limited.toString("utf8");
const arrayBuffer = limited.buffer.slice( const headers = Object.create(null);
limited.byteOffset, response.headers.forEach((value, key) => {
limited.byteOffset + limited.byteLength headers[String(key).toLowerCase()] = String(value);
); });
return Object.freeze({ return {
ok: response.ok, ok: response.ok,
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
@ -274,18 +405,32 @@ async function readOnlyPreviewFetch(resource, options = {}) {
preview: true, preview: true,
readOnly: true, readOnly: true,
truncated: buffer.length > MAX_PREVIEW_FETCH_BYTES, truncated: buffer.length > MAX_PREVIEW_FETCH_BYTES,
headers: Object.freeze({ headers,
get: (name) => response.headers.get(name) text,
}), bytes: Array.from(limited)
text: async () => text, };
json: async () => JSON.parse(text || "null"),
arrayBuffer: async () => arrayBuffer
});
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
} }
function parsePreviewUrl(input, base) {
const parsed = base === undefined ? new URL(input) : new URL(input, base);
return {
href: parsed.href,
origin: parsed.origin,
protocol: parsed.protocol,
username: parsed.username,
password: parsed.password,
host: parsed.host,
hostname: parsed.hostname,
port: parsed.port,
pathname: parsed.pathname,
search: parsed.search,
hash: parsed.hash
};
}
function createSerializableContext() { function createSerializableContext() {
return { return {
platform: "discord", platform: "discord",

View File

@ -0,0 +1,116 @@
"use strict";
const CONDITION_PATTERN = /^(<=|>=|<|>|==|=)\s*(-?\d+)$/;
function normalizeRandomReplies(value) {
let rows = value;
if (typeof rows === "string") {
try { rows = JSON.parse(rows || "[]"); } catch { rows = []; }
}
if (!Array.isArray(rows)) return [];
return rows.map((row) => ({
text: String(row?.text || "").trim(),
weight: integerValue(row?.weight, 1),
condition: String(row?.condition || "").trim().replace(/\s+/g, "")
})).filter((row) => row.text);
}
function parseRandomRepliesFromBody(body = {}) {
const texts = arrayValue(body.random_text);
const weights = arrayValue(body.random_weight);
const conditions = arrayValue(body.random_condition);
return normalizeRandomReplies(texts.map((text, index) => ({
text,
weight: weights[index] ?? 1,
condition: conditions[index] ?? ""
})));
}
function normalizeRandomConfig({ replies, rngEnabled, rngMin, rngMax } = {}) {
return {
replies: normalizeRandomReplies(replies),
rngEnabled: rngEnabled === true || rngEnabled === 1 || rngEnabled === "on" || rngEnabled === "true",
rngMin: integerValue(rngMin, 1),
rngMax: integerValue(rngMax, 100)
};
}
function validateRandomConfig(input = {}) {
const config = normalizeRandomConfig(input);
const errors = [];
if (!config.replies.length) errors.push("Add at least one random reply.");
if (config.replies.length > 100) errors.push("Random Reply supports at most 100 messages.");
if (!Number.isInteger(config.rngMin) || !Number.isInteger(config.rngMax) || config.rngMin < -1000000 || config.rngMin > 1000000 || config.rngMax < -1000000 || config.rngMax > 1000000) {
errors.push("RNG limits must be whole numbers between -1,000,000 and 1,000,000.");
}
if (config.rngMin > config.rngMax) errors.push("RNG minimum must be less than or equal to the maximum.");
config.replies.forEach((reply, index) => {
if (reply.text.length > 2000) errors.push(`Reply ${index + 1} is longer than 2,000 characters.`);
if (!Number.isInteger(reply.weight) || reply.weight < 1 || reply.weight > 999) {
errors.push(`Reply ${index + 1} weight must be a whole number from 1 to 999.`);
}
if (reply.condition && !CONDITION_PATTERN.test(reply.condition)) {
errors.push(`Reply ${index + 1} condition must look like <20, >=10, or =5.`);
}
if (reply.condition && !config.rngEnabled) {
errors.push(`Turn on RNG before adding a condition to reply ${index + 1}.`);
}
});
return { ok: errors.length === 0, errors, config };
}
function selectRandomReply(input = {}, random = Math.random) {
const validation = validateRandomConfig(input);
if (!validation.ok) throw new Error(validation.errors[0]);
const config = validation.config;
const rng = config.rngEnabled
? config.rngMin + Math.floor(safeRandom(random) * (config.rngMax - config.rngMin + 1))
: null;
const eligible = config.replies.filter((reply) => !reply.condition || conditionMatches(reply.condition, rng));
if (!eligible.length) throw new Error(`No random reply matches RNG value ${rng}.`);
const totalWeight = eligible.reduce((total, reply) => total + reply.weight, 0);
let target = safeRandom(random) * totalWeight;
for (const reply of eligible) {
target -= reply.weight;
if (target < 0) return { reply, rng, eligibleCount: eligible.length };
}
return { reply: eligible[eligible.length - 1], rng, eligibleCount: eligible.length };
}
function conditionMatches(condition, value) {
const match = String(condition || "").match(CONDITION_PATTERN);
if (!match || !Number.isFinite(value)) return false;
const expected = Number(match[2]);
if (match[1] === "<") return value < expected;
if (match[1] === "<=") return value <= expected;
if (match[1] === ">") return value > expected;
if (match[1] === ">=") return value >= expected;
return value === expected;
}
function safeRandom(random) {
const value = Number(typeof random === "function" ? random() : Math.random());
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(value, 0.999999999999));
}
function arrayValue(value) {
if (Array.isArray(value)) return value;
if (value === undefined || value === null) return [];
return [value];
}
function integerValue(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
const number = Number(value);
return Number.isInteger(number) ? number : Number.NaN;
}
module.exports = {
conditionMatches,
normalizeRandomConfig,
normalizeRandomReplies,
parseRandomRepliesFromBody,
selectRandomReply,
validateRandomConfig
};

View File

@ -5,6 +5,7 @@ const {
runAdvancedCommand, runAdvancedCommand,
normalizeCommandResult normalizeCommandResult
} = require("./commands"); } = require("./commands");
const { normalizeRandomReplies, selectRandomReply } = require("./command-random");
const { getEnabledPlatformIds, normalizePlatformSelection } = require("./platforms"); const { getEnabledPlatformIds, normalizePlatformSelection } = require("./platforms");
const placeholders = require("./placeholders"); const placeholders = require("./placeholders");
@ -138,7 +139,7 @@ function createCommandRouter({ settings }) {
async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) { async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
const row = db const row = db
.prepare( .prepare(
"SELECT response, mode, language, code, platform FROM custom_commands WHERE trigger = ? AND enabled = 1" "SELECT response, mode, language, code, platform, random_replies_json, rng_enabled, rng_min, rng_max FROM custom_commands WHERE trigger = ? AND enabled = 1"
) )
.get(trigger); .get(trigger);
if (!row) { if (!row) {
@ -175,6 +176,26 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
} else { } else {
await safeReply(reply, "Command ran but returned no output."); await safeReply(reply, "Command ran but returned no output.");
} }
} else if (row.mode === "random") {
const selected = selectRandomReply({
replies: normalizeRandomReplies(row.random_replies_json),
rngEnabled: Boolean(row.rng_enabled),
rngMin: row.rng_min,
rngMax: row.rng_max
});
const rendered = await placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",
template: selected.reply.text,
outputAudience: "user",
user: {
id: ctx.user.id,
username: ctx.user.username,
isAdmin: false,
isMod: false
},
runtimeContext: { ctx, user: ctx.user, platform, runtime: true, command: { rng: selected.rng } }
});
await safeReply(reply, rendered.rendered);
} else { } else {
const rendered = await placeholders.renderTemplate({ const rendered = await placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response", fieldId: "core.custom_commands.static_response",

View File

@ -3,6 +3,11 @@ const { spawn } = require("child_process");
const fs = require("fs"); const fs = require("fs");
const os = require("os"); const os = require("os");
const path = require("path"); const path = require("path");
const {
prepareJavaScriptCommand,
preparePythonCommand,
resolveJavaScriptHandler
} = require("./command-code");
function buildCommandContext({ platform, user, message, args, argsText }) { function buildCommandContext({ platform, user, message, args, argsText }) {
return { return {
@ -33,12 +38,12 @@ async function runJsCommand(code, ctx) {
exports: {} exports: {}
}; };
const context = vm.createContext(sandbox); const context = vm.createContext(sandbox);
const script = new vm.Script(code, { filename: "command.js" }); const script = new vm.Script(prepareJavaScriptCommand(code), { filename: "command.js" });
script.runInContext(context, { timeout: 1000 }); script.runInContext(context, { timeout: 1000 });
const handler = context.run || context.module.exports || context.exports; const handler = resolveJavaScriptHandler(context);
if (typeof handler !== "function") { if (typeof handler !== "function") {
throw new Error("Dynamic commands must export a run(ctx) function."); throw new Error("Dynamic command did not create a runnable handler.");
} }
const result = handler(ctx); const result = handler(ctx);
if (result && typeof result.then === "function") { if (result && typeof result.then === "function") {
@ -50,7 +55,7 @@ async function runJsCommand(code, ctx) {
async function runPythonCommand(code, ctx) { async function runPythonCommand(code, ctx) {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const payload = JSON.stringify(ctx); const payload = JSON.stringify(ctx);
const encoded = Buffer.from(code, "utf8").toString("base64"); const encoded = Buffer.from(preparePythonCommand(code), "utf8").toString("base64");
const script = ` const script = `
import base64, json, sys, traceback import base64, json, sys, traceback
ctx = json.loads(sys.stdin.read() or "{}") ctx = json.loads(sys.stdin.read() or "{}")
@ -59,7 +64,7 @@ globals_dict = {}
try: try:
exec(code, globals_dict) exec(code, globals_dict)
if "run" not in globals_dict: if "run" not in globals_dict:
raise Exception("Define a run(ctx) function.") raise Exception("Dynamic command did not create a runnable handler.")
result = globals_dict["run"](ctx) result = globals_dict["run"](ctx)
if result is None: if result is None:
sys.exit(0) sys.exit(0)

View File

@ -129,6 +129,10 @@ function migrate() {
preview_error TEXT, preview_error TEXT,
preview_generated_at INTEGER, preview_generated_at INTEGER,
preview_dynamic_segments TEXT NOT NULL DEFAULT '[]', preview_dynamic_segments TEXT NOT NULL DEFAULT '[]',
random_replies_json TEXT NOT NULL DEFAULT '[]',
rng_enabled INTEGER NOT NULL DEFAULT 0,
rng_min INTEGER NOT NULL DEFAULT 1,
rng_max INTEGER NOT NULL DEFAULT 100,
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
@ -222,6 +226,16 @@ function migrate() {
CREATE INDEX IF NOT EXISTS feedback_support_feedback_idx ON feedback_support (feedback_id, created_at); CREATE INDEX IF NOT EXISTS feedback_support_feedback_idx ON feedback_support (feedback_id, created_at);
CREATE TABLE IF NOT EXISTS feedback_merges (
source_feedback_id TEXT PRIMARY KEY,
target_feedback_id TEXT NOT NULL,
merged_by TEXT,
reason TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS feedback_merges_target_idx ON feedback_merges (target_feedback_id, created_at);
CREATE TABLE IF NOT EXISTS feedback_attachments ( CREATE TABLE IF NOT EXISTS feedback_attachments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
feedback_id TEXT NOT NULL, feedback_id TEXT NOT NULL,
@ -234,6 +248,67 @@ function migrate() {
); );
CREATE INDEX IF NOT EXISTS feedback_attachments_feedback_idx ON feedback_attachments (feedback_id, created_at); CREATE INDEX IF NOT EXISTS feedback_attachments_feedback_idx ON feedback_attachments (feedback_id, created_at);
CREATE TABLE IF NOT EXISTS overlays (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
canvas_width INTEGER NOT NULL DEFAULT 1920,
canvas_height INTEGER NOT NULL DEFAULT 1080,
sort_order INTEGER NOT NULL DEFAULT 0,
active_scene_id TEXT,
public_token_hash TEXT NOT NULL UNIQUE,
public_token_encrypted TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS overlays_sort_idx ON overlays (sort_order, created_at);
CREATE TABLE IF NOT EXISTS overlay_scenes (
id TEXT PRIMARY KEY,
overlay_id TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
public_token_hash TEXT NOT NULL UNIQUE,
public_token_encrypted TEXT NOT NULL,
obs_scene_name TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (overlay_id) REFERENCES overlays(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS overlay_scenes_overlay_idx ON overlay_scenes (overlay_id, sort_order, created_at);
CREATE TABLE IF NOT EXISTS overlay_modules (
id TEXT PRIMARY KEY,
scene_id TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
config_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (scene_id) REFERENCES overlay_scenes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS overlay_modules_scene_idx ON overlay_modules (scene_id, sort_order, created_at);
CREATE TABLE IF NOT EXISTS overlay_obs_settings (
overlay_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
provider TEXT NOT NULL DEFAULT 'local_obs_websocket',
endpoint TEXT NOT NULL DEFAULT 'ws://127.0.0.1:4455',
password_encrypted TEXT,
sync_direction TEXT NOT NULL DEFAULT 'none',
reconnect INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (overlay_id) REFERENCES overlays(id) ON DELETE CASCADE
);
`); `);
const columns = db const columns = db
@ -270,6 +345,18 @@ function migrate() {
if (!columns.includes("preview_dynamic_segments")) { if (!columns.includes("preview_dynamic_segments")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN preview_dynamic_segments TEXT NOT NULL DEFAULT '[]'"); db.exec("ALTER TABLE custom_commands ADD COLUMN preview_dynamic_segments TEXT NOT NULL DEFAULT '[]'");
} }
if (!columns.includes("random_replies_json")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN random_replies_json TEXT NOT NULL DEFAULT '[]'");
}
if (!columns.includes("rng_enabled")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN rng_enabled INTEGER NOT NULL DEFAULT 0");
}
if (!columns.includes("rng_min")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN rng_min INTEGER NOT NULL DEFAULT 1");
}
if (!columns.includes("rng_max")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN rng_max INTEGER NOT NULL DEFAULT 100");
}
const pageColumns = db const pageColumns = db
.prepare("PRAGMA table_info(custom_pages)") .prepare("PRAGMA table_info(custom_pages)")
@ -310,6 +397,17 @@ function migrate() {
db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_size INTEGER"); db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_size INTEGER");
} }
const overlayColumns = db
.prepare("PRAGMA table_info(overlays)")
.all()
.map((column) => column.name);
if (!overlayColumns.includes("canvas_width")) {
db.exec("ALTER TABLE overlays ADD COLUMN canvas_width INTEGER NOT NULL DEFAULT 1920");
}
if (!overlayColumns.includes("canvas_height")) {
db.exec("ALTER TABLE overlays ADD COLUMN canvas_height INTEGER NOT NULL DEFAULT 1080");
}
migrateLegacyUsers(); migrateLegacyUsers();
} }

View File

@ -3,7 +3,7 @@ const crypto = require("crypto");
const DELAY_MS = 3000; const DELAY_MS = 3000;
const EXPIRES_MS = 30000; const EXPIRES_MS = 30000;
const MAX_PENDING = 20; const MAX_PENDING = 20;
const DESTRUCTIVE_PATH = /(?:^|\/)(?:delete|remove|clear|reset|renew|uninstall|cleanup|archive|revoke|unlink|unset)(?:\/|$)/i; const DESTRUCTIVE_PATH = /(?:^|\/)(?:delete|remove|clear|reset|renew|uninstall|cleanup|archive|revoke|unlink|unset|revert)(?:\/|$)/i;
function isDestructivePath(value) { function isDestructivePath(value) {
return DESTRUCTIVE_PATH.test(normalizeAction(value)); return DESTRUCTIVE_PATH.test(normalizeAction(value));

View File

@ -211,24 +211,30 @@ function findSimilarFeedback(input = {}, options = {}) {
const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : ""; const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : "";
const currentUrl = cleanUrl(input.current_url); const currentUrl = cleanUrl(input.current_url);
const pagePath = pagePathKey(currentUrl); const pagePath = pagePathKey(currentUrl);
const targetMetadata = sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata);
if (summary.length < 6 && description.length < 12 && !pagePath) return []; if (summary.length < 6 && description.length < 12 && !pagePath) return [];
const rows = db const rows = db
.prepare( .prepare(
"SELECT id, submitter_id, summary, description, category, severity, scope_type, scope_label, current_url, page_title, status, created_at, updated_at, last_activity_at " + "SELECT id, submitter_id, summary, description, steps_to_reproduce, expected_behavior, actual_behavior, category, severity, scope_type, scope_label, target_metadata_json, current_url, page_title, status, created_at, updated_at, last_activity_at " +
"FROM feedback_entries WHERE deleted_at IS NULL AND status NOT IN ('deleted', 'closed', 'solved', 'fixed', 'archived') " + "FROM feedback_entries WHERE deleted_at IS NULL AND status NOT IN ('deleted', 'closed', 'solved', 'fixed', 'archived') " +
"ORDER BY last_activity_at DESC LIMIT 150" "ORDER BY last_activity_at DESC LIMIT 150"
) )
.all(); .all();
const queryTokens = tokenSet(`${summary} ${description}`); const queryTokens = tokenSet(`${summary} ${description} ${input.steps_to_reproduce || ""} ${input.expected_behavior || ""} ${input.actual_behavior || ""} ${Object.values(targetMetadata).join(" ")}`);
const matches = rows const matches = rows
.map((row) => { .map((row) => {
const rowTokens = tokenSet(`${row.summary} ${row.description || ""} ${row.scope_label || ""} ${row.page_title || ""}`); const rowTarget = parseJson(row.target_metadata_json, {});
const rowTokens = tokenSet(`${row.summary} ${row.description || ""} ${row.steps_to_reproduce || ""} ${row.expected_behavior || ""} ${row.actual_behavior || ""} ${row.scope_label || ""} ${row.page_title || ""} ${Object.values(rowTarget).join(" ")}`);
const samePath = pagePath && pagePath === pagePathKey(row.current_url); const samePath = pagePath && pagePath === pagePathKey(row.current_url);
const sameTarget = targetMetadata.path && targetMetadata.path === rowTarget.path;
const sameSelector = targetMetadata.selector && targetMetadata.selector === rowTarget.selector;
const score = const score =
jaccardScore(queryTokens, rowTokens) + jaccardScore(queryTokens, rowTokens) +
(scopeType && row.scope_type === scopeType ? 0.25 : 0) + (scopeType && row.scope_type === scopeType ? 0.25 : 0) +
(category && row.category === category ? 0.15 : 0) + (category && row.category === category ? 0.15 : 0) +
(samePath ? 0.35 : 0); (samePath ? 0.35 : 0) +
(sameTarget ? 0.4 : 0) +
(sameSelector ? 0.25 : 0);
return { row, score }; return { row, score };
}) })
.filter(({ score }) => score >= 0.32) .filter(({ score }) => score >= 0.32)
@ -291,6 +297,47 @@ function getFeedbackForAdmin(id) {
return entry; return entry;
} }
function mergeFeedback(sourceId, targetId, input = {}, actor) {
const source = getFeedbackForAdmin(sourceId);
const target = getFeedbackForAdmin(targetId);
if (!source || !target) throw new Error("Both feedback items must exist before they can be merged.");
if (source.id === target.id) throw new Error("A feedback item cannot be merged into itself.");
if (source.status === "deleted" || target.status === "deleted") throw new Error("Deleted feedback cannot be merged.");
if (target.merged_into) throw new Error("Choose the final canonical feedback item, not another duplicate.");
const reason = cleanText(input.reason, 1000) || `Merged into ${target.summary}.`;
const now = Date.now();
db.transaction(() => {
db.prepare(
"INSERT INTO feedback_merges (source_feedback_id, target_feedback_id, merged_by, reason, created_at) VALUES (?, ?, ?, ?, ?) " +
"ON CONFLICT(source_feedback_id) DO UPDATE SET target_feedback_id = excluded.target_feedback_id, merged_by = excluded.merged_by, reason = excluded.reason, created_at = excluded.created_at"
).run(source.id, target.id, actor?.id || null, reason, now);
db.prepare(
"UPDATE feedback_entries SET status = 'duplicate', updated_at = ?, last_activity_at = ?, deleted_at = NULL WHERE id = ?"
).run(now, now, source.id);
addStatusHistory(source.id, "duplicate", actor?.id || null, `Merged into feedback ${target.id}: ${reason}`, now);
db.prepare(
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'work_note', ?, 0, ?)"
).run(target.id, actor?.id || null, `Merged feedback ${source.id} (${source.summary}). ${reason}`, now);
touchFeedback(target.id, now);
})();
return { source: getFeedbackForAdmin(source.id), target: getFeedbackForAdmin(target.id) };
}
function unmergeFeedback(sourceId, input = {}, actor) {
const source = getFeedbackForAdmin(sourceId);
if (!source?.merged_into) throw new Error("That feedback item is not currently merged.");
const now = Date.now();
const note = cleanText(input.reason, 1000) || "Duplicate merge was undone.";
db.transaction(() => {
db.prepare("DELETE FROM feedback_merges WHERE source_feedback_id = ?").run(source.id);
db.prepare(
"UPDATE feedback_entries SET status = 'reviewed', updated_at = ?, last_activity_at = ?, deleted_at = NULL WHERE id = ?"
).run(now, now, source.id);
addStatusHistory(source.id, "reviewed", actor?.id || null, note, now);
})();
return getFeedbackForAdmin(source.id);
}
function listFeedbackForAdmin(filters = {}) { function listFeedbackForAdmin(filters = {}) {
const where = []; const where = [];
const params = []; const params = [];
@ -315,9 +362,21 @@ function listFeedbackForAdmin(filters = {}) {
} }
if (filters.area) { if (filters.area) {
where.push( where.push(
"(lower(feedback_entries.scope_label) LIKE lower(?) OR lower(feedback_entries.current_url) LIKE lower(?) OR lower(feedback_entries.page_title) LIKE lower(?))" "(lower(feedback_entries.scope_label) LIKE lower(?) OR lower(feedback_entries.current_url) LIKE lower(?) OR lower(feedback_entries.page_title) LIKE lower(?) OR lower(feedback_entries.target_metadata_json) LIKE lower(?))"
); );
params.push(`%${filters.area}%`, `%${filters.area}%`, `%${filters.area}%`); params.push(`%${filters.area}%`, `%${filters.area}%`, `%${filters.area}%`, `%${filters.area}%`);
}
if (filters.route) {
where.push("lower(feedback_entries.current_url) LIKE lower(?)");
params.push(`%${filters.route}%`);
}
if (filters.plugin) {
where.push("(lower(feedback_entries.current_url) LIKE lower(?) OR lower(feedback_entries.scope_label) LIKE lower(?))");
params.push(`%/plugins/${filters.plugin}%`, `%${filters.plugin}%`);
}
if (filters.target) {
where.push("(lower(feedback_entries.target_metadata_json) LIKE lower(?) OR lower(feedback_entries.scope_label) LIKE lower(?))");
params.push(`%${filters.target}%`, `%${filters.target}%`);
} }
const from = parseDateBoundary(filters.date_from, "start"); const from = parseDateBoundary(filters.date_from, "start");
if (from) { if (from) {
@ -585,10 +644,12 @@ function supportFeedback(id, actor) {
if (!row || row.deleted_at || row.status === "deleted") { if (!row || row.deleted_at || row.status === "deleted") {
throw new Error("Feedback item was not found."); throw new Error("Feedback item was not found.");
} }
const merged = db.prepare("SELECT target_feedback_id FROM feedback_merges WHERE source_feedback_id = ?").get(id);
const effectiveId = merged?.target_feedback_id || id;
db.prepare( db.prepare(
"INSERT OR IGNORE INTO feedback_support (feedback_id, user_id, created_at) VALUES (?, ?, ?)" "INSERT OR IGNORE INTO feedback_support (feedback_id, user_id, created_at) VALUES (?, ?, ?)"
).run(id, actor.id, Date.now()); ).run(effectiveId, actor.id, Date.now());
return supportSummary([id], actor.id).counts.get(id) || 0; return supportSummary([effectiveId], actor.id).counts.get(effectiveId) || 0;
} }
function adminUpdateFeedback(id, input, actor) { function adminUpdateFeedback(id, input, actor) {
@ -644,6 +705,13 @@ function deleteFeedback(id, options = {}) {
} }
const attachments = attachmentsFor(id); const attachments = attachmentsFor(id);
db.transaction(() => { db.transaction(() => {
const mergedSources = db.prepare("SELECT source_feedback_id FROM feedback_merges WHERE target_feedback_id = ?").all(id);
for (const source of mergedSources) {
const now = Date.now();
db.prepare("UPDATE feedback_entries SET status = 'reviewed', updated_at = ?, last_activity_at = ? WHERE id = ?")
.run(now, now, source.source_feedback_id);
}
db.prepare("DELETE FROM feedback_merges WHERE source_feedback_id = ? OR target_feedback_id = ?").run(id, id);
db.prepare("DELETE FROM feedback_comments WHERE feedback_id = ?").run(id); db.prepare("DELETE FROM feedback_comments WHERE feedback_id = ?").run(id);
db.prepare("DELETE FROM feedback_status_history WHERE feedback_id = ?").run(id); db.prepare("DELETE FROM feedback_status_history WHERE feedback_id = ?").run(id);
db.prepare("DELETE FROM feedback_support WHERE feedback_id = ?").run(id); db.prepare("DELETE FROM feedback_support WHERE feedback_id = ?").run(id);
@ -1747,17 +1815,19 @@ function supportSummary(ids, userId) {
return { counts, mine }; return { counts, mine };
} }
const placeholders = cleanIds.map(() => "?").join(","); const placeholders = cleanIds.map(() => "?").join(",");
db.prepare( const related = new Map(cleanIds.map((id) => [id, new Set([id])]));
`SELECT feedback_id, COUNT(*) AS count FROM feedback_support WHERE feedback_id IN (${placeholders}) GROUP BY feedback_id` db.prepare(`SELECT source_feedback_id, target_feedback_id FROM feedback_merges WHERE target_feedback_id IN (${placeholders})`)
)
.all(...cleanIds) .all(...cleanIds)
.forEach((row) => counts.set(row.feedback_id, row.count)); .forEach((row) => related.get(row.target_feedback_id)?.add(row.source_feedback_id));
if (userId) { const allIds = [...new Set([...related.values()].flatMap((set) => [...set]))];
db.prepare( const allPlaceholders = allIds.map(() => "?").join(",");
`SELECT feedback_id FROM feedback_support WHERE user_id = ? AND feedback_id IN (${placeholders})` const supporters = db.prepare(
) `SELECT feedback_id, user_id FROM feedback_support WHERE feedback_id IN (${allPlaceholders})`
.all(userId, ...cleanIds) ).all(...allIds);
.forEach((row) => mine.add(row.feedback_id)); for (const [id, relatedIds] of related) {
const users = new Set(supporters.filter((row) => relatedIds.has(row.feedback_id)).map((row) => row.user_id));
if (users.size) counts.set(id, users.size);
if (userId && users.has(userId)) mine.add(id);
} }
return { counts, mine }; return { counts, mine };
} }
@ -1782,7 +1852,7 @@ function enforceRateLimit(userId) {
} }
} }
function hydrateFeedback(row, { admin }) { function hydrateFeedback(row, { admin, includeMerge = true }) {
const parsed = { const parsed = {
...decorateLabels(row), ...decorateLabels(row),
target_metadata: parseJson(row.target_metadata_json, {}), target_metadata: parseJson(row.target_metadata_json, {}),
@ -1804,9 +1874,43 @@ function hydrateFeedback(row, { admin }) {
delete parsed.diagnostics_json; delete parsed.diagnostics_json;
parsed.comments = parsed.comments.filter((comment) => comment.visible_to_submitter); parsed.comments = parsed.comments.filter((comment) => comment.visible_to_submitter);
} }
if (includeMerge) Object.assign(parsed, mergeContext(row.id, admin));
return parsed; return parsed;
} }
function mergeContext(feedbackId, admin) {
const mergedInto = db.prepare(
"SELECT feedback_merges.*, feedback_entries.summary, feedback_entries.status FROM feedback_merges " +
"JOIN feedback_entries ON feedback_entries.id = feedback_merges.target_feedback_id WHERE source_feedback_id = ?"
).get(feedbackId);
const sourceRows = db.prepare(
"SELECT feedback_entries.* FROM feedback_merges JOIN feedback_entries ON feedback_entries.id = feedback_merges.source_feedback_id " +
"WHERE feedback_merges.target_feedback_id = ? ORDER BY feedback_merges.created_at ASC"
).all(feedbackId);
const reporterIds = new Set();
const ownReporter = db.prepare("SELECT submitter_id FROM feedback_entries WHERE id = ?").get(feedbackId)?.submitter_id;
if (ownReporter) reporterIds.add(ownReporter);
sourceRows.forEach((row) => reporterIds.add(row.submitter_id));
return {
merged_into: mergedInto ? {
id: mergedInto.target_feedback_id,
summary: mergedInto.summary,
status: mergedInto.status,
reason: mergedInto.reason,
created_at: mergedInto.created_at
} : null,
merged_sources: sourceRows.map((source) => {
const hydrated = hydrateFeedback(source, { admin, includeMerge: false });
if (!admin) {
hydrated.screenshot = null;
hydrated.attachments = [];
}
return hydrated;
}),
reporter_count: reporterIds.size
};
}
function attachmentsFor(feedbackId) { function attachmentsFor(feedbackId) {
return db return db
.prepare("SELECT * FROM feedback_attachments WHERE feedback_id = ? ORDER BY created_at ASC") .prepare("SELECT * FROM feedback_attachments WHERE feedback_id = ? ORDER BY created_at ASC")
@ -2042,7 +2146,9 @@ module.exports = {
listMyFeedback, listMyFeedback,
listPublicFeedback, listPublicFeedback,
markFeedbackViewed, markFeedbackViewed,
mergeFeedback,
notificationSummary, notificationSummary,
supportFeedback, supportFeedback,
unmergeFeedback,
addSubmitterComment addSubmitterComment
}; };

View File

@ -0,0 +1,621 @@
const { OBSWebSocket, EventSubscription } = require("obs-websocket-js");
const { publishWebEvent } = require("./web-events");
const {
getObsSettings,
getOverlay,
listEnabledObsSettings,
onOverlayChanged,
setActiveScene
} = require("./overlays");
const providers = new Map();
const browserBridgeConnectors = new Map();
const OBS_BROWSER_CONTROL_LEVELS = [
"No OBS access",
"Read OBS status",
"Read scenes and transitions",
"Basic OBS actions",
"Advanced OBS control",
"Full OBS control"
];
function cleanNames(values) {
return Array.isArray(values)
? values.slice(0, 250).map((value) => {
const candidate = value && typeof value === "object"
? (value.name || value.sceneName || value.transitionName || value.profileName || value.sceneCollectionName)
: value;
return String(candidate || "").trim().slice(0, 200);
}).filter(Boolean)
: [];
}
function registerOverlayConnectorProvider(provider) {
if (!provider?.id || typeof provider.create !== "function") {
throw new Error("Connector providers require an id and create(settings) function.");
}
providers.set(provider.id, provider);
return () => providers.delete(provider.id);
}
function listOverlayConnectorProviders() {
return Array.from(providers.values()).map(({ id, label, available = true, description }) => ({
id,
label,
available,
description
}));
}
class LocalObsWebSocketConnector {
constructor(settings) {
this.settings = settings;
this.client = new OBSWebSocket();
this.state = "disconnected";
this.error = null;
this.currentScene = null;
this.scenes = [];
this.transitions = [];
this.currentTransition = null;
this.canvasWidth = null;
this.canvasHeight = null;
this.profiles = [];
this.currentProfile = null;
this.sceneCollections = [];
this.currentSceneCollection = null;
this.outputs = {};
this.sceneListeners = new Set();
this.closedListeners = new Set();
this.statusListeners = new Set();
this.client.on("CurrentProgramSceneChanged", ({ sceneName }) => {
this.currentScene = sceneName || null;
this.sceneListeners.forEach((listener) => listener(this.currentScene));
});
this.client.on("ConnectionClosed", (error) => {
this.state = "disconnected";
this.error = error?.message || null;
this.closedListeners.forEach((listener) => listener(error));
});
this.client.on("ConnectionError", (error) => {
this.state = "error";
this.error = error?.message || "OBS connection failed.";
});
const refreshStatus = () => this.refreshSetupState().then(() => {
this.statusListeners.forEach((listener) => listener(this.status()));
}).catch(() => {});
this.client.on("CurrentProfileChanged", ({ profileName }) => {
this.currentProfile = profileName || null;
refreshStatus();
});
this.client.on("ProfileListChanged", refreshStatus);
this.client.on("CurrentSceneCollectionChanged", ({ sceneCollectionName }) => {
this.currentSceneCollection = sceneCollectionName || null;
refreshStatus();
});
this.client.on("SceneCollectionListChanged", refreshStatus);
this.client.on("CurrentSceneTransitionChanged", ({ transitionName }) => {
this.currentTransition = transitionName || null;
refreshStatus();
});
this.client.on("SceneTransitionListChanged", refreshStatus);
for (const eventName of [
"StreamStateChanged", "RecordStateChanged", "ReplayBufferStateChanged", "VirtualcamStateChanged"
]) this.client.on(eventName, refreshStatus);
}
async connect() {
this.state = "connecting";
this.error = null;
try {
await this.client.connect(this.settings.endpoint, this.settings.password || undefined, {
eventSubscriptions: EventSubscription.Scenes | EventSubscription.Transitions | EventSubscription.Config | EventSubscription.Outputs
});
this.state = "connected";
const current = await this.client.call("GetCurrentProgramScene");
this.currentScene = current.currentProgramSceneName || null;
await this.refreshSetupState();
return this.status();
} catch (error) {
this.state = "error";
this.error = error?.message || "OBS connection failed.";
try { await this.client.disconnect(); } catch {}
throw error;
}
}
async disconnect() {
try { await this.client.disconnect(); } catch {}
this.state = "disconnected";
}
onSceneChanged(listener) {
this.sceneListeners.add(listener);
return () => this.sceneListeners.delete(listener);
}
onClosed(listener) {
this.closedListeners.add(listener);
return () => this.closedListeners.delete(listener);
}
onStatusChanged(listener) {
this.statusListeners.add(listener);
return () => this.statusListeners.delete(listener);
}
async listScenes() {
const result = await this.client.call("GetSceneList");
this.scenes = (result.scenes || []).map((scene) => scene.sceneName).filter(Boolean);
return this.scenes;
}
async getActiveScene() {
const result = await this.client.call("GetCurrentProgramScene");
this.currentScene = result.currentProgramSceneName || null;
return this.currentScene;
}
async setActiveScene(sceneName) {
await this.client.call("SetCurrentProgramScene", { sceneName });
this.currentScene = sceneName;
}
async refreshSetupState() {
const results = await Promise.allSettled([
this.listScenes(),
this.client.call("GetTransitionList"),
this.client.call("GetVideoSettings"),
this.client.call("GetProfileList"),
this.client.call("GetSceneCollectionList"),
this.client.call("GetStreamStatus"),
this.client.call("GetRecordStatus"),
this.client.call("GetReplayBufferStatus"),
this.client.call("GetVirtualCamStatus")
]);
if (results[1].status === "fulfilled") {
this.transitions = (results[1].value.transitions || []).map((item) => item.transitionName).filter(Boolean);
this.currentTransition = results[1].value.currentSceneTransitionName || null;
}
if (results[2].status === "fulfilled") {
this.canvasWidth = Number(results[2].value.baseWidth) || null;
this.canvasHeight = Number(results[2].value.baseHeight) || null;
}
if (results[3].status === "fulfilled") {
this.profiles = cleanNames(results[3].value.profiles);
this.currentProfile = results[3].value.currentProfileName || null;
}
if (results[4].status === "fulfilled") {
this.sceneCollections = cleanNames(results[4].value.sceneCollections);
this.currentSceneCollection = results[4].value.currentSceneCollectionName || null;
}
this.outputs = {
streaming: results[5].status === "fulfilled" && Boolean(results[5].value.outputActive),
recording: results[6].status === "fulfilled" && Boolean(results[6].value.outputActive),
recording_paused: results[6].status === "fulfilled" && Boolean(results[6].value.outputPaused),
replay_buffer: results[7].status === "fulfilled" && Boolean(results[7].value.outputActive),
virtual_camera: results[8].status === "fulfilled" && Boolean(results[8].value.outputActive)
};
}
status() {
return {
state: this.state,
error: this.error,
current_scene: this.currentScene,
scenes: this.scenes,
transitions: this.transitions,
current_transition: this.currentTransition,
canvas_width: this.canvasWidth,
canvas_height: this.canvasHeight,
profiles: this.profiles,
current_profile: this.currentProfile,
scene_collections: this.sceneCollections,
current_scene_collection: this.currentSceneCollection,
outputs: this.outputs,
profiles_supported: true,
scene_collections_supported: true
};
}
}
class ObsBrowserBridgeConnector {
constructor(settings) {
this.settings = settings;
this.overlayId = settings.overlay_id;
this.state = "waiting";
this.error = "Waiting for this overlay to load inside OBS.";
this.currentScene = null;
this.scenes = [];
this.transitions = [];
this.currentTransition = null;
this.outputs = {};
this.controlLevel = 0;
this.pluginVersion = null;
this.canvasWidth = null;
this.canvasHeight = null;
this.sourceActive = null;
this.sourceVisible = null;
this.instanceId = null;
this.lastReportAt = 0;
this.sceneListeners = new Set();
this.closedListeners = new Set();
this.statusListeners = new Set();
this.staleTimer = null;
}
async connect() {
browserBridgeConnectors.set(this.overlayId, this);
return this.status();
}
async disconnect() {
if (browserBridgeConnectors.get(this.overlayId) === this) browserBridgeConnectors.delete(this.overlayId);
clearTimeout(this.staleTimer);
this.state = "disconnected";
}
onSceneChanged(listener) {
this.sceneListeners.add(listener);
return () => this.sceneListeners.delete(listener);
}
onClosed(listener) {
this.closedListeners.add(listener);
return () => this.closedListeners.delete(listener);
}
onStatusChanged(listener) {
this.statusListeners.add(listener);
return () => this.statusListeners.delete(listener);
}
notifyStatus() {
this.statusListeners.forEach((listener) => listener(this.status()));
}
acceptReport(report = {}) {
const now = Date.now();
const instanceId = String(report.instance_id || "").slice(0, 100);
if (!instanceId) throw new Error("Browser Bridge instance ID is missing.");
const currentIsFresh = this.instanceId && now - this.lastReportAt < 15000;
const shouldTakeOver = !currentIsFresh || this.instanceId === instanceId || (report.source_active === true && this.sourceActive !== true);
if (!shouldTakeOver) return { accepted: false, primary_instance_id: this.instanceId, ...this.status() };
const previousScene = this.currentScene;
this.instanceId = instanceId;
this.lastReportAt = now;
this.controlLevel = Math.min(5, Math.max(0, Math.round(Number(report.control_level) || 0)));
this.pluginVersion = String(report.plugin_version || "").slice(0, 80) || null;
this.currentScene = String(report.current_scene || "").slice(0, 200) || null;
this.scenes = cleanNames(report.scenes);
this.transitions = cleanNames(report.transitions);
this.currentTransition = String(report.current_transition || "").slice(0, 200) || null;
this.canvasWidth = Math.min(7680, Math.max(0, Math.round(Number(report.canvas_width) || 0))) || null;
this.canvasHeight = Math.min(7680, Math.max(0, Math.round(Number(report.canvas_height) || 0))) || null;
this.sourceActive = typeof report.source_active === "boolean" ? report.source_active : null;
this.sourceVisible = typeof report.source_visible === "boolean" ? report.source_visible : null;
this.outputs = report.outputs && typeof report.outputs === "object" ? {
streaming: Boolean(report.outputs.streaming),
recording: Boolean(report.outputs.recording),
recording_paused: Boolean(report.outputs.recordingPaused || report.outputs.recording_paused),
replay_buffer: Boolean(report.outputs.replaybuffer || report.outputs.replay_buffer),
virtual_camera: Boolean(report.outputs.virtualcam || report.outputs.virtual_camera)
} : {};
this.state = "connected";
this.error = this.controlLevel < 2
? "Choose at least “Read access to user information” in the Browser Source page permissions."
: (["overlay_to_obs", "bidirectional"].includes(this.settings.sync_direction) && this.controlLevel < 4
? "Advanced OBS access is required when Lumi should change OBS scenes."
: null);
clearTimeout(this.staleTimer);
this.staleTimer = setTimeout(() => {
this.state = "waiting";
this.error = "The OBS Browser Source stopped reporting. Keep it loaded and disable shutdown when hidden for persistent control.";
this.notifyStatus();
}, 20000);
if (this.currentScene && this.currentScene !== previousScene) {
this.sceneListeners.forEach((listener) => listener(this.currentScene));
}
this.notifyStatus();
return { accepted: true, primary_instance_id: this.instanceId, ...this.status() };
}
async listScenes() {
return this.scenes;
}
async getActiveScene() {
return this.currentScene;
}
async setActiveScene(sceneName) {
if (this.state !== "connected") throw new Error("The OBS Browser Bridge is not connected.");
if (this.controlLevel < 4) throw new Error("Advanced OBS page permission is required to change scenes.");
const commandId = `${Date.now()}:${Math.random().toString(16).slice(2)}`;
const delivered = publishWebEvent("overlay:obs-browser-command", {
command_id: commandId,
target_instance_id: this.instanceId,
action: "set_current_scene",
scene_name: String(sceneName || "").slice(0, 200)
}, { scope: `overlay:${this.overlayId}` });
if (!delivered) throw new Error("The OBS Browser Bridge is not listening for commands.");
}
status() {
return {
state: this.state,
error: this.error,
current_scene: this.currentScene,
scenes: this.scenes,
transitions: this.transitions,
current_transition: this.currentTransition,
outputs: this.outputs,
canvas_width: this.canvasWidth,
canvas_height: this.canvasHeight,
control_level: this.controlLevel,
control_level_label: OBS_BROWSER_CONTROL_LEVELS[this.controlLevel],
plugin_version: this.pluginVersion,
source_active: this.sourceActive,
source_visible: this.sourceVisible,
bridge_instance_id: this.instanceId,
last_report_at: this.lastReportAt || null,
profiles: [],
current_profile: null,
scene_collections: [],
current_scene_collection: null,
profiles_supported: false,
scene_collections_supported: false
};
}
}
registerOverlayConnectorProvider({
id: "local_obs_websocket",
label: "OBS WebSocket",
description: "Connect from Lumi to OBS on the streaming computer or private local network.",
create: (settings) => new LocalObsWebSocketConnector(settings)
});
registerOverlayConnectorProvider({
id: "obs_browser_bridge",
label: "OBS Browser Bridge",
description: "Use the Lumi Browser Source itself to read and synchronize OBS without a WebSocket password.",
create: (settings) => new ObsBrowserBridgeConnector(settings)
});
registerOverlayConnectorProvider({
id: "trusted_lumi_client",
label: "Trusted Lumi client (future)",
description: "Reserved provider interface for a future trusted client running beside OBS.",
available: false,
create() {
throw new Error("The trusted Lumi client provider is an extension point and is not implemented yet.");
}
});
class OverlayConnectorManager {
constructor() {
this.connections = new Map();
this.syncSuppressed = new Set();
this.stopped = false;
this.removeOverlayListener = null;
}
async start() {
this.stopped = false;
if (!this.removeOverlayListener) {
this.removeOverlayListener = onOverlayChanged((change) => this.handleOverlayChange(change));
}
for (const settings of listEnabledObsSettings()) {
await this.restart(settings.overlay_id).catch(() => {});
}
}
async stop() {
this.stopped = true;
this.removeOverlayListener?.();
this.removeOverlayListener = null;
const entries = Array.from(this.connections.values());
this.connections.clear();
await Promise.all(entries.map(async (entry) => {
clearTimeout(entry.reconnectTimer);
await entry.connector.disconnect().catch(() => {});
}));
}
async restart(overlayId) {
await this.disconnect(overlayId);
const settings = getObsSettings(overlayId, { includeSecret: true });
if (!settings.enabled) return this.status(overlayId);
const provider = providers.get(settings.provider);
if (!provider || provider.available === false) {
const error = provider ? "Connector provider is not implemented." : "Unknown connector provider.";
this.connections.set(overlayId, { settings, connector: null, error, state: "unavailable" });
this.publishStatus(overlayId);
return this.status(overlayId);
}
const entry = {
settings,
connector: provider.create(settings),
state: "connecting",
error: null,
reconnectAttempts: 0,
reconnectTimer: null,
lastOutboundScene: null,
lastOutboundAt: 0
};
this.connections.set(overlayId, entry);
entry.connector.onSceneChanged((sceneName) => this.handleObsScene(overlayId, sceneName));
entry.connector.onClosed(() => this.scheduleReconnect(overlayId));
entry.connector.onStatusChanged?.((status) => {
entry.state = status.state || entry.state;
entry.error = status.error || null;
this.publishStatus(overlayId);
});
this.publishStatus(overlayId);
try {
const connectedStatus = await entry.connector.connect();
entry.state = connectedStatus?.state || "connected";
entry.error = connectedStatus?.error || null;
entry.reconnectAttempts = 0;
this.publishStatus(overlayId);
if (entry.state === "connected") {
await this.handleObsScene(overlayId, await entry.connector.getActiveScene());
}
return this.status(overlayId, { includeScenes: true });
} catch (error) {
entry.state = "error";
entry.error = error?.message || "OBS connection failed.";
this.publishStatus(overlayId);
this.scheduleReconnect(overlayId);
return this.status(overlayId);
}
}
async disconnect(overlayId) {
const entry = this.connections.get(overlayId);
if (!entry) return;
this.connections.delete(overlayId);
clearTimeout(entry.reconnectTimer);
if (entry.connector) await entry.connector.disconnect().catch(() => {});
}
scheduleReconnect(overlayId) {
const entry = this.connections.get(overlayId);
if (!entry || this.stopped || !entry.settings.reconnect || entry.reconnectTimer) return;
entry.state = "disconnected";
entry.reconnectAttempts += 1;
const delay = Math.min(30000, 1000 * (2 ** Math.min(entry.reconnectAttempts, 5)));
entry.reconnectTimer = setTimeout(() => {
entry.reconnectTimer = null;
this.restart(overlayId).catch(() => {});
}, delay);
this.publishStatus(overlayId);
}
async handleObsScene(overlayId, obsSceneName) {
const entry = this.connections.get(overlayId);
if (!entry || !obsSceneName) return;
if (entry.lastOutboundScene === obsSceneName && Date.now() - entry.lastOutboundAt < 2500) return;
entry.lastOutboundScene = null;
if (!["obs_to_overlay", "bidirectional"].includes(entry.settings.sync_direction)) return;
const overlay = getOverlay(overlayId);
const match = overlay?.scenes.find((scene) => scene.enabled && scene.obs_scene_name === obsSceneName);
if (match && overlay.active_scene_id !== match.id) {
setActiveScene(overlayId, match.id, { source: "obs" });
}
this.publishStatus(overlayId);
}
async handleOverlayChange({ overlayId, source, deleted }) {
if (deleted) return this.disconnect(overlayId);
if (this.syncSuppressed.has(overlayId)) return;
const entry = this.connections.get(overlayId);
if (!entry?.connector || entry.connector.status?.().state !== "connected" || source === "obs") return;
if (!["overlay_to_obs", "bidirectional"].includes(entry.settings.sync_direction)) return;
const overlay = getOverlay(overlayId);
const scene = overlay?.scenes.find((item) => item.id === overlay.active_scene_id);
const target = scene?.obs_scene_name;
if (!target || entry.lastOutboundScene === target) return;
entry.lastOutboundScene = target;
entry.lastOutboundAt = Date.now();
try {
await entry.connector.setActiveScene(target);
entry.error = null;
} catch (error) {
entry.error = error?.message || "Could not change the OBS scene.";
}
this.publishStatus(overlayId);
}
async setObsScene(overlayId, sceneName) {
const entry = this.connections.get(overlayId);
if (!entry?.connector || entry.connector.status?.().state !== "connected") throw new Error("OBS is not connected.");
entry.lastOutboundScene = String(sceneName || "");
entry.lastOutboundAt = Date.now();
await entry.connector.setActiveScene(entry.lastOutboundScene);
this.publishStatus(overlayId);
return this.status(overlayId, { includeScenes: true });
}
async status(overlayId, { includeScenes = false } = {}) {
const entry = this.connections.get(overlayId);
if (!entry) {
const settings = getObsSettings(overlayId);
return { state: settings.enabled ? "disconnected" : "disabled", error: null, current_scene: null, scenes: [] };
}
const connectorStatus = entry.connector?.status?.() || {};
const result = {
...connectorStatus,
state: connectorStatus.state || entry.state || "disconnected",
error: connectorStatus.error || entry.error || null,
current_scene: connectorStatus.current_scene || null,
scenes: Array.isArray(connectorStatus.scenes) ? connectorStatus.scenes : []
};
if (includeScenes && entry.connector && result.state === "connected") {
try { result.scenes = await entry.connector.listScenes(); }
catch (error) { result.error = error?.message || "Could not read OBS scenes."; }
}
return result;
}
publishStatus(overlayId) {
Promise.resolve(this.status(overlayId)).then((status) => {
publishWebEvent("overlay:obs-status", { overlay_id: overlayId, ...status }, { role: "admin" });
});
}
async test(settings) {
if (settings.provider === "obs_browser_bridge") {
if (!settings.overlayId) throw new Error("Overlay ID is required for Browser Bridge testing.");
const entry = this.connections.get(settings.overlayId);
if (entry?.settings?.provider !== "obs_browser_bridge") {
throw new Error("Save the Browser Bridge connection before testing it.");
}
const status = await this.status(settings.overlayId, { includeScenes: true });
if (status.state !== "connected") throw new Error(status.error || "The Browser Bridge is waiting for OBS.");
return { ok: true, ...status };
}
const provider = providers.get(settings.provider);
if (!provider || provider.available === false) throw new Error("Connector provider is not available.");
const connector = provider.create(settings);
try {
await connector.connect();
return {
ok: true,
current_scene: await connector.getActiveScene(),
scenes: await connector.listScenes()
};
} finally {
await connector.disconnect().catch(() => {});
}
}
reportBrowserBridge(overlayId, report) {
const entry = this.connections.get(overlayId);
if (!entry?.connector || entry.settings.provider !== "obs_browser_bridge" || typeof entry.connector.acceptReport !== "function") {
return { accepted: false, enabled: false, state: "disabled" };
}
const result = entry.connector.acceptReport(report);
entry.state = result.state || entry.state;
entry.error = result.error || null;
this.publishStatus(overlayId);
return { enabled: true, ...result };
}
suppressSync(overlayId, suppressed = true) {
if (suppressed) this.syncSuppressed.add(overlayId);
else this.syncSuppressed.delete(overlayId);
}
}
const overlayConnectorManager = new OverlayConnectorManager();
module.exports = {
ObsBrowserBridgeConnector,
listOverlayConnectorProviders,
overlayConnectorManager,
registerOverlayConnectorProvider
};

View File

@ -0,0 +1,175 @@
const moduleTypes = new Map();
function number(value, fallback, min, max) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
function text(value, fallback = "", max = 2000) {
return String(value === undefined || value === null ? fallback : value).slice(0, max);
}
function color(value, fallback = "#ffffff") {
const normalized = text(value, fallback, 32).trim();
return /^(#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba?\([0-9.,%\s]+\)|transparent)$/i.test(normalized)
? normalized
: fallback;
}
function url(value, { allowEmpty = true } = {}) {
const normalized = text(value, "", 2048).trim();
if (!normalized && allowEmpty) return "";
try {
const parsed = new URL(normalized);
return ["http:", "https:"].includes(parsed.protocol) ? parsed.toString() : "";
} catch {
return "";
}
}
function commonConfig(input = {}) {
const anchors = [
"top-left", "top-center", "top-right",
"center-left", "center", "center-right",
"bottom-left", "bottom-center", "bottom-right"
];
return {
x: number(input.x, 0, 0, 100),
y: number(input.y, 0, 0, 100),
width: number(input.width, 100, 0.1, 100),
height: number(input.height, 100, 0.1, 100),
opacity: number(input.opacity, 1, 0, 1),
anchor: anchors.includes(input.anchor) ? input.anchor : "top-left"
};
}
function mediaConfig(input = {}, { includeFit = false } = {}) {
const playBehavior = ["once", "loop", "manual"].includes(input.playBehavior)
? input.playBehavior
: "once";
return {
...commonConfig(input),
url: url(input.url),
playBehavior,
muted: input.muted === true || input.muted === "on",
volume: number(input.volume, 1, 0, 1),
playbackRate: number(input.playbackRate, 1, 0.25, 4),
startAt: number(input.startAt, 0, 0, 86400),
showControls: playBehavior === "manual" || input.showControls === true || input.showControls === "on",
...(includeFit ? { fit: ["contain", "cover", "fill"].includes(input.fit) ? input.fit : "contain" } : {})
};
}
function registerOverlayModuleType(type) {
if (!type?.id || typeof type.normalize !== "function") {
throw new Error("Overlay module types require an id and normalize function.");
}
const renderType = type.renderType || type.id;
if (!["text", "image", "video", "audio", "web"].includes(renderType)) {
throw new Error("Overlay module renderType must be text, image, video, audio, or web.");
}
moduleTypes.set(type.id, Object.freeze({ ...type, renderType }));
return () => moduleTypes.delete(type.id);
}
function getOverlayModuleType(id) {
return moduleTypes.get(String(id || "")) || null;
}
function listOverlayModuleTypes() {
return Array.from(moduleTypes.values()).map(({ id, label, description }) => ({ id, label, description }));
}
function normalizeModuleConfig(typeId, input) {
const type = getOverlayModuleType(typeId);
if (!type) throw new Error("Unsupported overlay module type.");
return type.normalize(input || {});
}
registerOverlayModuleType({
id: "text",
label: "Text",
description: "Positioned text with configurable colors and sizing.",
normalize(input) {
return {
...commonConfig(input),
text: text(input.text, "New text", 4000),
fontSize: number(input.fontSize, 48, 8, 400),
fontWeight: number(input.fontWeight, 700, 100, 900),
color: color(input.color, "#ffffff"),
background: color(input.background, "transparent"),
horizontalAlign: ["left", "center", "right"].includes(input.horizontalAlign)
? input.horizontalAlign
: (["left", "center", "right"].includes(input.align) ? input.align : "left"),
verticalAlign: ["top", "center", "bottom"].includes(input.verticalAlign) ? input.verticalAlign : "center",
overflow: ["wrap", "shrink", "clip", "single-line", "scroll"].includes(input.overflow)
? input.overflow
: "wrap",
padding: number(input.padding, 0, 0, 200)
};
}
});
registerOverlayModuleType({
id: "image",
label: "Image",
description: "A responsive image from an HTTPS or HTTP URL.",
normalize(input) {
return {
...commonConfig(input),
url: url(input.url),
fit: ["contain", "cover", "fill"].includes(input.fit) ? input.fit : "contain"
};
}
});
registerOverlayModuleType({
id: "video",
label: "Video / media",
description: "Play a video file, including its audio track, with scene-aware playback controls.",
renderType: "video",
normalize(input) {
return mediaConfig(input, { includeFit: true });
}
});
registerOverlayModuleType({
id: "audio",
label: "Audio",
description: "Play an audio file through the OBS Browser Source.",
renderType: "audio",
normalize(input) {
return mediaConfig(input);
}
});
registerOverlayModuleType({
id: "web",
label: "Website or alert overlay",
description: "Add a third-party alert box, chat overlay, or other web page.",
normalize(input) {
return {
...commonConfig(input),
url: url(input.url),
allowPointerEvents: input.allowPointerEvents === true || input.allowPointerEvents === "on",
autoRefresh: input.autoRefresh === true || input.autoRefresh === "on",
healthCheck: input.healthCheck !== false && input.healthCheck !== "false" && input.healthCheck !== "off",
refreshIntervalSeconds: number(input.refreshIntervalSeconds, 300, 30, 3600),
retryLimit: Math.round(number(input.retryLimit, 3, 0, 5)),
cropTop: number(input.cropTop, 0, 0, 95),
cropRight: number(input.cropRight, 0, 0, 95),
cropBottom: number(input.cropBottom, 0, 0, 95),
cropLeft: number(input.cropLeft, 0, 0, 95),
zoom: number(input.zoom, 1, 0.1, 5),
customCss: text(input.customCss, "", 12000)
};
}
});
module.exports = {
getOverlayModuleType,
listOverlayModuleTypes,
normalizeModuleConfig,
registerOverlayModuleType
};

View File

@ -0,0 +1,57 @@
const overlayAccessProviders = [];
const OVERLAY_CAPABILITIES = Object.freeze({
MANAGE: "manage",
VIEW_SECRETS: "view_secrets",
CONTROL_SCENE: "control_scene",
MANAGE_OBS: "manage_obs"
});
function registerOverlayAccessProvider(provider) {
if (!provider || typeof provider.allows !== "function") {
throw new Error("Overlay access providers must expose allows(context).");
}
overlayAccessProviders.push(provider);
return () => {
const index = overlayAccessProviders.indexOf(provider);
if (index >= 0) overlayAccessProviders.splice(index, 1);
};
}
function canAccessOverlay(user, overlayId, capability = OVERLAY_CAPABILITIES.MANAGE) {
if (!user) return false;
if (user.isAdmin) return true;
// Deliberately deny by default. A future moderator-assignment provider can be
// registered here without weakening secret access or changing route checks.
if (capability === OVERLAY_CAPABILITIES.VIEW_SECRETS || capability === OVERLAY_CAPABILITIES.MANAGE_OBS) {
return false;
}
return overlayAccessProviders.some((provider) => {
try {
return provider.allows({ user, overlayId, capability }) === true;
} catch {
return false;
}
});
}
function requireOverlayCapability(capability, getOverlayId = (req) => req.params.id) {
return (req, res, next) => {
const overlayId = getOverlayId(req);
if (!canAccessOverlay(req.session?.user, overlayId, capability)) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that overlay."
});
}
next();
};
}
module.exports = {
OVERLAY_CAPABILITIES,
canAccessOverlay,
registerOverlayAccessProvider,
requireOverlayCapability
};

View File

@ -0,0 +1,565 @@
const dns = require("dns").promises;
const net = require("net");
const { subscribeWebEvents } = require("./web-events");
const {
OVERLAY_CAPABILITIES,
canAccessOverlay
} = require("./overlay-permissions");
const { getOverlayModuleType, listOverlayModuleTypes } = require("./overlay-modules");
const {
addModule,
addScene,
buildPublicState,
createOverlay,
deleteModule,
deleteOverlay,
deleteScene,
duplicateModule,
duplicateOverlay,
duplicateScene,
getObsSettings,
getOverlay,
listOverlays,
regenerateOverlayToken,
regenerateSceneToken,
refreshModule,
reorderModules,
reorderOverlays,
reorderScenes,
resolvePublicOverlay,
saveObsSettings,
setActiveScene,
updateModule,
updateModuleTransform,
updateOverlay,
updateScene
} = require("./overlays");
const {
listOverlayConnectorProviders,
overlayConnectorManager
} = require("./overlay-connectors");
const externalHealthCache = new Map();
function isPrivateAddress(address) {
if (!address) return true;
if (net.isIPv4(address)) {
const parts = address.split(".").map(Number);
return parts[0] === 10 || parts[0] === 127 || parts[0] === 0 ||
(parts[0] === 169 && parts[1] === 254) ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] === 192 && parts[1] === 168);
}
const normalized = String(address).toLowerCase();
return normalized === "::1" || normalized === "::" || normalized.startsWith("fc") ||
normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") ||
normalized.startsWith("fea") || normalized.startsWith("feb") || normalized.startsWith("::ffff:127.");
}
async function isPrivateHealthTarget(url) {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
if (net.isIP(hostname)) return isPrivateAddress(hostname);
const addresses = await dns.lookup(hostname, { all: true, verbatim: true });
return !addresses.length || addresses.some((entry) => isPrivateAddress(entry.address));
}
async function fetchPublicOverlay(url, signal, redirects = 0) {
if (redirects > 4) throw new Error("Too many redirects.");
if (await isPrivateHealthTarget(url)) return null;
const response = await fetch(url, {
method: "GET",
redirect: "manual",
signal,
headers: {
Accept: "text/html,application/xhtml+xml,text/plain;q=0.8,*/*;q=0.2",
"User-Agent": "Lumi-Overlay-Health/1.0"
}
});
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
await response.body?.cancel?.().catch(() => {});
const nextUrl = new URL(response.headers.get("location"), url).toString();
return fetchPublicOverlay(nextUrl, signal, redirects + 1);
}
return response;
}
async function probeExternalOverlay(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetchPublicOverlay(url, controller.signal);
if (!response) {
return {
healthy: true,
skipped: true,
status: null,
reason: "Private and local website addresses are refreshed but not probed by Lumi."
};
}
if (!response.ok) {
response.body?.cancel?.().catch?.(() => {});
return { healthy: false, status: response.status, reason: `The website returned HTTP ${response.status}.` };
}
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
if (contentType && !/(html|xhtml|text|javascript|json|svg)/i.test(contentType)) {
response.body?.cancel?.().catch?.(() => {});
return { healthy: false, status: response.status, reason: "The website returned an unexpected content type." };
}
const reader = response.body?.getReader?.();
let sample = "";
if (reader) {
const decoder = new TextDecoder();
while (sample.length < 65536) {
const { done, value } = await reader.read();
if (done) break;
sample += decoder.decode(value, { stream: true });
}
await reader.cancel().catch(() => {});
}
if (!sample.trim()) {
return { healthy: false, status: response.status, reason: "The website returned an empty reply." };
}
if (contentType.includes("html") && !/<(?:!doctype|html|head|body|script|style|div|canvas|svg)\b/i.test(sample)) {
return { healthy: false, status: response.status, reason: "The website reply did not look like a usable overlay page." };
}
return {
healthy: true,
status: response.status,
reason: null,
checked_at: Date.now(),
etag: response.headers.get("etag") || null,
last_modified: response.headers.get("last-modified") || null
};
} catch (error) {
return {
healthy: false,
status: null,
reason: error?.name === "AbortError" ? "The website took too long to reply." : "The website could not be reached."
};
} finally {
clearTimeout(timeout);
}
}
function noStore(res) {
res.set("Cache-Control", "no-store, private");
res.set("Pragma", "no-cache");
}
function publicOverlayContext(req) {
return resolvePublicOverlay(req.params.overlayToken, req.params.sceneToken || null);
}
function registerPublicOverlayRoutes(app) {
const registerVariant = (basePath, fixed) => {
app.post(`${basePath}/obs-bridge`, (req, res) => {
noStore(res);
const context = publicOverlayContext(req);
if (!context || !context.overlay.enabled || (fixed && !context.fixedScene?.enabled)) {
return res.status(404).json({ accepted: false, error: "Overlay unavailable." });
}
try {
const result = overlayConnectorManager.reportBrowserBridge(context.overlay.id, req.body || {});
return res.json({
accepted: Boolean(result.accepted),
enabled: Boolean(result.enabled),
state: result.state || "disabled"
});
} catch (error) {
return res.status(400).json({ accepted: false, error: error.message });
}
});
app.get(`${basePath}/module-health`, async (req, res) => {
noStore(res);
const context = publicOverlayContext(req);
if (!context) return res.status(404).json({ healthy: false, reason: "Overlay unavailable." });
const state = buildPublicState(context.overlay.id, fixed ? context.fixedScene.id : null);
const module = state.scene?.modules?.find((item) => item.id === req.query.module_id && item.renderType === "web");
if (!module?.config?.url) return res.status(404).json({ healthy: false, reason: "Website source unavailable." });
const cacheKey = `${context.overlay.id}:${module.id}`;
const cached = externalHealthCache.get(cacheKey);
if (cached && Date.now() - cached.at < 15000) return res.json(cached.result);
const result = await probeExternalOverlay(module.config.url);
externalHealthCache.set(cacheKey, { at: Date.now(), result });
return res.status(result.healthy ? 200 : 502).json(result);
});
app.get(`${basePath}/state`, (req, res) => {
noStore(res);
const context = publicOverlayContext(req);
if (!context) return res.status(404).json({ exists: false, enabled: false, scene: null });
return res.json(buildPublicState(context.overlay.id, fixed ? context.fixedScene.id : null));
});
app.get(`${basePath}/events`, (req, res) => {
const context = publicOverlayContext(req);
if (!context) return res.status(404).end();
return subscribeWebEvents(req, res, {
scopes: [`overlay:${context.overlay.id}`],
initialEvents: [{ event: "overlay:changed", payload: { revision: context.overlay.updated_at } }]
});
});
app.get(basePath, (req, res) => {
noStore(res);
const context = publicOverlayContext(req);
if (!context || !context.overlay.enabled || (fixed && !context.fixedScene.enabled)) {
return res.status(404).send("Overlay unavailable.");
}
const statePath = `${req.path.replace(/\/$/, "")}/state`;
const eventsPath = `${req.path.replace(/\/$/, "")}/events`;
const healthPath = `${req.path.replace(/\/$/, "")}/module-health`;
const bridgePath = `${req.path.replace(/\/$/, "")}/obs-bridge`;
res.set("Content-Security-Policy", "default-src 'none'; img-src https: http: data:; frame-src https: http:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';");
return res.render("overlay-render", {
statePath,
eventsPath,
healthPath,
bridgePath,
initialStateJson: JSON.stringify(buildPublicState(context.overlay.id, fixed ? context.fixedScene.id : null)).replace(/</g, "\\u003c")
});
});
};
registerVariant("/overlay/:overlayToken/scene/:sceneToken", true);
registerVariant("/overlay/:overlayToken", false);
}
function requireOverlayAccess(capability = OVERLAY_CAPABILITIES.MANAGE) {
return (req, res, next) => {
if (!canAccessOverlay(req.session?.user, req.params.id, capability)) {
if (req.path.startsWith("/api/")) return res.status(403).json({ error: "Access denied." });
return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that overlay." });
}
next();
};
}
function requireSceneInOverlay(req, res, next) {
const overlay = getOverlay(req.params.id);
if (!overlay?.scenes.some((scene) => scene.id === req.params.sceneId)) {
if (req.path.startsWith("/api/")) return res.status(404).json({ error: "Scene not found." });
return res.status(404).render("error", { title: "Scene not found", message: "That scene is not part of this overlay." });
}
next();
}
function requireModuleInOverlay(req, res, next) {
const overlay = getOverlay(req.params.id);
if (!overlay?.scenes.some((scene) => scene.modules.some((module) => module.id === req.params.moduleId))) {
if (req.path.startsWith("/api/")) return res.status(404).json({ error: "Source not found." });
return res.status(404).render("error", { title: "Source not found", message: "That source is not part of this overlay." });
}
next();
}
function adminOnly(req, res, next) {
if (!req.session?.user?.isAdmin) {
if (req.path.startsWith("/api/")) return res.status(403).json({ error: "Access denied." });
return res.status(403).render("error", { title: "Access denied", message: "Administrator access is required." });
}
next();
}
function flash(req, type, message) {
req.session.flash = { type, message };
}
function redirectWithResult(req, res, pathValue, work, success) {
try {
const value = work();
flash(req, "success", success);
return res.redirect(pathValue);
} catch (error) {
flash(req, "error", error.message);
return res.redirect(pathValue);
}
}
function booleanField(body, name) {
return body[name] === true || body[name] === "true" || body[name] === "on" || body[name] === "1";
}
function orderedIds(body) {
if (Array.isArray(body.ids)) return body.ids;
if (typeof body.ids === "string") {
try {
const parsed = JSON.parse(body.ids);
if (Array.isArray(parsed)) return parsed;
} catch {}
return body.ids.split(",").map((id) => id.trim()).filter(Boolean);
}
return [];
}
function moduleInput(body) {
return {
name: body.name,
type: body.type,
enabled: booleanField(body, "enabled"),
config: {
text: body.text,
url: body.url,
x: body.x,
y: body.y,
width: body.width,
height: body.height,
opacity: body.opacity,
fontSize: body.font_size,
fontWeight: body.font_weight,
color: body.color,
background: body.background,
align: body.align,
padding: body.padding,
fit: body.fit,
playBehavior: body.play_behavior,
muted: booleanField(body, "muted"),
volume: body.volume,
playbackRate: body.playback_rate,
startAt: body.start_at,
showControls: booleanField(body, "show_controls"),
allowPointerEvents: booleanField(body, "allow_pointer_events"),
anchor: body.anchor,
horizontalAlign: body.horizontal_align,
verticalAlign: body.vertical_align,
overflow: body.text_overflow,
autoRefresh: booleanField(body, "auto_refresh"),
healthCheck: booleanField(body, "health_check"),
refreshIntervalSeconds: body.refresh_interval_seconds,
retryLimit: body.retry_limit,
cropTop: body.crop_top,
cropRight: body.crop_right,
cropBottom: body.crop_bottom,
cropLeft: body.crop_left,
zoom: body.zoom,
customCss: body.custom_css
}
};
}
function baseUrl(req) {
return `${req.protocol}://${req.get("host")}`;
}
function registerOverlayAdminRoutes(app) {
app.get("/admin/overlays", adminOnly, (req, res) => {
noStore(res);
res.render("admin-overlays", {
title: "OBS overlays",
overlays: listOverlays({ includeSecrets: true }),
baseUrl: baseUrl(req)
});
});
app.post("/admin/overlays", adminOnly, (req, res) => redirectWithResult(
req,
res,
"/admin/overlays",
() => createOverlay({ name: req.body.name, description: req.body.description, sceneName: req.body.scene_name || "Main" }),
"Overlay created."
));
app.post("/admin/overlays/reorder", adminOnly, (req, res) => redirectWithResult(req, res, "/admin/overlays", () => reorderOverlays(orderedIds(req.body)), "Overlay order saved."));
app.get("/admin/overlays/:id", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE), async (req, res) => {
noStore(res);
const canViewSecrets = canAccessOverlay(req.session.user, req.params.id, OVERLAY_CAPABILITIES.VIEW_SECRETS);
const canManageObs = canAccessOverlay(req.session.user, req.params.id, OVERLAY_CAPABILITIES.MANAGE_OBS);
const overlay = getOverlay(req.params.id, { includeSecrets: canViewSecrets });
if (!overlay) return res.status(404).render("error", { title: "Overlay not found", message: "That overlay does not exist." });
res.render("admin-overlay-detail", {
title: overlay.name,
overlay,
canViewSecrets,
canManageObs,
baseUrl: baseUrl(req),
moduleTypes: listOverlayModuleTypes(),
editorStateJson: JSON.stringify({
overlayId: overlay.id,
canvas: { width: overlay.canvas_width || 1920, height: overlay.canvas_height || 1080 },
activeSceneId: overlay.active_scene_id,
scenes: overlay.scenes.map((scene) => ({
id: scene.id,
name: scene.name,
enabled: scene.enabled,
modules: scene.modules.map((module) => ({
id: module.id,
name: module.name,
type: module.type,
renderType: getOverlayModuleType(module.type)?.renderType || module.type,
enabled: module.enabled,
config: module.config
}))
}))
}).replace(/</g, "\\u003c"),
connectorProviders: canManageObs ? listOverlayConnectorProviders() : [],
obsStatus: canManageObs
? await overlayConnectorManager.status(overlay.id, { includeScenes: true })
: { state: "restricted", current_scene: null, error: null, scenes: [] }
});
});
app.post("/admin/overlays/:id", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateOverlay(req.params.id, {
name: req.body.name,
description: req.body.description,
enabled: booleanField(req.body, "enabled"),
canvasWidth: req.body.canvas_width,
canvasHeight: req.body.canvas_height
}), "Overlay saved."));
app.post("/admin/overlays/:id/duplicate", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, "/admin/overlays", () => duplicateOverlay(req.params.id, req.body.name), "Overlay duplicated with new private URLs."));
app.post("/admin/overlays/:id/delete", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, "/admin/overlays", () => deleteOverlay(req.params.id), "Overlay deleted."));
app.post("/admin/overlays/:id/token/revoke", requireOverlayAccess(OVERLAY_CAPABILITIES.VIEW_SECRETS), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => regenerateOverlayToken(req.params.id), "All Browser Source URLs for this overlay were regenerated. The old URLs are revoked."));
app.post("/admin/overlays/:id/active-scene", requireOverlayAccess(OVERLAY_CAPABILITIES.CONTROL_SCENE), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => setActiveScene(req.params.id, req.body.scene_id, { source: "admin" }), "Active overlay scene changed."));
app.post("/admin/overlays/:id/scenes", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => addScene(req.params.id, { name: req.body.name, obsSceneName: req.body.obs_scene_name }), "Scene created."));
app.post("/admin/overlays/:id/scenes/reorder", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => reorderScenes(req.params.id, orderedIds(req.body)), "Scene order saved."));
app.post("/admin/overlays/:id/scenes/:sceneId", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateScene(req.params.sceneId, { name: req.body.name, enabled: booleanField(req.body, "enabled"), obsSceneName: req.body.obs_scene_name }), "Scene saved."));
app.post("/admin/overlays/:id/scenes/:sceneId/duplicate", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => duplicateScene(req.params.sceneId, req.body.name), "Scene duplicated with a new private URL."));
app.post("/admin/overlays/:id/scenes/:sceneId/delete", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => deleteScene(req.params.sceneId), "Scene deleted."));
app.post("/admin/overlays/:id/scenes/:sceneId/token/revoke", requireOverlayAccess(OVERLAY_CAPABILITIES.VIEW_SECRETS), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => regenerateSceneToken(req.params.sceneId), "Fixed scene URL regenerated. The old URL is revoked."));
app.post("/admin/overlays/:id/scenes/:sceneId/modules", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => addModule(req.params.sceneId, moduleInput(req.body)), "Source added."));
app.post("/admin/overlays/:id/scenes/:sceneId/modules/reorder", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => reorderModules(req.params.sceneId, orderedIds(req.body)), "Layer order saved."));
app.post("/admin/overlays/:id/modules/:moduleId", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateModule(req.params.moduleId, moduleInput(req.body)), "Source saved."));
app.post("/admin/overlays/:id/modules/:moduleId/duplicate", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => duplicateModule(req.params.moduleId, req.body.name), "Source copied."));
app.post("/admin/overlays/:id/modules/:moduleId/delete", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => deleteModule(req.params.moduleId), "Source deleted."));
app.post("/api/admin/overlays/:id/modules/:moduleId/transform", requireOverlayAccess(), requireModuleInOverlay, (req, res) => {
noStore(res);
try {
res.json({ ok: true, module: updateModuleTransform(req.params.moduleId, req.body || {}) });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/api/admin/overlays/:id/modules/:moduleId/refresh", requireOverlayAccess(), requireModuleInOverlay, (req, res) => {
noStore(res);
try {
refreshModule(req.params.moduleId);
res.json({ ok: true, message: "Source refresh requested." });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.get("/api/admin/overlays/:id/modules/:moduleId/health", requireOverlayAccess(), requireModuleInOverlay, async (req, res) => {
noStore(res);
const overlay = getOverlay(req.params.id);
const module = overlay?.scenes.flatMap((scene) => scene.modules).find((item) => item.id === req.params.moduleId);
if (!module || module.type !== "web" || !module.config?.url) {
return res.status(404).json({ ok: false, healthy: false, reason: "Website source unavailable." });
}
const result = await probeExternalOverlay(module.config.url);
return res.json({ ok: true, ...result });
});
app.post("/admin/overlays/:id/obs", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
try {
saveObsSettings(req.params.id, {
enabled: booleanField(req.body, "enabled"),
provider: req.body.provider,
endpoint: req.body.endpoint,
password: req.body.password,
clearPassword: booleanField(req.body, "clear_password"),
syncDirection: req.body.sync_direction,
reconnect: booleanField(req.body, "reconnect")
});
await overlayConnectorManager.restart(req.params.id);
flash(req, "success", "OBS connector settings saved.");
} catch (error) {
flash(req, "error", error.message);
}
res.redirect(`/admin/overlays/${req.params.id}`);
});
app.get("/api/admin/overlays/:id/obs/status", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
noStore(res);
res.json({ ok: true, ...(await overlayConnectorManager.status(req.params.id, { includeScenes: true })) });
});
app.post("/api/admin/overlays/:id/obs/test", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
noStore(res);
try {
const existing = getObsSettings(req.params.id, { includeSecret: true });
const result = await overlayConnectorManager.test({
overlayId: req.params.id,
provider: req.body.provider || existing.provider,
endpoint: req.body.endpoint || existing.endpoint,
password: req.body.password || existing.password
});
res.json(result);
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/api/admin/overlays/:id/obs/scene", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
noStore(res);
try {
res.json({ ok: true, ...(await overlayConnectorManager.setObsScene(req.params.id, req.body.scene_name)) });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/api/admin/overlays/:id/obs/import", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
noStore(res);
let syncSuppressed = false;
try {
const status = await overlayConnectorManager.status(req.params.id, { includeScenes: true });
if (status.state !== "connected" || !status.scenes?.length) {
throw new Error("Connect OBS with scene-reading permission before importing its setup.");
}
overlayConnectorManager.suppressSync(req.params.id, true);
syncSuppressed = true;
let overlay = getOverlay(req.params.id);
let created = 0;
let mapped = 0;
for (const obsSceneName of status.scenes) {
if (overlay.scenes.some((scene) => scene.obs_scene_name === obsSceneName)) continue;
const sameName = overlay.scenes.find((scene) => !scene.obs_scene_name && scene.name.toLowerCase() === obsSceneName.toLowerCase());
if (sameName) {
updateScene(sameName.id, { name: sameName.name, enabled: sameName.enabled, obsSceneName });
mapped += 1;
} else {
addScene(req.params.id, { name: obsSceneName, obsSceneName });
created += 1;
}
overlay = getOverlay(req.params.id);
}
if (status.canvas_width && status.canvas_height) {
updateOverlay(req.params.id, {
name: overlay.name,
description: overlay.description,
enabled: overlay.enabled,
canvasWidth: status.canvas_width,
canvasHeight: status.canvas_height
});
}
overlay = getOverlay(req.params.id);
const current = overlay.scenes.find((scene) => scene.obs_scene_name === status.current_scene && scene.enabled);
if (current && current.id !== overlay.active_scene_id) setActiveScene(req.params.id, current.id, { source: "obs" });
return res.json({
ok: true,
created,
mapped,
canvas_updated: Boolean(status.canvas_width && status.canvas_height),
message: `OBS setup applied: ${created} scene${created === 1 ? "" : "s"} added, ${mapped} existing scene${mapped === 1 ? "" : "s"} matched.`
});
} catch (error) {
return res.status(400).json({ ok: false, error: error.message });
} finally {
if (syncSuppressed) overlayConnectorManager.suppressSync(req.params.id, false);
}
});
}
module.exports = {
registerOverlayAdminRoutes,
registerPublicOverlayRoutes
};

View File

@ -0,0 +1,62 @@
const crypto = require("crypto");
const { getSetting } = require("./settings");
function encryptionKey() {
const secret = getSetting("session_secret");
if (!secret) {
throw new Error("Lumi's session secret is not initialized.");
}
return crypto.createHash("sha256").update(`lumi-overlay:${secret}`).digest();
}
function generateToken() {
return crypto.randomBytes(32).toString("base64url");
}
function tokenHash(token) {
return crypto.createHash("sha256").update(String(token || "")).digest("hex");
}
function encryptSecret(value) {
if (value === null || value === undefined || value === "") return null;
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey(), iv);
const encrypted = Buffer.concat([cipher.update(String(value), "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1.${iv.toString("base64url")}.${tag.toString("base64url")}.${encrypted.toString("base64url")}`;
}
function decryptSecret(value) {
if (!value) return "";
const [version, ivValue, tagValue, encryptedValue] = String(value).split(".");
if (version !== "v1" || !ivValue || !tagValue || !encryptedValue) {
throw new Error("Stored secret has an unsupported format.");
}
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
encryptionKey(),
Buffer.from(ivValue, "base64url")
);
decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
return Buffer.concat([
decipher.update(Buffer.from(encryptedValue, "base64url")),
decipher.final()
]).toString("utf8");
}
function createStoredToken() {
const token = generateToken();
return {
token,
hash: tokenHash(token),
encrypted: encryptSecret(token)
};
}
module.exports = {
createStoredToken,
decryptSecret,
encryptSecret,
generateToken,
tokenHash
};

542
src/services/overlays.js Normal file
View File

@ -0,0 +1,542 @@
const crypto = require("crypto");
const EventEmitter = require("events");
const { db } = require("./db");
const { publishWebEvent } = require("./web-events");
const { getOverlayModuleType, normalizeModuleConfig } = require("./overlay-modules");
const {
createStoredToken,
decryptSecret,
encryptSecret,
tokenHash
} = require("./overlay-secrets");
const overlayChanges = new EventEmitter();
overlayChanges.setMaxListeners(100);
function requiredName(value, label = "Name") {
const normalized = String(value || "").trim().slice(0, 120);
if (!normalized) throw new Error(`${label} is required.`);
return normalized;
}
function canvasDimension(value, fallback) {
const parsed = Math.round(Number(value));
return Number.isFinite(parsed) ? Math.min(7680, Math.max(240, parsed)) : fallback;
}
function parseConfig(value) {
try {
const parsed = typeof value === "string" ? JSON.parse(value || "{}") : value;
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function nextOrder(table, parentColumn = null, parentId = null) {
const allowed = {
overlays: null,
overlay_scenes: "overlay_id",
overlay_modules: "scene_id"
};
if (!(table in allowed) || allowed[table] !== parentColumn) throw new Error("Invalid ordering target.");
const where = parentColumn ? ` WHERE ${parentColumn} = ?` : "";
const row = db.prepare(`SELECT COALESCE(MAX(sort_order), -1) + 1 AS value FROM ${table}${where}`)
.get(...(parentColumn ? [parentId] : []));
return Number(row?.value || 0);
}
function touchOverlay(overlayId, at = Date.now()) {
db.prepare("UPDATE overlays SET updated_at = ? WHERE id = ?").run(at, overlayId);
}
function overlayIdForScene(sceneId) {
return db.prepare("SELECT overlay_id FROM overlay_scenes WHERE id = ?").get(sceneId)?.overlay_id || null;
}
function overlayIdForModule(moduleId) {
return db.prepare(
"SELECT s.overlay_id FROM overlay_modules m JOIN overlay_scenes s ON s.id = m.scene_id WHERE m.id = ?"
).get(moduleId)?.overlay_id || null;
}
function notifyOverlayChanged(overlayId, source = "server") {
const overlay = db.prepare("SELECT updated_at FROM overlays WHERE id = ?").get(overlayId);
if (!overlay) return;
publishWebEvent("overlay:changed", { revision: overlay.updated_at }, { scope: `overlay:${overlayId}` });
overlayChanges.emit("changed", { overlayId, source, revision: overlay.updated_at });
}
function rowWithToken(row, includeSecrets) {
if (!row) return null;
const result = { ...row, enabled: Boolean(row.enabled) };
delete result.public_token_hash;
delete result.public_token_encrypted;
if (includeSecrets) result.public_token = decryptSecret(row.public_token_encrypted);
return result;
}
function listOverlays({ includeSecrets = false } = {}) {
return db.prepare(
`SELECT o.*,
(SELECT COUNT(*) FROM overlay_scenes s WHERE s.overlay_id = o.id) AS scene_count,
(SELECT COUNT(*) FROM overlay_modules m JOIN overlay_scenes s ON s.id = m.scene_id WHERE s.overlay_id = o.id) AS module_count,
(SELECT name FROM overlay_scenes s WHERE s.id = o.active_scene_id) AS active_scene_name
FROM overlays o ORDER BY o.sort_order, o.created_at`
).all().map((row) => rowWithToken(row, includeSecrets));
}
function getOverlay(id, { includeSecrets = false } = {}) {
const row = db.prepare("SELECT * FROM overlays WHERE id = ?").get(id);
if (!row) return null;
const overlay = rowWithToken(row, includeSecrets);
overlay.scenes = db.prepare("SELECT * FROM overlay_scenes WHERE overlay_id = ? ORDER BY sort_order, created_at")
.all(id)
.map((scene) => {
const result = rowWithToken(scene, includeSecrets);
result.modules = db.prepare("SELECT * FROM overlay_modules WHERE scene_id = ? ORDER BY sort_order, created_at")
.all(scene.id)
.map((module) => ({
...module,
enabled: Boolean(module.enabled),
config: parseConfig(module.config_json)
}));
return result;
});
overlay.active_scene_id = overlay.active_scene_id || overlay.scenes[0]?.id || null;
overlay.obs = getObsSettings(id, { includeSecret: includeSecrets });
return overlay;
}
function createOverlay(input = {}) {
const overlayToken = createStoredToken();
const sceneToken = createStoredToken();
const now = Date.now();
const overlayId = crypto.randomUUID();
const sceneId = crypto.randomUUID();
const create = db.transaction(() => {
db.prepare(
"INSERT INTO overlays (id, name, description, enabled, canvas_width, canvas_height, sort_order, active_scene_id, public_token_hash, public_token_encrypted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(
overlayId,
requiredName(input.name, "Overlay name"),
String(input.description || "").trim().slice(0, 1000),
input.enabled === false ? 0 : 1,
canvasDimension(input.canvasWidth, 1920),
canvasDimension(input.canvasHeight, 1080),
nextOrder("overlays"),
sceneId,
overlayToken.hash,
overlayToken.encrypted,
now,
now
);
db.prepare(
"INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, 1, 0, ?, ?, NULL, ?, ?)"
).run(sceneId, overlayId, requiredName(input.sceneName || "Main", "Scene name"), sceneToken.hash, sceneToken.encrypted, now, now);
});
create();
notifyOverlayChanged(overlayId, "create");
return getOverlay(overlayId, { includeSecrets: true });
}
function updateOverlay(id, input = {}) {
const existing = db.prepare("SELECT * FROM overlays WHERE id = ?").get(id);
if (!existing) throw new Error("Overlay not found.");
const now = Date.now();
db.prepare("UPDATE overlays SET name = ?, description = ?, enabled = ?, canvas_width = ?, canvas_height = ?, updated_at = ? WHERE id = ?")
.run(
requiredName(input.name === undefined ? existing.name : input.name, "Overlay name"),
String(input.description === undefined ? existing.description : input.description).trim().slice(0, 1000),
input.enabled === undefined ? existing.enabled : input.enabled ? 1 : 0,
canvasDimension(input.canvasWidth, existing.canvas_width || 1920),
canvasDimension(input.canvasHeight, existing.canvas_height || 1080),
now,
id
);
notifyOverlayChanged(id, "overlay_update");
return getOverlay(id);
}
function duplicateOverlay(id, name) {
const source = getOverlay(id);
if (!source) throw new Error("Overlay not found.");
const copy = createOverlay({
name: name || `${source.name} copy`,
description: source.description,
enabled: source.enabled,
canvasWidth: source.canvas_width,
canvasHeight: source.canvas_height,
sceneName: source.scenes[0]?.name || "Main"
});
const copyId = copy.id;
const clone = db.transaction(() => {
db.prepare("DELETE FROM overlay_modules WHERE scene_id IN (SELECT id FROM overlay_scenes WHERE overlay_id = ?)").run(copyId);
db.prepare("DELETE FROM overlay_scenes WHERE overlay_id = ?").run(copyId);
let firstSceneId = null;
for (const scene of source.scenes) {
const newSceneId = crypto.randomUUID();
const storedToken = createStoredToken();
if (!firstSceneId) firstSceneId = newSceneId;
db.prepare(
"INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(newSceneId, copyId, scene.name, scene.enabled ? 1 : 0, scene.sort_order, storedToken.hash, storedToken.encrypted, scene.obs_scene_name, Date.now(), Date.now());
for (const module of scene.modules) {
db.prepare(
"INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(crypto.randomUUID(), newSceneId, module.type, module.name, module.enabled ? 1 : 0, module.sort_order, module.config_json, Date.now(), Date.now());
}
}
db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(firstSceneId, Date.now(), copyId);
});
clone();
notifyOverlayChanged(copyId, "duplicate");
return getOverlay(copyId, { includeSecrets: true });
}
function reorderRows(table, ids, parentColumn = null, parentId = null) {
const allowed = {
overlays: { parent: null, idQuery: "SELECT id FROM overlays" },
overlay_scenes: { parent: "overlay_id", idQuery: "SELECT id FROM overlay_scenes WHERE overlay_id = ?" },
overlay_modules: { parent: "scene_id", idQuery: "SELECT id FROM overlay_modules WHERE scene_id = ?" }
};
const target = allowed[table];
if (!target || target.parent !== parentColumn) throw new Error("Invalid reorder target.");
const existing = db.prepare(target.idQuery).all(...(parentColumn ? [parentId] : [])).map((row) => row.id);
const requested = Array.isArray(ids) ? ids.map(String) : [];
if (requested.length !== existing.length || new Set(requested).size !== existing.length || existing.some((id) => !requested.includes(id))) {
throw new Error("Reorder list must contain every item exactly once.");
}
const update = db.prepare(`UPDATE ${table} SET sort_order = ?, updated_at = ? WHERE id = ?`);
db.transaction(() => requested.forEach((id, index) => update.run(index, Date.now(), id)))();
}
function reorderOverlays(ids) {
reorderRows("overlays", ids);
ids.forEach((id) => notifyOverlayChanged(id, "reorder"));
}
function deleteOverlay(id) {
const exists = db.prepare("SELECT id FROM overlays WHERE id = ?").get(id);
if (!exists) throw new Error("Overlay not found.");
db.transaction(() => {
db.prepare("DELETE FROM overlay_modules WHERE scene_id IN (SELECT id FROM overlay_scenes WHERE overlay_id = ?)").run(id);
db.prepare("DELETE FROM overlay_scenes WHERE overlay_id = ?").run(id);
db.prepare("DELETE FROM overlay_obs_settings WHERE overlay_id = ?").run(id);
db.prepare("DELETE FROM overlays WHERE id = ?").run(id);
})();
publishWebEvent("overlay:changed", { deleted: true, revision: Date.now() }, { scope: `overlay:${id}` });
overlayChanges.emit("changed", { overlayId: id, source: "delete", deleted: true, revision: Date.now() });
}
function regenerateOverlayToken(id) {
const token = createStoredToken();
const result = db.prepare("UPDATE overlays SET public_token_hash = ?, public_token_encrypted = ?, updated_at = ? WHERE id = ?")
.run(token.hash, token.encrypted, Date.now(), id);
if (!result.changes) throw new Error("Overlay not found.");
notifyOverlayChanged(id, "token_regenerated");
return token.token;
}
function addScene(overlayId, input = {}) {
if (!db.prepare("SELECT id FROM overlays WHERE id = ?").get(overlayId)) throw new Error("Overlay not found.");
const token = createStoredToken();
const id = crypto.randomUUID();
const now = Date.now();
db.prepare(
"INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(
id,
overlayId,
requiredName(input.name, "Scene name"),
input.enabled === false ? 0 : 1,
nextOrder("overlay_scenes", "overlay_id", overlayId),
token.hash,
token.encrypted,
String(input.obsSceneName || "").trim().slice(0, 256) || null,
now,
now
);
touchOverlay(overlayId, now);
notifyOverlayChanged(overlayId, "scene_create");
return getOverlay(overlayId, { includeSecrets: true }).scenes.find((scene) => scene.id === id);
}
function updateScene(sceneId, input = {}) {
const scene = db.prepare("SELECT * FROM overlay_scenes WHERE id = ?").get(sceneId);
if (!scene) throw new Error("Scene not found.");
const now = Date.now();
db.prepare("UPDATE overlay_scenes SET name = ?, enabled = ?, obs_scene_name = ?, updated_at = ? WHERE id = ?")
.run(
requiredName(input.name === undefined ? scene.name : input.name, "Scene name"),
input.enabled === undefined ? scene.enabled : input.enabled ? 1 : 0,
String(input.obsSceneName === undefined ? scene.obs_scene_name || "" : input.obsSceneName).trim().slice(0, 256) || null,
now,
sceneId
);
touchOverlay(scene.overlay_id, now);
notifyOverlayChanged(scene.overlay_id, "scene_update");
}
function duplicateScene(sceneId, name) {
const source = db.prepare("SELECT * FROM overlay_scenes WHERE id = ?").get(sceneId);
if (!source) throw new Error("Scene not found.");
const scene = addScene(source.overlay_id, { name: name || `${source.name} copy`, enabled: Boolean(source.enabled), obsSceneName: source.obs_scene_name });
const modules = db.prepare("SELECT * FROM overlay_modules WHERE scene_id = ? ORDER BY sort_order, created_at").all(sceneId);
const insert = db.prepare(
"INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
db.transaction(() => modules.forEach((module) => insert.run(crypto.randomUUID(), scene.id, module.type, module.name, module.enabled, module.sort_order, module.config_json, Date.now(), Date.now())))();
touchOverlay(source.overlay_id);
notifyOverlayChanged(source.overlay_id, "scene_duplicate");
return getOverlay(source.overlay_id, { includeSecrets: true }).scenes.find((item) => item.id === scene.id);
}
function reorderScenes(overlayId, ids) {
reorderRows("overlay_scenes", ids, "overlay_id", overlayId);
touchOverlay(overlayId);
notifyOverlayChanged(overlayId, "scene_reorder");
}
function deleteScene(sceneId) {
const scene = db.prepare("SELECT * FROM overlay_scenes WHERE id = ?").get(sceneId);
if (!scene) throw new Error("Scene not found.");
const count = db.prepare("SELECT COUNT(*) AS count FROM overlay_scenes WHERE overlay_id = ?").get(scene.overlay_id).count;
if (count <= 1) throw new Error("Every overlay must have at least one scene.");
db.transaction(() => {
db.prepare("DELETE FROM overlay_modules WHERE scene_id = ?").run(sceneId);
db.prepare("DELETE FROM overlay_scenes WHERE id = ?").run(sceneId);
if (db.prepare("SELECT active_scene_id FROM overlays WHERE id = ?").get(scene.overlay_id)?.active_scene_id === sceneId) {
const fallback = db.prepare("SELECT id FROM overlay_scenes WHERE overlay_id = ? ORDER BY enabled DESC, sort_order, created_at LIMIT 1").get(scene.overlay_id);
db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(fallback.id, Date.now(), scene.overlay_id);
} else {
touchOverlay(scene.overlay_id);
}
})();
notifyOverlayChanged(scene.overlay_id, "scene_delete");
}
function regenerateSceneToken(sceneId) {
const scene = db.prepare("SELECT overlay_id FROM overlay_scenes WHERE id = ?").get(sceneId);
if (!scene) throw new Error("Scene not found.");
const token = createStoredToken();
db.prepare("UPDATE overlay_scenes SET public_token_hash = ?, public_token_encrypted = ?, updated_at = ? WHERE id = ?")
.run(token.hash, token.encrypted, Date.now(), sceneId);
touchOverlay(scene.overlay_id);
notifyOverlayChanged(scene.overlay_id, "scene_token_regenerated");
return token.token;
}
function setActiveScene(overlayId, sceneId, { source = "server" } = {}) {
const scene = db.prepare("SELECT id, enabled FROM overlay_scenes WHERE id = ? AND overlay_id = ?").get(sceneId, overlayId);
if (!scene) throw new Error("Scene not found for this overlay.");
if (!scene.enabled) throw new Error("Disabled scenes cannot be activated.");
db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(sceneId, Date.now(), overlayId);
notifyOverlayChanged(overlayId, source);
}
function addModule(sceneId, input = {}) {
const overlayId = overlayIdForScene(sceneId);
if (!overlayId) throw new Error("Scene not found.");
const type = String(input.type || "text");
const config = normalizeModuleConfig(type, input.config || input);
const id = crypto.randomUUID();
const now = Date.now();
db.prepare(
"INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(id, sceneId, type, requiredName(input.name || "Module", "Module name"), input.enabled === false ? 0 : 1, nextOrder("overlay_modules", "scene_id", sceneId), JSON.stringify(config), now, now);
touchOverlay(overlayId, now);
notifyOverlayChanged(overlayId, "module_create");
return id;
}
function updateModule(moduleId, input = {}) {
const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
if (!module) throw new Error("Overlay module not found.");
const overlayId = overlayIdForModule(moduleId);
const type = String(input.type || module.type);
const config = normalizeModuleConfig(type, input.config || input);
const now = Date.now();
db.prepare("UPDATE overlay_modules SET type = ?, name = ?, enabled = ?, config_json = ?, updated_at = ? WHERE id = ?")
.run(type, requiredName(input.name === undefined ? module.name : input.name, "Module name"), input.enabled === undefined ? module.enabled : input.enabled ? 1 : 0, JSON.stringify(config), now, moduleId);
touchOverlay(overlayId, now);
notifyOverlayChanged(overlayId, "module_update");
}
function updateModuleTransform(moduleId, input = {}) {
const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
if (!module) throw new Error("Overlay source not found.");
const current = parseConfig(module.config_json);
updateModule(moduleId, {
type: module.type,
name: module.name,
enabled: Boolean(module.enabled),
config: {
...current,
x: input.x === undefined ? current.x : input.x,
y: input.y === undefined ? current.y : input.y,
width: input.width === undefined ? current.width : input.width,
height: input.height === undefined ? current.height : input.height,
anchor: input.anchor === undefined ? current.anchor : input.anchor
}
});
const updated = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
return { ...updated, enabled: Boolean(updated.enabled), config: parseConfig(updated.config_json) };
}
function refreshModule(moduleId) {
const overlayId = overlayIdForModule(moduleId);
if (!overlayId) throw new Error("Overlay source not found.");
publishWebEvent("overlay:module-refresh", { module_id: moduleId }, { scope: `overlay:${overlayId}` });
return { overlayId, moduleId };
}
function duplicateModule(moduleId, name) {
const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
if (!module) throw new Error("Overlay module not found.");
return addModule(module.scene_id, {
type: module.type,
name: name || `${module.name} copy`,
enabled: Boolean(module.enabled),
config: parseConfig(module.config_json)
});
}
function reorderModules(sceneId, ids) {
const overlayId = overlayIdForScene(sceneId);
if (!overlayId) throw new Error("Scene not found.");
reorderRows("overlay_modules", ids, "scene_id", sceneId);
touchOverlay(overlayId);
notifyOverlayChanged(overlayId, "module_reorder");
}
function deleteModule(moduleId) {
const overlayId = overlayIdForModule(moduleId);
if (!overlayId) throw new Error("Overlay module not found.");
db.prepare("DELETE FROM overlay_modules WHERE id = ?").run(moduleId);
touchOverlay(overlayId);
notifyOverlayChanged(overlayId, "module_delete");
}
function resolvePublicOverlay(overlayToken, sceneToken = null) {
const overlay = db.prepare("SELECT * FROM overlays WHERE public_token_hash = ?").get(tokenHash(overlayToken));
if (!overlay) return null;
let fixedScene = null;
if (sceneToken) {
fixedScene = db.prepare("SELECT * FROM overlay_scenes WHERE overlay_id = ? AND public_token_hash = ?").get(overlay.id, tokenHash(sceneToken));
if (!fixedScene) return null;
}
return { overlay, fixedScene };
}
function buildPublicState(overlayId, fixedSceneId = null) {
const overlay = db.prepare("SELECT id, enabled, active_scene_id, canvas_width, canvas_height, updated_at FROM overlays WHERE id = ?").get(overlayId);
if (!overlay) return { exists: false, enabled: false, revision: Date.now(), scene: null };
const canvas = { width: overlay.canvas_width || 1920, height: overlay.canvas_height || 1080 };
if (!overlay.enabled) return { exists: true, enabled: false, revision: overlay.updated_at, canvas, scene: null };
const sceneId = fixedSceneId || overlay.active_scene_id;
const scene = sceneId
? db.prepare("SELECT id, name, enabled FROM overlay_scenes WHERE id = ? AND overlay_id = ?").get(sceneId, overlayId)
: null;
if (!scene || !scene.enabled) return { exists: true, enabled: true, revision: overlay.updated_at, canvas, scene: null };
const modules = db.prepare("SELECT id, type, name, config_json FROM overlay_modules WHERE scene_id = ? AND enabled = 1 ORDER BY sort_order, created_at")
.all(scene.id)
.map((module) => ({
id: module.id,
type: module.type,
renderType: getOverlayModuleType(module.type)?.renderType || null,
name: module.name,
config: parseConfig(module.config_json)
}))
.filter((module) => module.renderType);
return {
exists: true,
enabled: true,
revision: overlay.updated_at,
canvas,
scene: { id: scene.id, name: scene.name, modules }
};
}
function getObsSettings(overlayId, { includeSecret = false } = {}) {
const row = db.prepare("SELECT * FROM overlay_obs_settings WHERE overlay_id = ?").get(overlayId);
const result = row || {
overlay_id: overlayId,
enabled: 0,
provider: "local_obs_websocket",
endpoint: "ws://127.0.0.1:4455",
password_encrypted: null,
sync_direction: "none",
reconnect: 1
};
const output = { ...result, enabled: Boolean(result.enabled), reconnect: Boolean(result.reconnect), has_password: Boolean(result.password_encrypted) };
delete output.password_encrypted;
if (includeSecret) output.password = result.password_encrypted ? decryptSecret(result.password_encrypted) : "";
return output;
}
function saveObsSettings(overlayId, input = {}) {
if (!db.prepare("SELECT id FROM overlays WHERE id = ?").get(overlayId)) throw new Error("Overlay not found.");
const requestedProvider = String(input.provider || "local_obs_websocket").trim();
const provider = /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(requestedProvider)
? requestedProvider
: "local_obs_websocket";
const endpoint = String(input.endpoint || "ws://127.0.0.1:4455").trim().slice(0, 2048);
if (provider === "local_obs_websocket" && !/^wss?:\/\//i.test(endpoint)) {
throw new Error("OBS endpoint must start with ws:// or wss://.");
}
const syncDirection = ["none", "obs_to_overlay", "overlay_to_obs", "bidirectional"].includes(input.syncDirection) ? input.syncDirection : "none";
const existing = db.prepare("SELECT password_encrypted FROM overlay_obs_settings WHERE overlay_id = ?").get(overlayId);
let passwordEncrypted = existing?.password_encrypted || null;
if (input.clearPassword) passwordEncrypted = null;
else if (input.password) passwordEncrypted = encryptSecret(input.password);
const now = Date.now();
db.prepare(
`INSERT INTO overlay_obs_settings (overlay_id, enabled, provider, endpoint, password_encrypted, sync_direction, reconnect, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(overlay_id) DO UPDATE SET enabled = excluded.enabled, provider = excluded.provider, endpoint = excluded.endpoint,
password_encrypted = excluded.password_encrypted, sync_direction = excluded.sync_direction, reconnect = excluded.reconnect, updated_at = excluded.updated_at`
).run(overlayId, input.enabled ? 1 : 0, provider, endpoint, passwordEncrypted, syncDirection, input.reconnect === false ? 0 : 1, now, now);
return getObsSettings(overlayId, { includeSecret: true });
}
function listEnabledObsSettings() {
return db.prepare("SELECT overlay_id FROM overlay_obs_settings WHERE enabled = 1").all().map((row) => getObsSettings(row.overlay_id, { includeSecret: true }));
}
function onOverlayChanged(listener) {
overlayChanges.on("changed", listener);
return () => overlayChanges.off("changed", listener);
}
module.exports = {
addModule,
addScene,
buildPublicState,
createOverlay,
deleteModule,
deleteOverlay,
deleteScene,
duplicateModule,
duplicateOverlay,
duplicateScene,
getObsSettings,
getOverlay,
listEnabledObsSettings,
listOverlays,
notifyOverlayChanged,
onOverlayChanged,
regenerateOverlayToken,
regenerateSceneToken,
refreshModule,
reorderModules,
reorderOverlays,
reorderScenes,
resolvePublicOverlay,
saveObsSettings,
setActiveScene,
updateModule,
updateModuleTransform,
updateOverlay,
updateScene
};

View File

@ -341,17 +341,17 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
return fallback; return fallback;
} }
try { try {
const value = await definition.resolver({ const value = await withTimeout(Promise.resolve(definition.resolver({
user, user,
policy, policy,
outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience), outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience),
runtimeContext, runtimeContext,
token: match, token: match,
id id
}); })), runtimeContext?.placeholder_timeout_ms);
return stringifyResolvedValue(value); return stringifyResolvedValue(value);
} catch (error) { } catch (error) {
errors.push({ token: match, id, reason: "resolver_failed" }); errors.push({ token: match, id, reason: error?.code === "EPLACEHOLDERTIMEOUT" ? "resolver_timeout" : "resolver_failed" });
return fallback; return fallback;
} }
}); });
@ -366,16 +366,31 @@ function stringifyResolvedValue(value) {
} }
function replaceAsync(value, matcher, replacer) { function replaceAsync(value, matcher, replacer) {
const matches = []; const source = String(value || "");
value.replace(matcher, (...args) => { const flags = matcher.flags.includes("g") ? matcher.flags : `${matcher.flags}g`;
matches.push(args); const expression = new RegExp(matcher.source, flags);
return args[0]; const matches = [...source.matchAll(expression)];
return matches.reduce(async (pending, match) => {
const state = await pending;
const replacement = await replacer(...match, match.index, source, match.groups);
return {
output: `${state.output}${source.slice(state.cursor, match.index)}${replacement}`,
cursor: match.index + match[0].length
};
}, Promise.resolve({ output: "", cursor: 0 })).then((state) => `${state.output}${source.slice(state.cursor)}`);
}
function withTimeout(promise, requestedMs) {
const timeoutMs = Math.max(50, Math.min(Number(requestedMs) || 1500, 10000));
let timer = null;
const timeout = new Promise((_resolve, reject) => {
timer = setTimeout(() => {
const error = new Error("Placeholder resolver timed out.");
error.code = "EPLACEHOLDERTIMEOUT";
reject(error);
}, timeoutMs);
}); });
return matches.reduce(async (pending, args) => { return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
const current = await pending;
const replacement = await replacer(...args);
return current.replace(args[0], replacement);
}, Promise.resolve(value));
} }
function registerCorePlaceholders() { function registerCorePlaceholders() {
@ -385,7 +400,7 @@ function registerCorePlaceholders() {
field_type: "command_response", field_type: "command_response",
output_audience: "user", output_audience: "user",
min_editor_role: "mod", min_editor_role: "mod",
allowed_namespaces: ["core.main", "user.public"], allowed_namespaces: ["core.main", "core.command", "custom", "user.public"],
max_sensitivity: "public_safe" max_sensitivity: "public_safe"
}); });
registerPlaceholders([ registerPlaceholders([
@ -415,6 +430,19 @@ function registerCorePlaceholders() {
example: "!", example: "!",
resolver: () => getSetting("command_prefix", "!") resolver: () => getSetting("command_prefix", "!")
}, },
{
id: "core.command.rng",
namespace: "core.command",
label: "Random Reply number",
description: "The number rolled for the current Random Reply command. Blank for other messages.",
value_type: "number",
sensitivity: "public_safe",
min_editor_role: "mod",
min_viewer_role: "user",
allowed_field_types: ["command_response"],
example: "42",
resolver: ({ runtimeContext }) => runtimeContext?.command?.rng ?? ""
},
{ {
id: "user.public.display_name", id: "user.public.display_name",
namespace: "user.public", namespace: "user.public",
@ -433,6 +461,75 @@ function registerCorePlaceholders() {
"" ""
} }
]); ]);
registerCustomPlaceholders(getSetting("custom_placeholders", []));
}
function validateCustomPlaceholders(value) {
const rows = Array.isArray(value) ? value : [];
const errors = [];
const definitions = [];
const names = new Set();
if (rows.length > 50) errors.push("You can save at most 50 custom placeholders.");
rows.slice(0, 50).forEach((row, index) => {
const name = String(row?.name || "").trim().toLowerCase().replace(/^custom\./, "");
const rawValue = String(row?.value ?? "");
const visibility = ["user", "mod", "admin"].includes(row?.visibility) ? row.visibility : "";
const description = String(row?.description || "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim().slice(0, 240);
if (!/^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/.test(name)) {
errors.push(`Custom placeholder ${index + 1} needs a lowercase name such as stream.title.`);
return;
}
if (!visibility) {
errors.push(`Choose who may receive custom.${name}.`);
return;
}
if (/(?:^|[._])(?:api_?key|client_?secret|credential|password|private_?key|secret|token)(?:$|[._])/.test(name)) {
errors.push(`custom.${name} looks like a secret. Passwords, tokens, credentials, and API keys must not be placeholders.`);
return;
}
if (names.has(name)) {
errors.push(`Custom placeholder custom.${name} is listed more than once.`);
return;
}
if (rawValue.length > 2000) {
errors.push(`Custom placeholder custom.${name} is longer than 2,000 characters.`);
return;
}
names.add(name);
definitions.push({
name,
value: rawValue.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").slice(0, 2000),
visibility,
description
});
});
return { ok: errors.length === 0, errors, definitions };
}
function registerCustomPlaceholders(value) {
unregisterNamespace("custom");
const validation = validateCustomPlaceholders(value);
if (!validation.ok) return validation;
const roles = {
user: { viewer: "user", sensitivity: "public_safe" },
mod: { viewer: "mod", sensitivity: "moderator" },
admin: { viewer: "admin", sensitivity: "admin" }
};
registerPlaceholders(validation.definitions.map((definition) => ({
id: `custom.${definition.name}`,
namespace: "custom",
label: definition.name.replace(/[._-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()),
description: definition.description || `Admin-managed custom value: custom.${definition.name}`,
value_type: "string",
sensitivity: roles[definition.visibility].sensitivity,
min_editor_role: definition.visibility === "user" ? "mod" : definition.visibility,
min_viewer_role: roles[definition.visibility].viewer,
allowed_field_types: ["command_response", "admin_template", "okf_markdown"],
group: "Custom",
example: definition.visibility === "user" ? definition.value.slice(0, 120) : null,
resolver: () => definition.value
})));
return validation;
} }
function registerPlatformPlaceholders({ discordClient, getTwitchClient, getYouTubeClient } = {}) { function registerPlatformPlaceholders({ discordClient, getTwitchClient, getYouTubeClient } = {}) {
@ -468,6 +565,15 @@ function discordPlatformPlaceholders(discordClient) {
} }
}; };
return [ return [
platformPlaceholder({
id: `${namespace}.data_status`,
namespace,
group,
label: "Discord data status",
description: "Whether Discord values come from the connected runtime cache or are unavailable.",
example: "live_cache",
resolver: () => discordDataStatus(discordClient)
}),
platformPlaceholder({ platformPlaceholder({
id: `${namespace}.name`, id: `${namespace}.name`,
namespace, namespace,
@ -564,6 +670,15 @@ function twitchPlatformPlaceholders(getTwitchClient) {
const namespace = "platform.twitch.channel"; const namespace = "platform.twitch.channel";
const group = "Twitch channel"; const group = "Twitch channel";
return [ return [
platformPlaceholder({
id: `${namespace}.data_status`,
namespace,
group,
label: "Twitch data status",
description: "Whether Twitch values come from a connected chat runtime, configured local values, or are unavailable.",
example: "connected",
resolver: () => getTwitchClient?.() ? "connected" : twitchChannels().length ? "configured_not_connected" : "unavailable"
}),
platformPlaceholder({ platformPlaceholder({
id: `${namespace}.primary_name`, id: `${namespace}.primary_name`,
namespace, namespace,
@ -609,6 +724,17 @@ function youtubePlatformPlaceholders(getYouTubeClient) {
const namespace = "platform.youtube.channel"; const namespace = "platform.youtube.channel";
const group = "YouTube channel"; const group = "YouTube channel";
return [ return [
platformPlaceholder({
id: `${namespace}.data_status`,
namespace,
group,
label: "YouTube data status",
description: "Whether YouTube values come from the connected runtime, configured local values, or are unavailable.",
example: "connected",
resolver: () => getYouTubeClient?.()
? "connected"
: getSetting("youtube_bot_channel_id", "") ? "configured_not_connected" : "unavailable"
}),
platformPlaceholder({ platformPlaceholder({
id: `${namespace}.id`, id: `${namespace}.id`,
namespace, namespace,
@ -653,12 +779,13 @@ function youtubePlatformPlaceholders(getYouTubeClient) {
async function resolveDiscordGuild(discordClient) { async function resolveDiscordGuild(discordClient) {
const guildId = getSetting("discord_guild_id", ""); const guildId = getSetting("discord_guild_id", "");
if (!discordClient || !guildId) return null; if (!discordClient || !guildId) return null;
const cached = discordClient.guilds?.cache?.get?.(guildId); return discordClient.guilds?.cache?.get?.(guildId) || null;
if (cached) return cached; }
if (typeof discordClient.guilds?.fetch === "function") {
return discordClient.guilds.fetch(guildId).catch(() => null); function discordDataStatus(discordClient) {
} const guildId = getSetting("discord_guild_id", "");
return null; if (!guildId) return "unavailable";
return discordClient?.guilds?.cache?.get?.(guildId) ? "live_cache" : "configured_not_connected";
} }
function countDiscordChannels(guild, kind) { function countDiscordChannels(guild, kind) {
@ -696,12 +823,14 @@ module.exports = {
getFieldPolicy, getFieldPolicy,
parsePlaceholders, parsePlaceholders,
registerCorePlaceholders, registerCorePlaceholders,
registerCustomPlaceholders,
registerFieldPolicy, registerFieldPolicy,
registerPlaceholder, registerPlaceholder,
registerPlaceholders, registerPlaceholders,
registerPlatformPlaceholders, registerPlatformPlaceholders,
renderTemplate, renderTemplate,
unregisterNamespace, unregisterNamespace,
validateCustomPlaceholders,
unregisterPlaceholder, unregisterPlaceholder,
validateTemplate, validateTemplate,
_internals: { _internals: {

View File

@ -1,6 +1,7 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const crypto = require("crypto"); const crypto = require("crypto");
const { writeJsonAtomicSync } = require("./safe-files");
const repoRoot = path.join(__dirname, "..", ".."); const repoRoot = path.join(__dirname, "..", "..");
const recoveryDir = path.join(repoRoot, "data", "recovery"); const recoveryDir = path.join(repoRoot, "data", "recovery");
@ -34,7 +35,7 @@ function writeRecoveryMarker(marker) {
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
...marker ...marker
}; };
fs.writeFileSync(markerPath, JSON.stringify(next, null, 2), "utf8"); writeJsonAtomicSync(markerPath, next);
return next; return next;
} }

183
src/services/safe-files.js Normal file
View File

@ -0,0 +1,183 @@
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { threadId } = require("worker_threads");
const TRANSIENT_CODES = new Set(["EACCES", "EBUSY", "EEXIST", "ENOTEMPTY", "EPERM"]);
const activeWrites = new Set();
const diagnostics = [];
const sleepArray = new Int32Array(new SharedArrayBuffer(4));
function writeJsonAtomicSync(target, value, options = {}) {
return writeTextAtomicSync(target, `${JSON.stringify(value, null, options.indent ?? 2)}\n`, options);
}
function writeTextAtomicSync(target, content, options = {}) {
if (!String(target || "").trim()) throw new Error("A target file is required.");
const file = path.resolve(String(target));
if (activeWrites.has(file)) {
const error = new Error(`A write to ${file} is already in progress.`);
error.code = "EWRITELOCKED";
recordDiagnostic(file, "write_locked", error, 0, "error", options);
throw error;
}
activeWrites.add(file);
fs.mkdirSync(path.dirname(file), { recursive: true });
cleanupStaleTempFilesSync(file, options);
const temporary = temporaryPath(file);
let descriptor = null;
try {
descriptor = fs.openSync(temporary, "wx", options.mode ?? 0o600);
fs.writeFileSync(descriptor, content, options.encoding || "utf8");
try { fs.fsyncSync(descriptor); } catch (error) {
recordDiagnostic(file, "fsync_unavailable", error, 0, "warning", options);
}
fs.closeSync(descriptor);
descriptor = null;
replaceFileSync(temporary, file, options);
if (options.mode != null) {
try { fs.chmodSync(file, options.mode); } catch {}
}
syncParentDirectory(file, options);
return file;
} catch (error) {
recordDiagnostic(file, "write_failed", error, 0, "error", options);
throw error;
} finally {
if (descriptor != null) {
try { fs.closeSync(descriptor); } catch {}
}
try { fs.rmSync(temporary, { force: true }); } catch {}
activeWrites.delete(file);
}
}
function replaceFileSync(temporary, target, options = {}) {
try {
retrySync(() => fs.renameSync(temporary, target), target, "rename", options);
return;
} catch (renameError) {
if (!fs.existsSync(target) || !TRANSIENT_CODES.has(renameError.code)) throw renameError;
}
const backup = `${target}.${process.pid}.${threadId}.${crypto.randomBytes(5).toString("hex")}.bak`;
let movedOriginal = false;
try {
retrySync(() => fs.renameSync(target, backup), target, "backup_existing", options);
movedOriginal = true;
retrySync(() => fs.renameSync(temporary, target), target, "install_replacement", options);
fs.rmSync(backup, { force: true });
recordDiagnostic(target, "backup_replace_succeeded", null, 0, "warning", options);
} catch (error) {
if (movedOriginal && fs.existsSync(backup) && !fs.existsSync(target)) {
try {
retrySync(() => fs.renameSync(backup, target), target, "restore_original", options);
} catch (restoreError) {
recordDiagnostic(target, "restore_failed", restoreError, 0, "error", options);
error.message = `${error.message} The original file remains at ${backup}.`;
}
}
throw error;
} finally {
if (fs.existsSync(backup) && fs.existsSync(target)) {
try { fs.rmSync(backup, { force: true }); } catch {}
}
}
}
function retrySync(operation, target, operationName, options = {}) {
const attempts = Math.max(1, Number(options.attempts) || 8);
const delayMs = Math.max(1, Number(options.delayMs) || 15);
let lastError = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return operation();
} catch (error) {
lastError = error;
if (!TRANSIENT_CODES.has(error.code) || attempt === attempts) throw error;
recordDiagnostic(target, operationName, error, attempt, "warning", options);
Atomics.wait(sleepArray, 0, 0, Math.min(250, delayMs * attempt));
}
}
throw lastError;
}
function runFileOperationWithRetries(operation, options = {}) {
if (typeof operation !== "function") throw new Error("A file operation function is required.");
return retrySync(
operation,
options.target || "file operation",
options.operation || "file_operation",
options
);
}
function cleanupStaleTempFilesSync(target, options = {}) {
const directory = path.dirname(target);
const basename = path.basename(target);
const maxAgeMs = Math.max(1000, Number(options.staleTempAgeMs) || 60 * 60 * 1000);
const now = Date.now();
let names = [];
try { names = fs.readdirSync(directory); } catch { return; }
for (const name of names) {
if (name !== `${basename}.tmp` && !(name.startsWith(`${basename}.`) && name.endsWith(".tmp"))) continue;
const candidate = path.join(directory, name);
try {
if (now - fs.statSync(candidate).mtimeMs < maxAgeMs) continue;
fs.rmSync(candidate, { force: true });
recordDiagnostic(target, "stale_temp_removed", null, 0, "warning", options);
} catch {}
}
}
function temporaryPath(target) {
return `${target}.${process.pid}.${threadId}.${crypto.randomBytes(7).toString("hex")}.tmp`;
}
function syncParentDirectory(target, options) {
let descriptor = null;
try {
descriptor = fs.openSync(path.dirname(target), "r");
fs.fsyncSync(descriptor);
} catch (error) {
if (process.platform !== "win32") {
recordDiagnostic(target, "directory_fsync_unavailable", error, 0, "warning", options);
}
} finally {
if (descriptor != null) try { fs.closeSync(descriptor); } catch {}
}
}
function recordDiagnostic(target, operation, error, attempt, level, options = {}) {
const entry = {
timestamp: new Date().toISOString(),
level,
operation,
target,
attempt,
code: error?.code || null,
message: error?.message || null
};
diagnostics.push(entry);
if (diagnostics.length > 100) diagnostics.splice(0, diagnostics.length - 100);
if (typeof options.onDiagnostic === "function") options.onDiagnostic(entry);
}
function getSafeFileDiagnostics() {
return diagnostics.map((entry) => ({ ...entry }));
}
function clearSafeFileDiagnostics() {
diagnostics.length = 0;
}
module.exports = {
TRANSIENT_CODES,
writeJsonAtomicSync,
writeTextAtomicSync,
cleanupStaleTempFilesSync,
runFileOperationWithRetries,
getSafeFileDiagnostics,
clearSafeFileDiagnostics
};

View File

@ -86,6 +86,7 @@ function ensureDefaults() {
youtube_redirect_uri: envString("YOUTUBE_REDIRECT_URI", ""), youtube_redirect_uri: envString("YOUTUBE_REDIRECT_URI", ""),
youtube_bot_refresh_token: envString("YOUTUBE_BOT_REFRESH_TOKEN", ""), youtube_bot_refresh_token: envString("YOUTUBE_BOT_REFRESH_TOKEN", ""),
youtube_bot_channel_id: envString("YOUTUBE_BOT_CHANNEL_ID", ""), youtube_bot_channel_id: envString("YOUTUBE_BOT_CHANNEL_ID", ""),
custom_placeholders: [],
localhost_login_username: "admin", localhost_login_username: "admin",
localhost_login_password: "admin", localhost_login_password: "admin",
theme_light_bg_1: "#ffe5c4", theme_light_bg_1: "#ffe5c4",

View File

@ -2,6 +2,10 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const os = require("os"); const os = require("os");
const crypto = require("crypto"); const crypto = require("crypto");
const {
runFileOperationWithRetries,
writeJsonAtomicSync
} = require("./safe-files");
let AdmZip = null; let AdmZip = null;
try { try {
AdmZip = require("adm-zip"); AdmZip = require("adm-zip");
@ -77,7 +81,7 @@ function loadIndex() {
function saveIndex(entries) { function saveIndex(entries) {
ensureSnapshotsDir(); ensureSnapshotsDir();
fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), "utf8"); writeJsonAtomicSync(indexPath, entries);
} }
async function backupDatabase(targetPath) { async function backupDatabase(targetPath) {
@ -328,17 +332,67 @@ function addFolder(zip, folderPath, basePath, ignore) {
} }
function resetCoreFiles() { function resetCoreFiles() {
const ignore = new Set([".git", "node_modules", "data", "plugins"]); resetDirectoryForFullUpdate(repoRoot, buildCorePreservePaths(repoRoot));
const entries = fs.readdirSync(repoRoot, { withFileTypes: true }); }
function resetDirectoryForFullUpdate(targetRoot, preservePaths = buildCorePreservePaths(targetRoot)) {
removeUnpreservedEntries(targetRoot, targetRoot, preservePaths);
}
function buildCorePreservePaths(targetRoot = repoRoot) {
const preserve = new Set(PRESERVE_RELATIVE_PATHS);
for (const scope of ["core", "plugins"]) {
const directory = path.join(targetRoot, "knowledge", scope);
collectLocalKnowledgePaths(directory, targetRoot, preserve);
}
return preserve;
}
function collectLocalKnowledgePaths(directory, base, preserve) {
if (!fs.existsSync(directory)) return;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const filePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
collectLocalKnowledgePaths(filePath, base, preserve);
} else if (!entry.isFile() || isLocallyOwnedKnowledgeFile(filePath)) {
preserve.add(normalizeRelative(path.relative(base, filePath)));
}
}
}
function isLocallyOwnedKnowledgeFile(filePath) {
if (path.extname(filePath).toLowerCase() !== ".md") return true;
let source = "";
try {
source = fs.readFileSync(filePath, "utf8");
} catch {
return true;
}
const frontmatter = source.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] || "";
const generated = /^generated:\s*true\s*$/im.test(frontmatter);
const readOnly = /^editable:\s*false\s*$/im.test(frontmatter);
return !(generated && readOnly);
}
function removeUnpreservedEntries(directory, base, preservePaths) {
const entries = fs.readdirSync(directory, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
if (ignore.has(entry.name)) { const fullPath = path.join(directory, entry.name);
const relativePath = normalizeRelative(path.relative(base, fullPath));
if (isRelativePathIgnored(relativePath, preservePaths)) continue;
if (entry.isDirectory() && hasPreservedDescendant(relativePath, preservePaths)) {
removeUnpreservedEntries(fullPath, base, preservePaths);
continue; continue;
} }
const fullPath = path.join(repoRoot, entry.name);
fs.rmSync(fullPath, { recursive: true, force: true }); fs.rmSync(fullPath, { recursive: true, force: true });
} }
} }
function hasPreservedDescendant(relativePath, preservePaths) {
const prefix = `${normalizeRelative(relativePath)}/`;
return [...preservePaths].some((candidate) => normalizeRelative(candidate).startsWith(prefix));
}
function normalizeRelative(value) { function normalizeRelative(value) {
return String(value || "").split(path.sep).join("/"); return String(value || "").split(path.sep).join("/");
} }
@ -421,19 +475,22 @@ function removeGeneratedPaths() {
function applyCoreFilesFromDirectory(rootPath) { function applyCoreFilesFromDirectory(rootPath) {
removeGeneratedPaths(); removeGeneratedPaths();
const preservePaths = buildCorePreservePaths(repoRoot);
resetDirectoryForFullUpdate(repoRoot, preservePaths);
copyDirectory( copyDirectory(
rootPath, rootPath,
repoRoot, repoRoot,
PRESERVE_RELATIVE_PATHS, preservePaths,
{ base: rootPath } { base: rootPath }
); );
} }
function applyCorePatch(rootPath) { function applyCorePatch(rootPath) {
const preservePaths = buildCorePreservePaths(repoRoot);
copyDirectory( copyDirectory(
rootPath, rootPath,
repoRoot, repoRoot,
PRESERVE_RELATIVE_PATHS, preservePaths,
{ base: rootPath } { base: rootPath }
); );
} }
@ -476,19 +533,66 @@ function applyPluginFiles(rootPath, pluginId, options = {}) {
} }
function replacePluginDirectory(rootPath, targetDir, options = {}) { function replacePluginDirectory(rootPath, targetDir, options = {}) {
if (options.preserveData) { const parent = path.dirname(targetDir);
resetPluginCode(targetDir); const name = path.basename(targetDir);
} else { const nonce = `${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
fs.rmSync(targetDir, { recursive: true, force: true }); const staging = path.join(parent, `.${name}.update-${nonce}`);
const backup = path.join(parent, `.${name}.backup-${nonce}`);
const ignore = options.preserveData
? new Set(["node_modules", "data"])
: new Set(["node_modules"]);
let targetMoved = false;
let dataMoved = false;
let replacementInstalled = false;
let rollbackFailed = false;
fs.mkdirSync(parent, { recursive: true });
try {
fs.mkdirSync(staging, { recursive: true });
copyDirectory(rootPath, staging, ignore);
if (fs.existsSync(targetDir)) {
moveDirectory(targetDir, backup, "backup_plugin");
targetMoved = true;
}
const backupData = path.join(backup, "data");
const stagedData = path.join(staging, "data");
if (options.preserveData && targetMoved && fs.existsSync(backupData)) {
moveDirectory(backupData, stagedData, "preserve_plugin_data");
dataMoved = true;
}
moveDirectory(staging, targetDir, "install_plugin");
replacementInstalled = true;
fs.rmSync(backup, { recursive: true, force: true });
} catch (error) {
try {
if (replacementInstalled && fs.existsSync(targetDir)) {
const installedData = path.join(targetDir, "data");
if (dataMoved && targetMoved && fs.existsSync(installedData) && fs.existsSync(backup)) {
moveDirectory(installedData, path.join(backup, "data"), "restore_plugin_data");
}
fs.rmSync(targetDir, { recursive: true, force: true });
} else if (dataMoved && targetMoved && fs.existsSync(path.join(staging, "data")) && fs.existsSync(backup)) {
moveDirectory(path.join(staging, "data"), path.join(backup, "data"), "restore_plugin_data");
}
if (targetMoved && fs.existsSync(backup) && !fs.existsSync(targetDir)) {
moveDirectory(backup, targetDir, "restore_plugin");
}
} catch (restoreError) {
rollbackFailed = true;
error.message = `${error.message} Plugin rollback also failed: ${restoreError.message}. Backup: ${backup}. Staging: ${staging}`;
}
throw error;
} finally {
if (!rollbackFailed) fs.rmSync(staging, { recursive: true, force: true });
} }
fs.mkdirSync(targetDir, { recursive: true }); }
copyDirectory(
rootPath, function moveDirectory(source, target, operation) {
targetDir, runFileOperationWithRetries(() => fs.renameSync(source, target), {
options.preserveData target,
? new Set(["node_modules", "data"]) operation,
: new Set(["node_modules"]) attempts: 8,
); delayMs: 20
});
} }
async function applyBotUpdate(zipPath, options = {}) { async function applyBotUpdate(zipPath, options = {}) {
@ -524,17 +628,29 @@ async function applyBotUpdate(zipPath, options = {}) {
recovery_marker_id: marker.id recovery_marker_id: marker.id
} }
}); });
let record = null;
try { try {
record = finalizeSnapshot(snapshot);
if (mode === "patch") { if (mode === "patch") {
applyCorePatch(rootPath); applyCorePatch(rootPath);
} else { } else {
applyCoreFilesFromDirectory(rootPath); applyCoreFilesFromDirectory(rootPath);
} }
const record = finalizeSnapshot(snapshot);
markRecoveryMarkerComplete({ snapshot_id: record.id }); markRecoveryMarkerComplete({ snapshot_id: record.id });
return record; return record;
} catch (error) { } catch (error) {
discardSnapshot(snapshot); if (record) {
try {
restoreSnapshot(record.id, {
expectedType: "bot",
allowUnsafeMajorRollback: true
});
} catch (restoreError) {
error.message = `${error.message} Automatic rollback also failed: ${restoreError.message}`;
}
} else {
discardSnapshot(snapshot);
}
markRecoveryMarkerFailed(error); markRecoveryMarkerFailed(error);
throw error; throw error;
} }
@ -575,15 +691,28 @@ async function applyPluginUpdate(zipPath, options = {}) {
recovery_marker_id: marker.id recovery_marker_id: marker.id
} }
}); });
let record = null;
try { try {
record = finalizeSnapshot(snapshot);
applyPluginFiles(rootPath, manifest.id, { applyPluginFiles(rootPath, manifest.id, {
preserveData: snapshot.pluginExisted preserveData: snapshot.pluginExisted
}); });
const record = finalizeSnapshot(snapshot);
markRecoveryMarkerComplete({ snapshot_id: record.id }); markRecoveryMarkerComplete({ snapshot_id: record.id });
return record; return record;
} catch (error) { } catch (error) {
discardSnapshot(snapshot); if (record) {
try {
restoreSnapshot(record.id, {
expectedType: "plugin",
expectedPluginId: manifest.id,
allowUnsafeMajorRollback: true
});
} catch (restoreError) {
error.message = `${error.message} Automatic rollback also failed: ${restoreError.message}`;
}
} else {
discardSnapshot(snapshot);
}
markRecoveryMarkerFailed(error); markRecoveryMarkerFailed(error);
throw error; throw error;
} }
@ -642,10 +771,13 @@ function restoreSnapshot(id, options = {}) {
throw new Error("Snapshot core archive missing."); throw new Error("Snapshot core archive missing.");
} }
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-")); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
extractZip(restoreZip, tempDir); try {
const rootPath = resolveZipRoot(tempDir); extractZip(restoreZip, tempDir);
applyCoreFilesFromDirectory(rootPath); const rootPath = resolveZipRoot(tempDir);
fs.rmSync(tempDir, { recursive: true, force: true }); applyCoreFilesFromDirectory(rootPath);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
} }
if (entry.type === "plugin") { if (entry.type === "plugin") {
@ -657,10 +789,13 @@ function restoreSnapshot(id, options = {}) {
throw new Error("Snapshot plugin archive missing."); throw new Error("Snapshot plugin archive missing.");
} }
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-")); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
extractZip(pluginZip, tempDir); try {
const rootPath = resolvePluginRoot(tempDir); extractZip(pluginZip, tempDir);
applyPluginFiles(rootPath, entry.pluginId, { preserveData: true }); const rootPath = resolvePluginRoot(tempDir);
fs.rmSync(tempDir, { recursive: true, force: true }); applyPluginFiles(rootPath, entry.pluginId, { preserveData: true });
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
} else { } else {
fs.rmSync(targetDir, { recursive: true, force: true }); fs.rmSync(targetDir, { recursive: true, force: true });
} }
@ -680,6 +815,8 @@ module.exports = {
applyCoreFilesFromDirectory, applyCoreFilesFromDirectory,
applyPluginFiles, applyPluginFiles,
resetPluginCode, resetPluginCode,
resetDirectoryForFullUpdate,
buildCorePreservePaths,
replacePluginDirectory, replacePluginDirectory,
listSnapshots, listSnapshots,
restoreSnapshot restoreSnapshot

View File

@ -1,6 +1,7 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { spawnSync } = require("child_process"); const { spawnSync } = require("child_process");
const { writeJsonAtomicSync } = require("./safe-files");
const repoRoot = path.join(__dirname, "..", ".."); const repoRoot = path.join(__dirname, "..", "..");
const dataDir = path.join(repoRoot, "data"); const dataDir = path.join(repoRoot, "data");
@ -82,7 +83,7 @@ function writeUpdateState(values = {}) {
...current, ...current,
...values ...values
}; };
fs.writeFileSync(updateStatePath, JSON.stringify(next, null, 2), "utf8"); writeJsonAtomicSync(updateStatePath, next);
return next; return next;
} }

View File

@ -0,0 +1,198 @@
const fs = require("fs");
const path = require("path");
const { TextDecoder } = require("util");
const FILE_TYPES = Object.freeze({
zip: {
mime: "application/zip",
mimes: ["application/zip", "application/x-zip-compressed", "application/octet-stream"],
extensions: [".zip"]
},
png: {
mime: "image/png",
mimes: ["image/png"],
extensions: [".png"]
},
jpeg: {
mime: "image/jpeg",
mimes: ["image/jpeg", "image/jpg"],
extensions: [".jpg", ".jpeg"]
},
webp: {
mime: "image/webp",
mimes: ["image/webp"],
extensions: [".webp"]
},
pdf: {
mime: "application/pdf",
mimes: ["application/pdf"],
extensions: [".pdf"]
},
text: {
mime: "text/plain",
mimes: ["text/plain"],
extensions: [".txt"]
},
svg: {
mime: "image/svg+xml",
mimes: ["image/svg+xml"],
extensions: [".svg"]
}
});
function flattenUploadedFiles(value) {
if (!value) return [];
if (Array.isArray(value)) return value.flatMap(flattenUploadedFiles);
if (value.path) return [value];
if (typeof value === "object") return Object.values(value).flatMap(flattenUploadedFiles);
return [];
}
function cleanupUploadedFiles(value) {
for (const file of flattenUploadedFiles(value)) {
if (!file.path) continue;
try {
fs.rmSync(file.path, { force: true });
} catch {
// Cleanup is best effort; callers still receive the original upload error.
}
}
}
function validateUploadedFile(file, options = {}) {
if (!file?.path) throw new Error("The uploaded file could not be read.");
const allowedTypes = normalizeAllowedTypes(options.allowedTypes);
const stat = fs.statSync(file.path);
const maxBytes = Math.max(1, Number(options.maxBytes) || Number.MAX_SAFE_INTEGER);
if (!stat.isFile() || stat.size < 1) throw new Error("The uploaded file is empty.");
if (stat.size > maxBytes) throw new Error(`The uploaded file is larger than ${formatBytes(maxBytes)}.`);
const originalName = safeDownloadFilename(file.originalname, "upload");
const extension = path.extname(originalName).toLowerCase();
const claimedMime = String(file.mimetype || "").toLowerCase();
const buffer = allowedTypes.some((type) => type === "text" || type === "svg")
? fs.readFileSync(file.path)
: readPrefix(file.path, 16);
const detectedType = allowedTypes.find((type) => matchesType(buffer, type));
if (!detectedType) {
throw new Error(`The file contents do not match an allowed ${options.label || "file"} type.`);
}
const definition = FILE_TYPES[detectedType];
if (claimedMime && !definition.mimes.includes(claimedMime)) {
throw new Error("The file contents do not match the type reported by the browser.");
}
if (options.requireExtension && !extension) {
throw new Error(`The uploaded file must use ${formatExtensions(allowedTypes)}.`);
}
if (extension && !definition.extensions.includes(extension)) {
throw new Error("The filename extension does not match the file contents.");
}
file.originalname = originalName;
file.mimetype = definition.mime;
file.size = stat.size;
file.detectedType = detectedType;
return file;
}
function validateUploadedFiles(value, options = {}) {
const files = flattenUploadedFiles(value);
try {
files.forEach((file) => validateUploadedFile(file, options));
return files;
} catch (error) {
cleanupUploadedFiles(files);
throw error;
}
}
function safeDownloadFilename(value, fallback = "attachment") {
const lastSegment = String(value || "")
.split(/[\\/]/)
.pop()
.normalize("NFKC")
.replace(/[\u0000-\u001f\u007f]/g, "")
.replace(/["';]/g, "")
.trim();
const cleaned = lastSegment.replace(/^\.+/, "").slice(0, 180);
return cleaned || fallback;
}
function normalizeAllowedTypes(value) {
const types = Array.isArray(value) ? value : [];
if (!types.length || types.some((type) => !FILE_TYPES[type])) {
throw new Error("Upload validation requires known allowed file types.");
}
return [...new Set(types)];
}
function matchesType(buffer, type) {
if (type === "png") return startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (type === "jpeg") return startsWith(buffer, [0xff, 0xd8, 0xff]);
if (type === "webp") return buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP";
if (type === "pdf") return buffer.subarray(0, 5).toString("ascii") === "%PDF-";
if (type === "zip") {
return startsWith(buffer, [0x50, 0x4b, 0x03, 0x04]) || startsWith(buffer, [0x50, 0x4b, 0x05, 0x06]) || startsWith(buffer, [0x50, 0x4b, 0x07, 0x08]);
}
if (type === "text") return isPlainText(buffer);
if (type === "svg") return isSafeSvg(buffer);
return false;
}
function isPlainText(buffer) {
if (buffer.includes(0)) return false;
try {
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
return true;
} catch {
return false;
}
}
function isSafeSvg(buffer) {
if (!isPlainText(buffer)) return false;
const source = buffer.toString("utf8").replace(/^\uFEFF/, "").trim();
if (!/^<\?xml\b[^>]*>\s*/i.test(source) && !/^<svg\b/i.test(source)) return false;
if (!/<svg\b/i.test(source)) return false;
return !/<\s*(script|foreignObject|iframe|object|embed|audio|video)\b/i.test(source)
&& !/\bon[a-z]+\s*=/i.test(source)
&& !/javascript\s*:/i.test(source)
&& !/@import\b/i.test(source)
&& !/<\s*!DOCTYPE|<\s*!ENTITY/i.test(source)
&& !/\b(?:href|xlink:href)\s*=\s*["']\s*(?!#)/i.test(source)
&& !/\burl\s*\(\s*["']?\s*(?!#)/i.test(source);
}
function startsWith(buffer, bytes) {
return buffer.length >= bytes.length && bytes.every((byte, index) => buffer[index] === byte);
}
function readPrefix(filePath, length) {
const descriptor = fs.openSync(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(descriptor, buffer, 0, length, 0);
return buffer.subarray(0, bytesRead);
} finally {
fs.closeSync(descriptor);
}
}
function formatBytes(bytes) {
if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`;
return `${Math.round(bytes / 1024)} KB`;
}
function formatExtensions(types) {
return types.flatMap((type) => FILE_TYPES[type].extensions).join(" or ");
}
module.exports = {
FILE_TYPES,
cleanupUploadedFiles,
flattenUploadedFiles,
safeDownloadFilename,
validateUploadedFile,
validateUploadedFiles
};

72
src/services/web-auth.js Normal file
View File

@ -0,0 +1,72 @@
const { getPlatformStatus } = require("./platforms");
function createWebAuth(options = {}) {
const platformStatus = options.getPlatformStatus || getPlatformStatus;
function isConfigured() {
const platforms = platformStatus().filter((platform) => platform.supported && platform.enabled);
return platforms.length > 0 && platforms.some((platform) => platform.configured);
}
function isLocalhostLoginAvailable(req) {
if (!req) return false;
const host = normalizeHostName(req.hostname || req.get?.("host"));
return host === "localhost" || host === "::1" || host === "::ffff:127.0.0.1" || host === "127.0.0.1" || host.startsWith("127.");
}
function getLocalhostLoginPlatform(req) {
if (!isLocalhostLoginAvailable(req)) return null;
return {
id: "localhost",
label: "Localhost Login",
configured: true,
enabled: true,
supported: true,
supportsLogin: true,
loginPath: "/auth/localhost"
};
}
function getPrimaryLoginPlatform(req) {
const localhost = getLocalhostLoginPlatform(req);
if (localhost) return localhost;
const platforms = platformStatus().filter((platform) => platform.supported && platform.enabled && platform.supportsLogin);
if (!platforms.length) return null;
return platforms.find((platform) => platform.configured) || platforms[0];
}
function getLoginRedirectPath(req) {
return getPrimaryLoginPlatform(req)?.loginPath || "/setup";
}
function requireConfigured(req, res, next) {
if (!isConfigured() && !isLocalhostLoginAvailable(req) && !req.path.startsWith("/setup")) {
return res.redirect("/setup");
}
return next();
}
function requireAuth(req, res, next) {
if (!req.session?.user) return res.redirect(getLoginRedirectPath(req));
return next();
}
return Object.freeze({
isConfigured,
isLocalhostLoginAvailable,
getLocalhostLoginPlatform,
getPrimaryLoginPlatform,
getLoginRedirectPath,
requireConfigured,
requireAuth
});
}
function normalizeHostName(value) {
const raw = String(value || "").toLowerCase();
if (raw.startsWith("[")) return raw.slice(1, raw.indexOf("]"));
if (raw === "::1" || raw === "::ffff:127.0.0.1") return raw;
return raw.replace(/:\d+$/, "");
}
module.exports = { createWebAuth, normalizeHostName };

Some files were not shown because too many files have changed in this diff Show More