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
Requires Node.js 18+.
Requires Node.js 18+ on Windows or Linux.
1. Install dependencies:
```
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:
```
npm run run
```
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`
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
Configure Twitch chat settings in **Admin → Settings**:
- `twitch_bot_username`
- `twitch_bot_oauth` (OAuth token)
- `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
@ -75,6 +96,13 @@ incomplete values fall back safely to the selected built-in base theme.
Developer and modding conventions are documented in
[`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
- 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
### 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
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
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
- 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
### 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: 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.command_prefix}}`
- `{{core.command.rng}}`
- `{{custom.stream.title}}`
- `{{user.public.display_name}}`
- `{{platform.discord.guild.member_count}}`
- `{{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
templates, but the catalog should present canonical namespaced tokens.
Custom admin-defined placeholders, if added later, should live under the
`{{custom.*}}` namespace and be managed from a trusted admin settings page.
Admin-managed values live under `{{custom.*}}`. Admins create them under
Settings > Reusable text values; names are validated, cannot collide with
system/plugin namespaces, and are stored in the update-preserved settings
database.
## Core Namespaces
Current core-owned namespaces include:
- `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
- `platform.discord.guild.*`: safe Discord guild statistics from the configured guild
- `platform.twitch.channel.*`: safe locally configured Twitch 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}}`
API-backed platform statistics such as Twitch follower/subscriber counts or
future stream schedules should be registered here only after the integration has
a reliable server-side fetch/cache layer and clear sensitivity rules.
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.
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
@ -91,9 +97,25 @@ Security is based on the intersection of:
- plugin availability
- 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
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
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
generic unavailable marker or leave legacy unknown tokens unchanged, depending
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
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
@ -50,5 +52,6 @@ Admins can:
- clear a stale marker after verifying startup,
- retry normal startup.
Rollback is never automatic. Major-version rollback remains blocked unless the
snapshot is explicitly marked rollback safe.
Manual major-version rollback remains blocked unless the snapshot is explicitly
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
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
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
contains valid manifest data, so use them as a manual fallback.

View File

@ -9,7 +9,6 @@ category: Core
tags: core, routes, commands, settings
generated: true
editable: false
updated_at: "2026-06-25T12:06:11.231Z"
---
# lumi-bot
Lumi is the core web UI and bot runtime.
@ -97,6 +96,8 @@ Version: 0.1.9
- POST /admin/feedback/export
- POST /admin/feedback/:id/export
- 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/finalize
- 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
- Response format: API response; exact schema was not detected statically.
- 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.
### GET /api/feedback/notifications
@ -508,9 +509,9 @@ Version: 0.1.9
- Purpose: Handles feedback id attachments attachmentId.
- Inputs: path params: `attachmentId`, `id`
- Response format: static file response
- Response format: file download
- 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.
### GET /health
@ -831,7 +832,7 @@ Version: 0.1.9
### GET /admin/feedback
- 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
- Access: admin access expected
- Side effects: Usually read-only.
@ -864,6 +865,24 @@ Version: 0.1.9
- 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/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
- 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.
- 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
- 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.
### POST /admin/check-update
- Purpose: Processes the admin check update action and stores or applies submitted form data.
- 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
- 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.
### POST /admin/restart
@ -1281,12 +1300,12 @@ Version: 0.1.9
# Lumi Bot
Discord bot + WebUI with role-based access, plugin management, and self-update support.
## Quick start
Requires Node.js 18+.
Requires Node.js 18+ on Windows or Linux.
1. Install dependencies:
```
npm install
```
2. Run with auto-restart:
```
npm run run
```
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

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, auto-vc
generated: true
editable: false
updated_at: "2026-06-25T12:06:11.231Z"
---
# Auto VC
Auto-create managed voice channels from lobby rooms.
@ -21,6 +20,7 @@ Default state: enabled
- /plugins/auto-vc
- GET /plugins/auto-vc
- POST /plugins/auto-vc/settings
- POST /plugins/auto-vc/settings/remove
- POST /plugins/auto-vc/bans
- POST /plugins/auto-vc/unban
## Route Reference
@ -45,9 +45,18 @@ Default state: enabled
### POST /plugins/auto-vc/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
- Response format: HTTP redirect after handling the request
- Access: admin access expected; logged-in session required or used
- 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
- 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
- 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
generated: true
editable: false
updated_at: "2026-06-25T12:06:11.231Z"
---
# Birthday
Birthday profiles, announcements, lookup commands, and optional birthday currency gifts.

View File

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

View File

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

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, expression-interaction
generated: true
editable: false
updated_at: "2026-06-25T12:06:11.231Z"
---
# Expression Interaction
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
generated: true
editable: false
updated_at: "2026-06-25T12:06:11.231Z"
---
# Lumi AI
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/runtime/:action
- 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/feedback
- GET /plugins/lumi_ai/assistant/jobs/:id
@ -61,9 +61,12 @@ Default state: enabled
- GET /plugins/lumi_ai/improvement_center
- POST /plugins/lumi_ai/improvement_center/settings
- 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/corrections/save
- 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/:id/delete
- POST /plugins/lumi_ai/improvement_center/evals/run
@ -258,6 +261,15 @@ Default state: enabled
- 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.
### 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
- Purpose: Processes the lumi_ai plugin action for assistant message.
@ -270,7 +282,7 @@ Default state: enabled
### POST /plugins/lumi_ai/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.
- Access: logged-in session required or used
- 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
- 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
- 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
- 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
- Purpose: Processes the lumi_ai plugin action for improvement center evals.

View File

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

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, okf
generated: true
editable: false
updated_at: "2026-06-25T12:06:11.231Z"
---
# OKF Knowledge
Role-gated knowledge, facts, and Q&A entries for Lumi communities.
@ -21,6 +20,7 @@ Default state: enabled
- /plugins/okf
- GET /plugins/okf
- GET /plugins/okf/admin
- POST /plugins/okf/admin/preview
- POST /plugins/okf/admin/community
- POST /plugins/okf/admin/community/:slug
- POST /plugins/okf/admin/entries
@ -58,6 +58,15 @@ Default state: enabled
- Side effects: Usually read-only.
- 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
- Purpose: Processes the okf plugin administration action for community.

View File

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

View File

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

View File

@ -9,7 +9,6 @@ category: Plugin
tags: plugin, throne_wishlist
generated: true
editable: false
updated_at: "2026-06-25T12:06:11.231Z"
---
# Throne Wishlist
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
generated: true
editable: false
updated_at: "2026-06-25T12:06:11.231Z"
---
# Welcome Messages
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",
"express": "^4.19.2",
"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"
},
"engines": {
@ -55,6 +56,15 @@
"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": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@ -219,9 +229,9 @@
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@ -232,7 +242,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@ -243,9 +253,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@ -349,50 +359,20 @@
}
},
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 0.8"
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"readable-stream": "^3.0.2",
"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": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -429,10 +409,10 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/date-fns": {
@ -659,6 +639,12 @@
"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": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@ -669,14 +655,14 @@
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@ -695,7 +681,7 @@
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@ -773,16 +759,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@ -904,9 +890,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@ -988,11 +974,14 @@
"node": ">= 0.10"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
"node_modules/isomorphic-ws": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/jake": {
"version": "10.9.4",
@ -1012,9 +1001,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
@ -1099,9 +1088,9 @@
}
},
"node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@ -1119,18 +1108,6 @@
"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": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@ -1144,22 +1121,22 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"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.",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 6.0.0"
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"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": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -1230,6 +1198,47 @@
"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": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -1270,9 +1279,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/picocolors": {
@ -1307,12 +1316,6 @@
"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": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -1337,12 +1340,13 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@ -1503,14 +1507,14 @@
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@ -1522,13 +1526,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@ -1734,6 +1738,18 @@
"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": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@ -1827,10 +1843,11 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@ -1846,15 +1863,6 @@
"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": {
"start": "node src/main.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": {
"node": ">=18"
@ -19,7 +27,8 @@
"ejs": "^3.1.10",
"express": "^4.19.2",
"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"
}
}

View File

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

View File

@ -164,7 +164,7 @@
<% if (isAdmin) { %>
<section class="card">
<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">
<h3>Rate limits</h3>
<div class="form-grid rate-limit-grid">
@ -371,11 +371,11 @@
<div class="modal" id="delete-lobby-modal" aria-hidden="true">
<div class="modal-backdrop" data-modal-close></div>
<div class="modal-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-lobby-title">
<h3 id="delete-lobby-title">Delete lobby?</h3>
<p>This removes the lobby configuration and stops auto-creating rooms from it.</p>
<h3 id="delete-lobby-title">Mark lobby for deletion?</h3>
<p>The lobby is removed only after you save the settings and complete the safety timer.</p>
<div class="modal-actions">
<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>
@ -437,6 +437,7 @@
const addButton = document.getElementById("add-lobby");
const container = document.getElementById("lobby-sections");
const template = document.getElementById("lobby-template");
const settingsForm = document.querySelector("[data-auto-vc-settings]");
if (!addButton || !container || !template) {
return;
}
@ -492,6 +493,7 @@
field.disabled = true;
});
updateHeaders();
if (settingsForm) settingsForm.action = "/plugins/auto-vc/settings/remove";
};
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>Announcement channel</th><td><%= diagnostics.channel.message %></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>
</table>
</div>
@ -37,8 +37,8 @@
<div class="field">
<label>Gift mode</label>
<select name="gift_mode">
<option value="automatic" <%= config.gift_mode === "automatic" ? "selected" : "" %>>automatic</option>
<option value="manual" <%= config.gift_mode === "manual" ? "selected" : "" %>>manual</option>
<option value="automatic" <%= config.gift_mode === "automatic" ? "selected" : "" %>>Send automatically</option>
<option value="manual" <%= config.gift_mode === "manual" ? "selected" : "" %>>Staff sends manually</option>
</select>
</div>
<div class="field">
@ -53,8 +53,8 @@
<div class="field">
<label>Feb 29 handling</label>
<select name="leap_day_policy">
<option value="feb28" <%= config.leap_day_policy === "feb28" ? "selected" : "" %>>feb28</option>
<option value="mar1" <%= config.leap_day_policy === "mar1" ? "selected" : "" %>>mar1</option>
<option value="feb28" <%= config.leap_day_policy === "feb28" ? "selected" : "" %>>Celebrate on February 28</option>
<option value="mar1" <%= config.leap_day_policy === "mar1" ? "selected" : "" %>>Celebrate on March 1</option>
</select>
</div>
<div class="field full">
@ -69,7 +69,7 @@
<% } else { %>
<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 class="field full">
<button type="submit" class="button">Save settings</button>
@ -109,7 +109,7 @@
</label>
</div>
<div class="field profile-actions">
<button type="submit" class="button subtle">Save</button>
<button type="submit" class="button subtle">Save message</button>
</div>
</form>
<div class="profile-actions">
@ -117,9 +117,9 @@
<input type="hidden" name="pool" value="fullYear" />
<button type="submit" class="button subtle">Duplicate</button>
</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" />
<button type="submit" class="button subtle">Remove</button>
<button type="submit" class="button danger">Remove</button>
</form>
</div>
<% }) %>
@ -158,7 +158,7 @@
</label>
</div>
<div class="field profile-actions">
<button type="submit" class="button subtle">Save</button>
<button type="submit" class="button subtle">Save message</button>
</div>
</form>
<div class="profile-actions">
@ -166,9 +166,9 @@
<input type="hidden" name="pool" value="partialYear" />
<button type="submit" class="button subtle">Duplicate</button>
</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" />
<button type="submit" class="button subtle">Remove</button>
<button type="submit" class="button danger">Remove</button>
</form>
</div>
<% }) %>

View File

@ -22,8 +22,8 @@
</div>
</form>
<% if (birthday) { %>
<form method="post" action="/plugins/birthday/profile/unset">
<button type="submit" class="button subtle">Remove birthday</button>
<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 danger">Remove birthday</button>
</form>
<% } %>
<% } else { %>

View File

@ -6,6 +6,10 @@ const express = require("express");
const multer = require("multer");
const EventEmitter = require("events");
const { ensureUserForIdentity } = require("../../src/services/users");
const {
cleanupUploadedFiles,
validateUploadedFile
} = require("../../src/services/upload-security");
const PLUGIN_ID = "economy-framework";
const LEGACY_STEM = ["echo", "nomy"].join("");
@ -218,6 +222,7 @@ module.exports = {
fs.mkdirSync(uploadDir, { recursive: true });
const upload = multer({
dest: uploadDir,
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 10, parts: 12 },
fileFilter: (_req, file, cb) => {
if (file.mimetype === "image/png") {
return cb(null, true);
@ -225,6 +230,31 @@ module.exports = {
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();
router.use("/assets", express.static(uploadDir));
@ -464,18 +494,18 @@ module.exports = {
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) {
return deny(res);
}
if (!req.file) {
req.session.flash = { type: "error", message: "Upload a PNG icon." };
next();
}, currencyIconUpload, (req, res) => {
if (req.currencyIconUploadError) {
req.session.flash = { type: "error", message: req.currencyIconUploadError };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
const ext = path.extname(req.file.originalname || "").toLowerCase();
if (ext && ext !== ".png") {
fs.rmSync(req.file.path, { force: true });
req.session.flash = { type: "error", message: "Only PNG files are allowed." };
if (!req.file) {
req.session.flash = { type: "error", message: "Upload a PNG icon." };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
const filename = `currency-${Date.now()}-${crypto.randomUUID()}.png`;

View File

@ -203,7 +203,7 @@
<div class="field">
<label>Upload PNG icon</label>
<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>
<button type="submit" class="button">Upload icon</button>
</form>
@ -454,8 +454,8 @@
<% events.forEach((event) => { %>
<li>
<span><strong><%= event.name %></strong> (<%= event.amount %>)</span>
<form method="post" action="/plugins/economy-framework/events/<%= event.id %>/delete">
<button type="submit" class="button subtle">Delete</button>
<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 danger">Delete</button>
</form>
</li>
<% }) %>
@ -523,7 +523,7 @@
<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="modal-actions">
<button type="submit" class="button">Save</button>
<button type="submit" class="button">Save responses</button>
</div>
</form>
</div>

View File

@ -196,7 +196,7 @@
<button type="submit" class="button">Restore</button>
</form>
<% } 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>
</form>
<% } %>
@ -244,7 +244,7 @@
</label>
</div>
<div class="field full">
<button type="submit" class="button">Save</button>
<button type="submit" class="button">Save expression</button>
</div>
</form>
</td>

View File

@ -522,6 +522,15 @@ class AiProvider {
force_through_reason: gateDecision?.forced ? gateDecision.reason_code : null,
internal_generated_length: initialText.length + String(finalText || "").length,
fallback_reason: fallbackReason,
feedback_context_snapshot: buildFeedbackContextSnapshot({
contextBlocks,
correctionContext,
repoContext,
contextDiagnostics,
controllerDecision,
exposedTools: toolExposure.exposed,
selectedTool
}),
stage_timings: {
deterministic_ms: gateDecision?.deterministic_ms || 0,
gate_ms: gateDecision?.gate_ms || 0,
@ -841,6 +850,49 @@ function redactPrompt(value) {
.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 = {
AiProvider,
isInScope,
@ -849,5 +901,6 @@ module.exports = {
isExactHelpShortcut,
normalizeHistory,
normalizeInferenceDiagnostics,
resolveOutputBudget
resolveOutputBudget,
buildFeedbackContextSnapshot
};

View File

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

View File

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

View File

@ -58,7 +58,8 @@ class CorrectionStore {
verified_at: null,
active: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
updated_at: new Date().toISOString(),
linked_okf_path: null
};
const store = this.read();
store.entries.unshift(entry);
@ -142,6 +143,14 @@ class CorrectionStore {
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) {
if (this.getConfig()?.improvement?.corrections_enabled === false) return [];
const role = normalizeRole(input.role);

View File

@ -2,6 +2,7 @@ const fs = require("fs");
const path = require("path");
const AdmZip = require("adm-zip");
const { resolveData, ensureDataDirs } = require("./paths");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
function redact(value) {
if (Array.isArray(value)) return value.map(redact);
@ -26,7 +27,7 @@ function createDiagnostic(input) {
function persistDiagnostic(input) {
ensureDataDirs();
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`);
return diagnostic;
}

View File

@ -4,6 +4,7 @@ const path = require("path");
const { spawn } = require("child_process");
const AdmZip = require("adm-zip");
const { resolveData } = require("./paths");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
class DownloadManager {
constructor(onEvent) {
@ -144,7 +145,7 @@ class DownloadManager {
job.state = "installing";
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_variant: runtimeMetadata?.backend_variant || null,
version: runtimeMetadata?.version || null,
@ -155,7 +156,7 @@ class DownloadManager {
size: dependency.size || 0
})),
installed_at: new Date().toISOString()
}, null, 2)}\n`);
});
} finally {
if (installCallbackStarted && typeof afterInstall === "function") {
await afterInstall({ job, finalDir, staging, runtimeMetadata });

View File

@ -1,5 +1,5 @@
const fs = require("fs");
const crypto = require("crypto");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
const { resolveData } = require("./paths");
const { roleOf } = require("./permissions");
@ -13,7 +13,10 @@ const FEEDBACK_TAGS = Object.freeze([
"should_clarify",
"bad_code",
"wrong_scope",
"wrong_tool_usage"
"wrong_tool_usage",
"bad_retrieval",
"bad_response",
"missing_knowledge"
]);
const FEEDBACK_KINDS = Object.freeze(["instruction_based", "strict_correction"]);
@ -36,11 +39,14 @@ class FeedbackStore {
platform: clean(input.platform, 80) || "webui",
model: clean(input.model, 200),
timestamp: validDate(input.timestamp) || new Date().toISOString(),
rating: input.rating === "up" || input.feedback_tag === "good" ? "up" : "down",
feedback_tag: tag,
feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind)
? input.feedback_kind
: "instruction_based",
optional_correction: clean(input.optional_correction, 16000),
context_snapshot: normalizeContextSnapshot(input.context_snapshot),
linked_okf_correction: null,
status: "pending",
submitted_by: String(actor?.id || "anonymous"),
reviewed_by: null,
@ -59,8 +65,10 @@ class FeedbackStore {
return entry;
}
list({ page = 1, pageSize = 20, status = "" } = {}) {
const filtered = this.read().entries.filter((entry) => !status || entry.status === status);
list({ page = 1, pageSize = 20, status = "", viewerRole = "admin" } = {}) {
const filtered = this.read().entries
.filter((entry) => roleRank(viewerRole) >= roleRank(entry.role))
.filter((entry) => !status || entry.status === status);
return paginate(filtered, page, pageSize);
}
@ -85,7 +93,7 @@ class FeedbackStore {
}
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.");
}
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) {
const store = this.read();
const before = store.entries.length;
@ -180,13 +197,7 @@ function paginate(rows, pageValue, pageSizeValue) {
}
function atomicJson(file, value) {
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
try {
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
fs.renameSync(tmp, file);
} finally {
fs.rmSync(tmp, { force: true });
}
writeJsonAtomicSync(file, value);
}
function clean(value, max) {
@ -202,11 +213,42 @@ function validDate(value) {
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 = {
FEEDBACK_KINDS,
FEEDBACK_TAGS,
FeedbackStore,
improvementAccess,
normalizeContextSnapshot,
paginate,
atomicJson
};

View File

@ -1,5 +1,6 @@
const fs = require("fs");
const { resolveData } = require("./paths");
const { writeJsonAtomicSync, writeTextAtomicSync } = require("../../../src/services/safe-files");
const historyFile = () => resolveData("metrics", "history.jsonl");
const stateFile = () => resolveData("metrics", "summary.json");
@ -118,7 +119,7 @@ function record(entry) {
});
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`);
}
function report() {
@ -176,6 +177,10 @@ function workHistoryPage(query = {}, page = 1, pageSize = 20) {
.filter((work) => matchesWorkFilters(work, filters));
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) {
const safePage = Math.max(1, Number.parseInt(page, 10) || 1);
const safeSize = Math.max(1, Math.min(100, Number.parseInt(pageSize, 10) || 15));
@ -202,12 +207,12 @@ function applyRetention() {
if (!rows.length) return;
const keepIds = retainedWorkIds(groupWorkRows(rows), retention);
if (!keepIds) return;
const kept = rows.filter((row) => {
const id = row.request_id || row.work_id;
return !id || keepIds.has(id);
const kept = rows.filter((row, index) => {
const id = rowWorkId(row, index);
return keepIds.has(id);
});
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() {
if (Math.random() > 0.02) return;
@ -225,9 +230,8 @@ function readHistoryRows() {
}
function groupWorkRows(rows) {
const groups = new Map();
for (const row of rows) {
const id = row.request_id || row.work_id;
if (!id) continue;
rows.forEach((row, index) => {
const id = rowWorkId(row, index);
if (!groups.has(id)) {
groups.set(id, {
work_id: id,
@ -237,6 +241,8 @@ function groupWorkRows(rows) {
source: row.origin || row.platform || "webui",
user_id: row.user_id || "",
role: row.role || "",
model: row.model || row.model_id || "",
provider: row.provider || row.backend || "",
internal_mode: row.controller_complexity || "",
okf_retrieval: row.okf_retrieval_depth || "",
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.user_id = row.user_id || group.user_id;
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.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;
@ -283,7 +291,7 @@ function groupWorkRows(rows) {
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.events.push(workEvent(row));
}
});
return [...groups.values()]
.map((group) => {
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_confidence", "okf_match_count", "context_block_count", "prompt_tokens",
"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]]));
}
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 = {}) {
return {
q: String(query.work_q || query.q || "").trim().toLowerCase(),
@ -474,6 +576,9 @@ module.exports = {
history,
historyPage,
workHistoryPage,
workHistoryExport,
buildWorkHistoryExport,
groupWorkRows,
slowRequestsPage,
paginateRows,
isValidTiming,

View File

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

View File

@ -3,6 +3,7 @@ const path = require("path");
const crypto = require("crypto");
const { resolveData } = require("./paths");
const { assertToolId } = require("./tool_repo_client");
const { runFileOperationWithRetries } = require("../../../src/services/safe-files");
const REQUIRED_FIELDS = Object.freeze([
"tool_id",
@ -46,12 +47,12 @@ class ToolInstaller {
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.");
return this.stageAndSwap(toolId, true);
return this.stageAndSwap(toolId, true, options);
}
async stageAndSwap(toolId, updating) {
async stageAndSwap(toolId, updating, options = {}) {
assertToolId(toolId);
const id = crypto.randomUUID();
const stageBase = path.join(this.stagingRoot, id);
@ -64,23 +65,37 @@ class ToolInstaller {
const remoteMetadata = validateToolDirectory(stageDir, toolId);
if (updating) preserveConfiguredPaths(target, stageDir, remoteMetadata);
let movedCurrent = false;
let installedReplacement = false;
try {
if (updating) {
fs.renameSync(target, backup);
fileOperation(() => fs.renameSync(target, backup), target, "tool_backup");
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) {
fs.rmSync(target, { recursive: true, force: true });
if (movedCurrent && fs.existsSync(backup)) fs.renameSync(backup, target);
if (installedReplacement && fs.existsSync(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;
}
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 };
} finally {
fs.rmSync(stageBase, { recursive: true, force: true });
if (fs.existsSync(backup) && !fs.existsSync(target)) fs.renameSync(backup, target);
else fs.rmSync(backup, { recursive: true, force: true });
if (fs.existsSync(backup) && !fs.existsSync(target)) {
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)) {
const metadataFile = path.join(directory, "tool_info.json");
const readmeFile = path.join(directory, "readme.md");

View File

@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
const { resolveData } = require("./paths");
const { registerManagedTool } = require("./tool_registry");
@ -272,15 +273,7 @@ class ToolLoader {
}
writeState(state) {
fs.mkdirSync(path.dirname(this.stateFile), { recursive: true });
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);
}
writeJsonAtomicSync(this.stateFile, state);
}
}

View File

@ -93,8 +93,14 @@ class ToolManager {
const wasEnabled = this.loader.isEnabled(toolId);
if (wasEnabled) await this.loader.disable(toolId, { persist: false });
try {
const result = await this.installer.update(toolId);
if (wasEnabled) await this.loader.enable(toolId, { persist: false });
const result = await this.installer.update(toolId, {
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);
return result;
} catch (error) {

View File

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

View File

@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
class ToolSettings {
constructor(options = {}) {
@ -41,11 +42,7 @@ class ToolSettings {
}
}
const file = settingsFile(local.dir);
fs.mkdirSync(path.dirname(file), { recursive: true });
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);
writeJsonAtomicSync(file, next, { mode: 0o600 });
return this.describe(toolId);
}
@ -57,16 +54,7 @@ class ToolSettings {
Object.entries(schema).map(([key, field]) => [key, normalizeValue(field.default, field, key)])
);
const file = settingsFile(local.dir);
fs.mkdirSync(path.dirname(file), { recursive: true });
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);
}
writeJsonAtomicSync(file, values, { mode: 0o600 });
return this.describe(toolId);
}

View File

@ -1,6 +1,7 @@
const fs = require("fs");
const path = require("path");
const { resolveData } = require("./paths");
const { writeTextAtomicSync } = require("../../../src/services/safe-files");
class TrainingExporter {
constructor({ feedback, corrections, outputDir }) {
@ -26,7 +27,7 @@ class TrainingExporter {
input: "",
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 };
}
}

View File

@ -1,6 +1,7 @@
const fs = require("fs");
const path = require("path");
const express = require("express");
const { writeJsonAtomicSync } = require("../../src/services/safe-files");
const { ensureDataDirs, resolveData } = require("./backend/paths");
const { getConfig, saveConfig, getRuntimeState } = require("./backend/config_manager");
const { detectHardware, estimateAllocation, performanceTuningHints } = require("./backend/hardware");
@ -871,6 +872,16 @@ module.exports = {
});
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) => {
const permission = canUseAssistant({
user: req.session.user,
@ -956,7 +967,8 @@ module.exports = {
origin: originContext.origin,
platform: originContext.platform,
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) {
@ -1005,9 +1017,11 @@ module.exports = {
platform: "webui",
model: req.body.model,
timestamp: req.body.timestamp,
rating: req.body.rating,
feedback_tag: req.body.feedback_tag,
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);
return res.status(201).json({ success: true, id: entry.id });
} catch (error) {
@ -1269,7 +1283,8 @@ module.exports = {
reviews: feedbackStore.list({
page: req.query.review_page,
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 }),
evalCases: evalStore.list({ page: req.query.eval_page, pageSize: 15 }),
@ -1298,6 +1313,8 @@ module.exports = {
const access = improvementAccess(req.session.user, config);
if (!access.allowed) return deniedImprovement(res);
try {
const review = feedbackStore.get(req.params.id);
if (!canReviewAiFeedback(review, access)) return deniedImprovement(res);
const action = cleanText(req.body.action, 30);
if (action === "flag" && access.can_flag) {
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);
} else if (action === "reject" && access.can_approve) {
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) {
feedbackStore.edit(req.params.id, req.body, req.session.user);
} else if (action === "export" && access.can_export) {
feedbackStore.markExportApproved(req.params.id, req.session.user);
} else if (action === "delete" && access.can_delete) {
feedbackStore.delete(req.params.id);
} else {
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) => {
const access = improvementAccess(req.session.user, config);
if (!access.can_implement) return deniedImprovement(res);
@ -1342,12 +1381,22 @@ module.exports = {
} else if (target === "training_export") {
feedbackStore.markExportApproved(review.id, req.session.user);
} else {
correctionStore.createFromFeedback(review, {
const correction = correctionStore.createFromFeedback(review, {
...req.body,
target,
explicitly_safe: req.body.explicitly_safe === "on",
enabled: req.body.enabled === "on"
}, 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.");
} catch (error) {
@ -1380,8 +1429,6 @@ module.exports = {
});
} else if (action === "toggle" && access.can_edit) {
correctionStore.setEnabled(req.params.id, req.body.enabled === "on");
} else if (action === "delete" && access.can_delete) {
correctionStore.delete(req.params.id);
} else {
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) => {
const access = improvementAccess(req.session.user, config);
if (!access.can_run_evals) return deniedImprovement(res);
@ -1555,6 +1613,42 @@ function boundedNumber(value, min, max, fallback) {
function cleanText(value, 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) {
req.session.flash = { type, message };
return res.redirect(`/plugins/${PLUGIN_ID}`);
@ -2061,7 +2155,7 @@ function writeCommandsManifest(pluginDir, config) {
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;

View File

@ -51,6 +51,8 @@
.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-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 input { flex: 1 1 180px; }
.lumi-ai-feedback button { cursor: pointer; font-weight: 700; }

View File

@ -279,11 +279,29 @@
const controls = document.createElement("form");
controls.className = "lumi-ai-feedback";
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");
select.setAttribute("aria-label", "Feedback tag");
for (const tag of [
"good",
"bad",
"bad_retrieval",
"bad_response",
"missing_knowledge",
"wrong_link",
"hallucinated",
"too_generic",
@ -316,9 +334,11 @@
const submitFeedback = document.createElement("button");
submitFeedback.type = "submit";
submitFeedback.textContent = "Send feedback";
controls.append(select, kind, correction, submitFeedback);
controls.addEventListener("submit", async (event) => {
event.preventDefault();
details.append(select, kind, correction, submitFeedback);
controls.append(quickActions, details);
const sendFeedback = async ({ tag, rating, correctionText = "", feedbackKind = "instruction_based" }) => {
helpful.disabled = true;
needsWork.disabled = true;
submitFeedback.disabled = true;
try {
const response = await trackedFetch(`${endpoint}/assistant/feedback`, {
@ -326,18 +346,36 @@
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...context,
feedback_tag: select.value,
feedback_kind: kind.value,
optional_correction: correction.value.trim()
rating,
feedback_tag: tag,
feedback_kind: feedbackKind,
optional_correction: correctionText
})
});
const data = await readResponseJson(response);
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) {
helpful.disabled = false;
needsWork.disabled = false;
submitFeedback.disabled = false;
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 });
item.append(controls);
};

View File

@ -9,9 +9,4 @@
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.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.confirmLabel = "Delete tool";
const remove = button("Delete", "danger");
remove.type = "submit";
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(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;
await assert.rejects(() => manager.update("lumi_ai_weather"), /required|missing/i);
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 { RuntimeManager, combinedResourceEstimate, runCaptured, buildRuntimeArgs } = require("../backend/runtime_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 {
buildControllerDecision,
@ -30,7 +30,7 @@ const modelManifest = require("../models_manifest.json");
const runtimeManifest = require("../runtime_manifest.json");
const storage = require("../backend/storage");
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 { AiRateLimiter, mergeLimits } = require("../backend/rate_limits");
const { PLATFORM_DEFAULTS, buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("../backend/commands");
@ -127,7 +127,7 @@ async function run() {
user_id: "history-user",
role: "admin",
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
});
recordMetric({
@ -172,6 +172,16 @@ async function run() {
assert.equal(workPage.entries[0].final_tokens, 40);
assert.equal(workPage.entries[0].delivered_tokens, 35);
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 panelDiagnostic = new AssistantPanelDiagnostics(panelTemplate);
@ -211,14 +221,20 @@ async function run() {
assert(settingsTemplate.includes("Improvement Center"));
assert(settingsTemplate.includes("work.has_truncation"));
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");
for (const control of ["Review queue", "Save Corrections", "Run all evals", "Export instruction JSONL", "Export DPO JSONL"]) {
assert(improvementTemplate.includes(control));
}
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");
for (const tag of FEEDBACK_TAGS) assert(assistantFeedbackScript.includes(`"${tag}"`));
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");
for (const marker of ["preparing_install", "installing", "Stopping runtimes", "Downloading dependency"]) {
assert(settingsFrontendScript.includes(marker));
@ -252,13 +268,28 @@ async function run() {
origin: "webui",
platform: "webui",
model: "test-model",
rating: "down",
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" });
assert.deepEqual(
Object.keys(review).filter((key) => /prompt|context|reasoning/i.test(key)),
[]
);
assert.equal(review.rating, "down");
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" });
assert.equal(feedbackStore.get(review.id).status, "verified");
feedbackStore.setStatus(review.id, "approved", { id: "admin" });
@ -284,6 +315,10 @@ async function run() {
explicitly_safe: true,
enabled: true
}, { 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(correctionStore.match({
message: review.user_message,

View File

@ -32,18 +32,18 @@
<section class="ai-band" id="reviews">
<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">
<% ["", "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>
<% }) %>
</div>
</div>
<div class="improvement-list">
<% reviews.entries.forEach((review) => { %>
<article class="improvement-card">
<article class="improvement-card" id="review-<%= review.id %>">
<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>
</header>
<div class="improvement-pair">
@ -51,6 +51,18 @@
<div><span>Assistant answer</span><pre><%= review.assistant_answer %></pre></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><% } %>
<% 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><% } %>
<div class="improvement-actions">
<% if (access.can_flag) { %>
@ -66,6 +78,10 @@
</form>
<% } %>
<% 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 %>">
<input type="hidden" name="action" value="approve" />
<button class="button" type="submit">Approve</button>
@ -80,10 +96,12 @@
<input type="hidden" name="action" value="export" />
<button class="button subtle" type="submit"><%= review.export_approved ? "Export approved" : "Approve for export" %></button>
</form>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" data-improvement-confirm="Delete this feedback record?">
<input type="hidden" name="action" value="delete" />
<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">
<button class="button danger" type="submit">Delete</button>
</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>
</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>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>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>Platform scope</label><input name="permission_platform" value="<%= review.platform || "any" %>" /></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 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_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>
</tr>
<% if (access.can_edit) { %>

View File

@ -103,7 +103,7 @@
]
}) %>
</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" />
<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>
@ -258,7 +258,7 @@
<div><span><%= category.replace("_", " ") %></span><strong><%= formatBytes(bytes) %></strong></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="runtime_archives" /> Runtime archives</label>
<label><input type="checkbox" name="categories" value="logs" /> Old logs</label>
@ -727,6 +727,8 @@
<div class="ai-work-prompt">
<strong>Original prompt</strong>
<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 class="table-wrap">
<table class="table ai-work-events">
@ -773,7 +775,7 @@
<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) %>/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>
</tr>
<% }) %>

View File

@ -2,6 +2,11 @@ const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const multer = require("multer");
const {
cleanupUploadedFiles,
safeDownloadFilename,
validateUploadedFiles
} = require("../../src/services/upload-security");
const PLUGIN_ID = "moderation";
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);
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) => {
@ -145,21 +178,37 @@ module.exports = {
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) {
return res.redirect("/");
}
const isAdmin = Boolean(req.session.user?.isAdmin);
const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod);
if (!isMod) {
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();
if (actionType === "kick") {
cleanupUploadedFiles(req.files);
req.session.flash = {
type: "info",
message: "Kick actions are coming soon."
@ -169,6 +218,7 @@ module.exports = {
const target = resolveTarget(db, req.body);
if (!target) {
cleanupUploadedFiles(req.files);
req.session.flash = { type: "error", message: "Target not found." };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
@ -176,6 +226,7 @@ module.exports = {
const reasonShort = (req.body.reason_short || "").trim();
const reasonDetail = (req.body.reason_detail || "").trim();
if (!reasonShort || !reasonDetail) {
cleanupUploadedFiles(req.files);
req.session.flash = {
type: "error",
message: "Both summary and detailed reasons are required."

View File

@ -217,7 +217,8 @@
</div>
<div class="field full">
<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>
<button type="submit" class="button">Submit action</button>
</form>
@ -309,7 +310,7 @@
<td><%= new Date(note.created_at).toLocaleString() %></td>
<% if (isAdmin) { %>
<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>
</form>
</td>

View File

@ -118,10 +118,10 @@
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text">Permanent</span>
</label>
<button type="submit" class="button subtle">Update</button>
<button type="submit" class="button subtle">Update duration</button>
</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>
</form>
</div>

View File

@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
const { writeTextAtomicSync } = require("../../../src/services/safe-files");
const KNOWLEDGE_SCOPES = Object.freeze(["corrections", "community", "plugins", "core"]);
const SCOPE_PRIORITY = Object.freeze({
@ -26,8 +27,9 @@ const PLACEHOLDER_SUGGEST_RESERVED_KEYS = new Set([
"updated_at"
]);
const SEARCH_STOPWORDS = new Set([
"a", "an", "and", "are", "about", "describe", "for", "identify", "is", "me",
"of", "please", "tell", "the", "to", "what", "who", "was", "were"
"a", "an", "and", "are", "about", "can", "could", "describe", "do", "does", "for",
"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();
@ -102,6 +104,7 @@ function searchFileKnowledge({ query = "", user, limit = 5, rootDir = process.cw
const access = accessForUser(user);
if (!access.authenticated) return [];
const tokens = tokenSet(query);
if (String(query || "").trim() && !tokens.size) return [];
const entries = resolveVisibleKnowledgePlaceholders(loadKnowledgeEntries(rootDir)
.filter((entry) => canSeeKnowledgeEntry(entry, access))
);
@ -207,6 +210,7 @@ function listCommunityKnowledgeFiles(rootDir = process.cwd()) {
status: entry.status,
visibility: entry.visibility,
priority: entry.priority,
category: entry.category,
tags: entry.tags,
editable: entry.editable,
generated: entry.generated,
@ -216,6 +220,18 @@ function listCommunityKnowledgeFiles(rootDir = process.cwd()) {
.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) {
const entry = loadKnowledgeEntries(rootDir, { includeHidden: true })
.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 markdown = serializeKnowledgeFile(metadata, values.body || "");
fs.writeFileSync(filePath, markdown);
writeTextAtomicSync(filePath, markdown);
knowledgeIndexCache.clear();
parseKnowledgeFile(filePath, root, { includeHidden: true });
return getCommunityKnowledgeFile(rootDir, slug);
@ -253,7 +269,7 @@ function saveCorrectionKnowledgeFile(rootDir = process.cwd(), values = {}) {
const filePath = path.join(root, "corrections", `${slug}.md`);
const metadata = normalizeCorrectionFileValues(values, slug);
const markdown = serializeKnowledgeFile(metadata, values.body || "");
fs.writeFileSync(filePath, markdown);
writeTextAtomicSync(filePath, markdown);
knowledgeIndexCache.clear();
const entry = parseKnowledgeFile(filePath, root, { includeHidden: true });
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 textTokens = tokenSet(text);
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);
return {
id: entry.id,
@ -584,9 +609,14 @@ function tokenSet(value) {
const cleaned = cleanText(value, 4000);
const expanded = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
const tokens = new Set();
for (const token of `${cleaned} ${expanded}`.toLowerCase().split(/[^a-z0-9_]+/)) {
if (token.length < 2 || SEARCH_STOPWORDS.has(token)) continue;
tokens.add(token);
for (const variant of [cleaned, expanded]) {
const words = variant.toLowerCase().split(/[^a-z0-9]+/)
.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;
}
@ -663,6 +693,7 @@ module.exports = {
knowledgeRoot,
listKnowledgeFiles,
listCommunityKnowledgeFiles,
listCorrectionKnowledgeFiles,
listKnowledgePlaceholders,
loadKnowledgeEntries,
migrateSingleBracePlaceholders,

View File

@ -2,19 +2,19 @@ const fs = require("fs");
const path = require("path");
const { ensureKnowledgeDirs, knowledgeRoot } = require("./file_knowledge");
const { writeTextAtomicSync } = require("../../../src/services/safe-files");
function generateKnowledgeFiles(rootDir = process.cwd()) {
const root = ensureKnowledgeDirs(rootDir);
const generatedAt = new Date().toISOString();
const written = [];
const coreFile = path.join(root, "core", "lumi-core.md");
const coreMarkdown = buildCoreKnowledge(rootDir, generatedAt);
const coreMarkdown = buildCoreKnowledge(rootDir);
if (writeGeneratedFile(coreFile, coreMarkdown)) {
written.push(path.relative(rootDir, coreFile));
}
for (const plugin of discoverPlugins(rootDir)) {
const pluginFile = path.join(root, "plugins", `${slugify(plugin.id)}.md`);
const pluginMarkdown = buildPluginKnowledge(plugin, generatedAt);
const pluginMarkdown = buildPluginKnowledge(plugin);
if (writeGeneratedFile(pluginFile, pluginMarkdown)) {
written.push(path.relative(rootDir, pluginFile));
}
@ -22,7 +22,7 @@ function generateKnowledgeFiles(rootDir = process.cwd()) {
return written;
}
function buildCoreKnowledge(rootDir, generatedAt) {
function buildCoreKnowledge(rootDir) {
const packageJson = readJson(path.join(rootDir, "package.json"));
const readme = readText(path.join(rootDir, "README.md"));
const routes = discoverCoreRoutes(rootDir);
@ -37,8 +37,7 @@ function buildCoreKnowledge(rootDir, generatedAt) {
category: "Core",
tags: "core, routes, commands, settings",
generated: true,
editable: false,
updated_at: generatedAt
editable: false
}) + [
`# ${packageJson.name || "Lumi Core"}`,
"",
@ -64,7 +63,7 @@ function buildCoreKnowledge(rootDir, generatedAt) {
].filter((line) => line !== "").join("\n");
}
function buildPluginKnowledge(plugin, generatedAt) {
function buildPluginKnowledge(plugin) {
const routeLines = plugin.routes.length
? plugin.routes.map((route) => `- ${route.method ? `${route.method.toUpperCase()} ` : ""}${route.path}`).join("\n")
: "- No plugin routes detected.";
@ -82,8 +81,7 @@ function buildPluginKnowledge(plugin, generatedAt) {
category: "Plugin",
tags: ["plugin", plugin.id, ...(plugin.keywords || [])].join(", "),
generated: true,
editable: false,
updated_at: generatedAt
editable: false
}) + [
`# ${plugin.name || plugin.id}`,
"",
@ -221,8 +219,9 @@ function discoverCommandTriggers(source) {
function writeGeneratedFile(filePath, content) {
if (!canOverwriteGeneratedFile(filePath)) return false;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${content.trim()}\n`);
const output = `${content.trim()}\n`;
if (fs.existsSync(filePath) && fs.readFileSync(filePath, "utf8") === output) return false;
writeTextAtomicSync(filePath, output);
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 VISIBILITY_VALUES = Object.freeze(["user", "mod", "admin"]);
const REVIEW_STATES = Object.freeze(["draft", "review_pending", "approved", "rejected"]);
const VISIBILITY_RANK = Object.freeze({ user: 0, mod: 1, admin: 2 });
function ensureTables(db) {
db.exec(`
@ -80,29 +81,75 @@ function accessForUser(db, user) {
const okfPermission = okfPermissionForUser(db, user?.id);
const isAdmin = Boolean(user?.isAdmin);
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 {
authenticated: Boolean(user),
isAdmin,
isMod,
okfPermission,
canEdit: isAdmin || ["edit", "edit_review", "edit_review_implement"].includes(okfPermission),
canReview: isAdmin || ["edit_review", "edit_review_implement"].includes(okfPermission),
canImplement: isAdmin || okfPermission === "edit_review_implement",
canManagePermissions: isAdmin,
maxVisibility: isAdmin ? "admin" : isMod ? "mod" : "user"
capabilities,
canEdit: capabilities.edit_content,
canReview: capabilities.review,
canImplement: capabilities.implement,
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 } = {}) {
if (!access.authenticated || !entry || entry.deleted_at) return false;
if (access.isAdmin) return true;
if (management && access.canEdit) return true;
if (access.capabilities.read_admin_fields) 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.visibility === "admin") return false;
if (entry.visibility === "mod" && !access.isMod) return false;
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) {
const base = {
id: entry.id,
@ -119,10 +166,10 @@ function publicEntry(entry, access) {
updated_at: entry.updated_at,
published_at: entry.published_at
};
if (access.isMod || access.isAdmin) {
if (access.capabilities.read_mod_fields) {
base.moderator_markdown = entry.moderator_markdown;
}
if (access.isAdmin || access.canEdit) {
if (access.capabilities.read_admin_fields) {
base.admin_markdown = entry.admin_markdown;
base.ai_facts_markdown = entry.ai_facts_markdown;
base.source_links = parseJsonArray(entry.source_links_json);
@ -153,9 +200,10 @@ function getEntryBySlug(db, slug, user, options = {}) {
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));
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) {
@ -165,7 +213,7 @@ function createEntry(db, values, actor) {
const entry = normalizeValues(values, {
actor,
existing: null,
canImplement: access.canImplement,
access,
now
});
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)"
).run(entry);
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) {
const access = accessForUser(db, actor);
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));
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 next = normalizeValues(values, {
actor,
existing: current,
canImplement: access.canImplement,
access,
now
});
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"
).run(next);
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 = "") {
const access = accessForUser(db, actor);
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 now = Date.now();
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"
).run(next);
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
.prepare("SELECT * FROM okf_versions WHERE entry_id = ? ORDER BY version_number DESC")
.all(entryId)
.map((row) => ({
...row,
previous: parseJsonObject(row.previous_json),
next: parseJsonObject(row.next_json)
}));
.map((row) => {
const { previous_json, next_json, changed_by, ...metadata } = row;
return {
...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 = "") {
const access = accessForUser(db, actor);
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);
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
.prepare("SELECT * FROM okf_versions WHERE entry_id = ? AND version_number = ?")
.get(entryId, Number(versionNumber));
@ -287,11 +342,11 @@ function restoreVersion(db, entryId, versionNumber, actor, note = "") {
aliases_json: JSON.stringify(splitLines(snapshot.aliases || [])),
summary: cleanText(snapshot.summary, 800),
user_markdown: cleanText(snapshot.user_markdown, 24000),
moderator_markdown: cleanText(snapshot.moderator_markdown, 24000),
admin_markdown: cleanText(snapshot.admin_markdown, 24000),
ai_facts_markdown: cleanText(snapshot.ai_facts_markdown, 24000),
source_links_json: JSON.stringify(cleanLinkList(snapshot.source_links || [])),
visibility: normalizeChoice(snapshot.visibility, VISIBILITY_VALUES, "user"),
moderator_markdown: access.capabilities.read_mod_fields ? cleanText(snapshot.moderator_markdown, 24000) : current.moderator_markdown,
admin_markdown: access.capabilities.read_admin_fields ? cleanText(snapshot.admin_markdown, 24000) : current.admin_markdown,
ai_facts_markdown: access.capabilities.read_admin_fields ? cleanText(snapshot.ai_facts_markdown, 24000) : current.ai_facts_markdown,
source_links_json: access.capabilities.read_admin_fields ? JSON.stringify(cleanLinkList(snapshot.source_links || [])) : current.source_links_json,
visibility: allowedVisibility(snapshot.visibility, access, current.visibility),
status: normalizeChoice(snapshot.status, STATUS_VALUES, "draft"),
review_state: normalizeChoice(snapshot.review_state, REVIEW_STATES, "draft"),
updated_by: String(actor.id),
@ -315,7 +370,7 @@ function restoreVersion(db, entryId, versionNumber, actor, note = "") {
actor,
note || `Restored version ${Number(versionNumber)}.`
);
return normalizeEntry(restored);
return publicEntry(restored, access);
}
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));
}
function listPermissions(db) {
function listPermissions(db, user) {
if (!accessForUser(db, user).capabilities.manage_permissions) return [];
return db
.prepare(
"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) {
const access = accessForUser(db, user);
if (!access.authenticated) return [];
if (!access.capabilities.read_user_fields) return [];
return listEntries(db, { q: query }, user)
.filter((entry) => entry.status === "published" && entry.review_state === "approved")
.slice(0, limit)
@ -361,7 +417,12 @@ function searchForAi(db, query, user, limit = 5) {
category: entry.category,
visibility: entry.visibility,
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)
.join("\n\n")
.slice(0, 4000),
@ -383,16 +444,16 @@ function matchesFilters(entry, filters, access, options) {
entry.tags_json,
entry.aliases_json
];
if (access.isMod || access.isAdmin || options.management) 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_mod_fields) visibleText.push(entry.moderator_markdown);
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);
}
function normalizeValues(values, { actor, existing, canImplement, now }) {
const status = canImplement
function normalizeValues(values, { actor, existing, access, now }) {
const status = access.capabilities.implement
? normalizeChoice(values.status, STATUS_VALUES, existing?.status || "draft")
: existing?.status || "draft";
const reviewState = canImplement
const reviewState = access.capabilities.implement
? normalizeChoice(values.review_state, REVIEW_STATES, existing?.review_state || "draft")
: "review_pending";
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))),
summary: cleanText(values.summary ?? existing?.summary, 800),
user_markdown: cleanText(values.user_markdown ?? existing?.user_markdown, 24000),
moderator_markdown: cleanText(values.moderator_markdown ?? existing?.moderator_markdown, 24000),
admin_markdown: cleanText(values.admin_markdown ?? existing?.admin_markdown, 24000),
ai_facts_markdown: cleanText(values.ai_facts_markdown ?? existing?.ai_facts_markdown, 24000),
source_links_json: JSON.stringify(cleanLinkList(values.source_links ?? parseJsonArray(existing?.source_links_json))),
visibility: normalizeChoice(values.visibility, VISIBILITY_VALUES, existing?.visibility || "user"),
moderator_markdown: access.capabilities.read_mod_fields ? cleanText(values.moderator_markdown ?? existing?.moderator_markdown, 24000) : existing?.moderator_markdown || "",
admin_markdown: access.capabilities.read_admin_fields ? cleanText(values.admin_markdown ?? existing?.admin_markdown, 24000) : existing?.admin_markdown || "",
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: access.capabilities.read_admin_fields ? JSON.stringify(cleanLinkList(values.source_links ?? parseJsonArray(existing?.source_links_json))) : existing?.source_links_json || "[]",
visibility: allowedVisibility(values.visibility, access, existing?.visibility || "user"),
status,
review_state: reviewState,
reviewed_by: existing?.reviewed_by || null,

View File

@ -24,10 +24,12 @@ const {
ensureKnowledgeDirs,
getCommunityKnowledgeFile,
listCommunityKnowledgeFiles,
listCorrectionKnowledgeFiles,
loadKnowledgeEntries,
migrateSingleBracePlaceholders,
registerKnowledgePlaceholderDefinitions,
saveCommunityKnowledgeFile,
saveCorrectionKnowledgeFile,
searchFileKnowledge
} = require("./backend/file_knowledge");
const { generateKnowledgeFiles } = require("./backend/generate_knowledge");
@ -39,6 +41,10 @@ const PLUGIN_ID = "okf";
module.exports = {
id: PLUGIN_ID,
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);
ensureKnowledgeDirs(process.cwd());
if (!getSetting("okf_single_brace_placeholder_migration_v1", false)) {
@ -88,7 +94,11 @@ module.exports = {
global.lumiFrameworks.okf = {
search: searchOkfForAi,
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);
@ -119,12 +129,18 @@ module.exports = {
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) => {
try {
validateOkfPlaceholderFields({
body: req.body.body
}, 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() });
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`);
@ -139,10 +155,11 @@ module.exports = {
validateOkfPlaceholderFields({
body: req.body.body
}, 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,
existing_slug: req.params.slug
});
}, existing));
registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() });
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`);
@ -189,7 +206,7 @@ module.exports = {
router.post("/admin/entries/:slug/versions/:version/restore", requireOkfManagement(db), (req, res) => {
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.");
const entry = restoreVersion(
db,
@ -258,7 +275,15 @@ module.exports = {
role: "public",
authRequired: true,
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 () => {
global.lumiFrameworks?.ai?.unregisterContext?.(PLUGIN_ID);
@ -392,7 +417,8 @@ async function resolveOkfEntryPlaceholders(entry, user, placeholders) {
}
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 = {
q: req.query.q || "",
status: req.query.status || "",
@ -401,13 +427,18 @@ function renderAdmin(req, res, db) {
};
const entries = listEntries(db, filters, 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 communityFiles = listCommunityKnowledgeFiles(process.cwd());
const selectedCommunity = req.query.community ? getCommunityKnowledgeFile(process.cwd(), req.query.community) : null;
const systemFiles = listSystemKnowledgeFiles(process.cwd());
const selected = req.query.edit ? getEditableEntry(db, req.query.edit, req.session.user) : null;
const communityFiles = listCommunityKnowledgeFiles(process.cwd()).filter((entry) => canReadOkfVisibility(access, entry.visibility));
const requestedCommunity = req.query.community ? getCommunityKnowledgeFile(process.cwd(), req.query.community) : null;
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 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 versions = selected ? listVersions(db, selected.id) : [];
const versions = selected ? listVersions(db, selected.id, req.session.user) : [];
res.render(view("admin"), {
title: "OKF Management",
activeTab,
@ -418,11 +449,13 @@ function renderAdmin(req, res, db) {
selectedCommunity,
systemFiles,
selectedSystemFile,
correctionFiles,
selectedCorrection,
versions,
permissions: listPermissions(db),
permissions: listPermissions(db, req.session.user),
levels: PERMISSION_LEVELS,
statuses: STATUS_VALUES,
visibilityValues: VISIBILITY_VALUES,
visibilityValues: VISIBILITY_VALUES.filter((value) => canReadOkfVisibility(access, value)),
reviewStates: REVIEW_STATES,
categories: categoriesFor(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.community) return "community";
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()) {
@ -449,6 +484,24 @@ function listSystemKnowledgeFiles(rootDir = process.cwd()) {
.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) {
const value = String(key || "");
return files.find((file) =>
@ -463,11 +516,6 @@ function view(name) {
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) {
if (req.session.user?.isAdmin) return next();
return denied(res);

View File

@ -8,6 +8,7 @@ const {
accessForUser,
createEntry,
ensureTables,
getEditableEntry,
getEntryBySlug,
grantPermission,
listEntries,
@ -23,6 +24,7 @@ const {
ensureKnowledgeDirs,
getCommunityKnowledgeFile,
listCommunityKnowledgeFiles,
listCorrectionKnowledgeFiles,
loadKnowledgeEntries,
listKnowledgePlaceholders,
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("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("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 mod = { id: "mod-1", isMod: true };
const user = { id: "user-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/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-search"));
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 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");
assert(okfPluginSource.includes("preferSpecificKnowledgeChunks"));
assert(okfPluginSource.includes("trimForContext"));
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: "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(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, {
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", mod).moderator_markdown.includes("Moderators"), 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.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(publicAdminContext.some((item) => item.facts.includes("Admin-only implementation notes.")));
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, {
title: "Draft from editor",
@ -144,12 +192,16 @@ updateEntry(db, "draft-from-editor", {
user_markdown: "Updated draft content.",
change_note: "Editor update."
}, 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.");
assert.equal(restored.summary, "Editor draft");
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);
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 });
assert.equal(identityResults[0].id, "community.people");
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];
assert(commandUserResult.facts.includes("The public currency is coins."));
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.path, "knowledge/corrections/feedback-correction.md");
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");
fs.writeFileSync(currencyKnowledgePath, [
"---",
@ -393,6 +448,12 @@ assert(generatedPlugin.includes("Test Plugin"));
assert(generatedPlugin.includes("### GET /plugins/test-plugin/settings"));
assert(generatedPlugin.includes("Inputs:"));
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"), [
"---",
"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"));
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);
assert.equal(accessForUser(db, editor).canEdit, false);

View File

@ -1,14 +1,17 @@
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
<section class="card">
<%- include("../../../src/web/views/partials/page-header", {
eyebrow: "Administration",
pageTitle: "OKF Management",
description: "Manage role-gated knowledge entries, review state, version history, and OKF-specific editing permissions."
eyebrow: okfAccess.canManagePermissions ? "Administration" : "Community contribution",
pageTitle: okfAccess.canManagePermissions ? "OKF Management" : "Contribute to OKF",
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">
<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=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>
<% if (activeTab === "general") { %>
<form method="get" action="/plugins/okf/admin" class="log-controls">
@ -49,8 +52,10 @@
</form>
<% } else if (activeTab === "community") { %>
<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>
<% } 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">
<% 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>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>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>
</form>
<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>
<tbody>
<% 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><%= file.category || "Community" %></td>
<td><span class="badge"><%= file.status %></span></td>
@ -185,6 +191,7 @@
<option value="<%= value %>" <%= selectedCommunity && selectedCommunity.visibility === value ? "selected" : "" %>><%= value %></option>
<% }) %>
</select>
<span class="hint">Controls the minimum role allowed to retrieve or view this file.</span>
</div>
<div class="field">
<label>Priority</label>
@ -197,8 +204,13 @@
</div>
<div class="field full">
<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>
<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>
<% if (selectedCommunity && (selectedCommunity.generated || !selectedCommunity.editable)) { %>
<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>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>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>
</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>
@ -248,7 +261,7 @@
<thead><tr><th>File</th><th>Scope</th><th>Category</th><th>Status</th><th>Visibility</th></tr></thead>
<tbody>
<% 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><%= file.scope %></td>
<td><%= file.category || "General" %></td>
@ -277,6 +290,46 @@
</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) { %>
<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">
@ -309,29 +362,32 @@
<span class="hint">Use comma-separated tags. Existing tags appear as suggestions while typing.</span>
</div>
<div class="field">
<label>Visibility</label>
<label>Who can see this entry?</label>
<select name="visibility">
<% visibilityValues.forEach((value) => { %>
<option value="<%= value %>" <%= selected && selected.visibility === value ? "selected" : "" %>><%= value %></option>
<% }) %>
</select>
<span class="hint">Sets the minimum role that can find, view, or retrieve this entry.</span>
</div>
<div class="field">
<label>Status</label>
<label>Publishing status</label>
<select name="status" <%= okfAccess.canImplement ? "" : "disabled" %>>
<% statuses.forEach((status) => { %>
<option value="<%= status %>" <%= selected && selected.status === status ? "selected" : "" %>><%= status %></option>
<% }) %>
</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><% } %>
</div>
<div class="field">
<label>Review state</label>
<label>Review progress</label>
<select name="review_state" <%= okfAccess.canImplement ? "" : "disabled" %>>
<% reviewStates.forEach((state) => { %>
<option value="<%= state %>" <%= selected && selected.review_state === state ? "selected" : "" %>><%= state %></option>
<% }) %>
</select>
<span class="hint">Tracks editorial approval separately from whether an entry is published.</span>
</div>
<div class="field">
<label>Aliases / related questions</label>
@ -344,25 +400,34 @@
</div>
<div class="field full">
<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>
<% if (okfAccess.capabilities.read_mod_fields) { %>
<div class="field full">
<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>
<% } %>
<% if (okfAccess.capabilities.read_admin_fields) { %>
<div class="field full">
<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 class="field full">
<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>
<span class="hint">Stored now for future AI retrieval integration. The public UI only shows this to admins/editors.</span>
<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">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 class="field full">
<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>
<span class="hint">Add one supporting web link, in-app path, or section link per line. Unsafe URL schemes are ignored.</span>
</div>
<% } %>
<div class="field full">
<label>Change note</label>
<input name="change_note" placeholder="Optional note for version history" />
@ -377,6 +442,7 @@
<details class="feedback-metadata">
<summary>Role preview</summary>
<div class="stats-grid">
<% if (okfAccess.capabilities.read_mod_fields) { %>
<article class="stat-card">
<span class="stat-label">User view</span>
<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.moderator_markdown || "_No moderator/support details yet._") %></div>
</article>
<% } %>
<% if (okfAccess.capabilities.read_admin_fields) { %>
<article class="stat-card">
<span class="stat-label">Admin/editor view</span>
<span class="stat-label">Admin view</span>
<strong><%= selected.title %></strong>
<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.moderator_markdown || "_No moderator/support details yet._") %></div>
<div class="feedback-copy-block"><%- renderMarkdown(selected.admin_markdown || "_No admin/internal details yet._") %></div>
</article>
<% } %>
</div>
<p class="hint">AI facts are stored separately for future role-aware retrieval and are not shown in normal user/mod previews.</p>
</details>
@ -412,13 +481,13 @@
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/publish" class="inline-form">
<button class="button subtle" type="submit">Publish</button>
</form>
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/archive" class="inline-form">
<button class="button subtle" type="submit">Archive</button>
<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 danger" type="submit">Archive</button>
</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>
</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>
</form>
<% } %>
@ -432,7 +501,7 @@
<% versions.forEach((version) => { %>
<article class="feedback-copy-block">
<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>
<summary>Snapshot</summary>
<pre><%= JSON.stringify(version.next, null, 2) %></pre>
@ -516,10 +585,13 @@
<label>User-facing Markdown answer</label>
<textarea name="user_markdown" rows="8" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user"></textarea>
</div>
<% if (okfAccess.capabilities.read_mod_fields) { %>
<div class="field full">
<label>Moderator/support Markdown details</label>
<textarea name="moderator_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="mod"></textarea>
</div>
<% } %>
<% if (okfAccess.capabilities.read_admin_fields) { %>
<div class="field full">
<label>Admin/internal Markdown details</label>
<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">
<label>AI-facing facts/context</label>
<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 class="field full">
<label>Source links / references</label>
<textarea name="source_links" rows="3" placeholder="One URL or local path per line"></textarea>
</div>
<% } %>
<div class="field full">
<label>Change note</label>
<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>
<% if (!grant.revoked_at) { %>
<form method="post" action="/plugins/okf/admin/permissions/<%= grant.id %>/revoke" class="inline-form">
<button class="button subtle" type="submit">Revoke</button>
<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 danger" type="submit">Revoke</button>
</form>
<% } %>
</td>
@ -626,7 +699,13 @@
let visible = 0;
rows.forEach((row) => {
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);
if (!row.hidden) visible += 1;
});
@ -639,6 +718,45 @@
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 openButtons = document.querySelectorAll("[data-okf-create-open]");

View File

@ -183,7 +183,7 @@
<button type="submit" class="button">Restore</button>
</form>
<% } 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>
</form>
<% } %>

View File

@ -90,8 +90,8 @@
</td>
<td>
<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 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" 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-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>
</td>
</tr>

View File

@ -45,7 +45,7 @@
</div>
<div class="field full">
<% if (!message.archived) { %>
<button type="submit" class="button">Save</button>
<button type="submit" class="button">Save message</button>
<% } %>
</div>
</form>
@ -60,7 +60,7 @@
<button type="submit" class="button">Restore</button>
</form>
<% } 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 %>" />
<button type="submit" class="button danger">Archive</button>
</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,
previewParts
} = require("../src/services/command-preview");
const { runAdvancedCommand } = require("../src/services/commands");
const {
conditionMatches,
selectRandomReply,
validateRandomConfig
} = require("../src/services/command-random");
const {
DELAY_MS,
issueConfirmation,
@ -23,7 +29,11 @@ async function run() {
"preview_status",
"preview_error",
"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}`);
}
@ -47,6 +57,78 @@ async function run() {
assert(previewParts(preview.preview_text, segments).some((part) => part.dynamic));
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);
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({
language: "js",
@ -77,6 +159,18 @@ async function run() {
assert.equal(readOnlyFetch.preview_status, "ready");
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({
language: "js",
code: `
@ -142,8 +236,16 @@ async function run() {
assert(layout.includes("data-destructive-confirm disabled"));
assert(commandView.includes("Preview unavailable"));
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(">Random Reply<"));
assert(commandView.includes(">Dynamic<"));
assert(commandView.includes("random_weight"));
assert(commandView.includes("{{core.command.rng}}"));
assert.equal(commandView.includes('"Advanced (" + command.language + ")"'), false);
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",
page_title: "Settings",
target_metadata: {
path: "/admin/settings; Settings; Save settings",
selector: "#save-settings",
tag: "button",
text: "Save settings",
@ -64,12 +65,15 @@ try {
});
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.diagnostics.hidden, undefined);
assert.equal(entry.diagnostics.similar_feedback_confirmation, "distinct_or_additional_context");
assert.equal(entry.diagnostics.similar_feedback_ids, "12345678-abcd");
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.listMyFeedback("user-1").length, 1);
assert.equal(feedback.supportFeedback(entry.id, { id: "user-2" }), 1);
@ -124,6 +128,31 @@ try {
scope_type: "element",
current_url: "http://localhost/admin/settings"
}, { 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({
summary: "Controls are weird",
description: "Clicking the save button does not apply changes after editing settings.",
@ -246,6 +275,7 @@ try {
userInitial: "A",
title: "Feedback review",
feedbackItems: feedback.listFeedbackForAdmin({}),
feedbackExportAiAvailable: false,
filters: { status: "", category: "", severity: "", scope: "", area: "", submitter: "", date_from: "", date_to: "", needs_action: "", sort: "last_activity" }
}, { filename: adminView });
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 === "{{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({
id: "core.main.admin_note",
@ -73,6 +93,33 @@ assert.equal(invalid.errors[0].reason, "viewer_role_forbidden");
assert.equal(rendered.ok, true);
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", "");
try {
setSetting("discord_guild_id", "guild-1");
@ -102,15 +149,25 @@ assert.equal(invalid.errors[0].reason, "viewer_role_forbidden");
outputAudience: "user"
});
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({
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,
outputAudience: "user",
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 {
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.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.");
})().catch((error) => {
console.error(error);

View File

@ -2,7 +2,10 @@ const assert = require("assert");
const fs = require("fs");
const os = require("os");
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-"));
try {
@ -26,7 +29,83 @@ try {
assert.equal(fs.existsSync(path.join(target, "stale.js")), false);
assert.equal(fs.statSync(model).size, 3 * 1024 * 1024 * 1024);
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 {
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();
verifyThemeService();
verifyUpdateCachePreserved();
verifySharedUpdateActions();
verifyHomepageEmbeds();
verifyCustomPlaceholderSettings();
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 { isPlatformEnabled } = require("./services/platforms");
const { registerCorePlaceholders } = require("./services/placeholders");
const { overlayConnectorManager } = require("./services/overlay-connectors");
const {
isSafeModeRequested,
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);
app.listen(port, () => {
console.log(`WebUI listening on http://localhost:${port}`);
@ -114,6 +121,7 @@ async function main() {
return;
}
shuttingDown = true;
await overlayConnectorManager.stop();
await stopPlugins();
await stopBot();
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 { spawn } = require("child_process");
const {
prepareJavaScriptCommand,
preparePythonCommand,
resolveJavaScriptHandler
} = require("./command-code");
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 MAX_CODE_LENGTH = 20000;
const MAX_PREVIEW_FETCH_BYTES = 128 * 1024;
@ -33,59 +38,248 @@ process.stdin.on("end", async () => {
async function previewJavaScript(code) {
const source = String(code || "");
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.");
}
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);
Object.assign(sandbox, {
ctx: contextValue,
console: Object.freeze({ log() {}, warn() {}, error() {} }),
Math: safeMath,
Date: PreviewDate,
JSON,
Promise,
URL,
URLSearchParams,
fetch: readOnlyPreviewFetch,
module: { exports: {} },
exports: {}
});
sandbox.__previewFetchBridge = readOnlyPreviewFetchBridge;
sandbox.__previewParseUrlBridge = parsePreviewUrl;
const context = vm.createContext(sandbox, {
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"
});
script.runInContext(context, { timeout: 250 });
const handler = context.run || context.module.exports || context.exports;
const handler = resolveJavaScriptHandler(context);
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 output = [...replies];
const output = Array.from(context.__previewReplies || [], normalizeResult);
const normalizedReturn = normalizeResult(returned);
if (normalizedReturn && !output.includes(normalizedReturn)) output.push(normalizedReturn);
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) {
const source = String(code || "");
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({
code: source,
code: preparePythonCommand(source),
ctx: createSerializableContext()
}));
});
}
function createMockContext(reply) {
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 = {}) {
async function readOnlyPreviewFetchBridge(resource, options = {}) {
if (typeof fetch !== "function") {
throw new Error("Preview fetch is unavailable in this runtime.");
}
@ -246,10 +373,14 @@ async function readOnlyPreviewFetch(resource, options = {}) {
if (!["GET", "HEAD"].includes(method)) {
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 timeout = setTimeout(() => controller.abort(), PREVIEW_FETCH_TIMEOUT_MS);
try {
const response = await fetch(resource, {
const response = await fetch(target, {
...options,
method,
body: undefined,
@ -262,11 +393,11 @@ async function readOnlyPreviewFetch(resource, options = {}) {
? buffer.subarray(0, MAX_PREVIEW_FETCH_BYTES)
: buffer;
const text = limited.toString("utf8");
const arrayBuffer = limited.buffer.slice(
limited.byteOffset,
limited.byteOffset + limited.byteLength
);
return Object.freeze({
const headers = Object.create(null);
response.headers.forEach((value, key) => {
headers[String(key).toLowerCase()] = String(value);
});
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
@ -274,18 +405,32 @@ async function readOnlyPreviewFetch(resource, options = {}) {
preview: true,
readOnly: true,
truncated: buffer.length > MAX_PREVIEW_FETCH_BYTES,
headers: Object.freeze({
get: (name) => response.headers.get(name)
}),
text: async () => text,
json: async () => JSON.parse(text || "null"),
arrayBuffer: async () => arrayBuffer
});
headers,
text,
bytes: Array.from(limited)
};
} finally {
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() {
return {
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,
normalizeCommandResult
} = require("./commands");
const { normalizeRandomReplies, selectRandomReply } = require("./command-random");
const { getEnabledPlatformIds, normalizePlatformSelection } = require("./platforms");
const placeholders = require("./placeholders");
@ -138,7 +139,7 @@ function createCommandRouter({ settings }) {
async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
const row = db
.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);
if (!row) {
@ -175,6 +176,26 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
} else {
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 {
const rendered = await placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",

View File

@ -3,6 +3,11 @@ const { spawn } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
const {
prepareJavaScriptCommand,
preparePythonCommand,
resolveJavaScriptHandler
} = require("./command-code");
function buildCommandContext({ platform, user, message, args, argsText }) {
return {
@ -33,12 +38,12 @@ async function runJsCommand(code, ctx) {
exports: {}
};
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 });
const handler = context.run || context.module.exports || context.exports;
const handler = resolveJavaScriptHandler(context);
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);
if (result && typeof result.then === "function") {
@ -50,7 +55,7 @@ async function runJsCommand(code, ctx) {
async function runPythonCommand(code, ctx) {
return await new Promise((resolve, reject) => {
const payload = JSON.stringify(ctx);
const encoded = Buffer.from(code, "utf8").toString("base64");
const encoded = Buffer.from(preparePythonCommand(code), "utf8").toString("base64");
const script = `
import base64, json, sys, traceback
ctx = json.loads(sys.stdin.read() or "{}")
@ -59,7 +64,7 @@ globals_dict = {}
try:
exec(code, 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)
if result is None:
sys.exit(0)

View File

@ -129,6 +129,10 @@ function migrate() {
preview_error TEXT,
preview_generated_at INTEGER,
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,
created_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 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 (
id TEXT PRIMARY KEY,
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 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
@ -270,6 +345,18 @@ function migrate() {
if (!columns.includes("preview_dynamic_segments")) {
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
.prepare("PRAGMA table_info(custom_pages)")
@ -310,6 +397,17 @@ function migrate() {
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();
}

View File

@ -3,7 +3,7 @@ const crypto = require("crypto");
const DELAY_MS = 3000;
const EXPIRES_MS = 30000;
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) {
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 currentUrl = cleanUrl(input.current_url);
const pagePath = pagePathKey(currentUrl);
const targetMetadata = sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata);
if (summary.length < 6 && description.length < 12 && !pagePath) return [];
const rows = db
.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') " +
"ORDER BY last_activity_at DESC LIMIT 150"
)
.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
.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 sameTarget = targetMetadata.path && targetMetadata.path === rowTarget.path;
const sameSelector = targetMetadata.selector && targetMetadata.selector === rowTarget.selector;
const score =
jaccardScore(queryTokens, rowTokens) +
(scopeType && row.scope_type === scopeType ? 0.25 : 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 };
})
.filter(({ score }) => score >= 0.32)
@ -291,6 +297,47 @@ function getFeedbackForAdmin(id) {
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 = {}) {
const where = [];
const params = [];
@ -315,9 +362,21 @@ function listFeedbackForAdmin(filters = {}) {
}
if (filters.area) {
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");
if (from) {
@ -585,10 +644,12 @@ function supportFeedback(id, actor) {
if (!row || row.deleted_at || row.status === "deleted") {
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(
"INSERT OR IGNORE INTO feedback_support (feedback_id, user_id, created_at) VALUES (?, ?, ?)"
).run(id, actor.id, Date.now());
return supportSummary([id], actor.id).counts.get(id) || 0;
).run(effectiveId, actor.id, Date.now());
return supportSummary([effectiveId], actor.id).counts.get(effectiveId) || 0;
}
function adminUpdateFeedback(id, input, actor) {
@ -644,6 +705,13 @@ function deleteFeedback(id, options = {}) {
}
const attachments = attachmentsFor(id);
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_status_history 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 };
}
const placeholders = cleanIds.map(() => "?").join(",");
db.prepare(
`SELECT feedback_id, COUNT(*) AS count FROM feedback_support WHERE feedback_id IN (${placeholders}) GROUP BY feedback_id`
)
const related = new Map(cleanIds.map((id) => [id, new Set([id])]));
db.prepare(`SELECT source_feedback_id, target_feedback_id FROM feedback_merges WHERE target_feedback_id IN (${placeholders})`)
.all(...cleanIds)
.forEach((row) => counts.set(row.feedback_id, row.count));
if (userId) {
db.prepare(
`SELECT feedback_id FROM feedback_support WHERE user_id = ? AND feedback_id IN (${placeholders})`
)
.all(userId, ...cleanIds)
.forEach((row) => mine.add(row.feedback_id));
.forEach((row) => related.get(row.target_feedback_id)?.add(row.source_feedback_id));
const allIds = [...new Set([...related.values()].flatMap((set) => [...set]))];
const allPlaceholders = allIds.map(() => "?").join(",");
const supporters = db.prepare(
`SELECT feedback_id, user_id FROM feedback_support WHERE feedback_id IN (${allPlaceholders})`
).all(...allIds);
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 };
}
@ -1782,7 +1852,7 @@ function enforceRateLimit(userId) {
}
}
function hydrateFeedback(row, { admin }) {
function hydrateFeedback(row, { admin, includeMerge = true }) {
const parsed = {
...decorateLabels(row),
target_metadata: parseJson(row.target_metadata_json, {}),
@ -1804,9 +1874,43 @@ function hydrateFeedback(row, { admin }) {
delete parsed.diagnostics_json;
parsed.comments = parsed.comments.filter((comment) => comment.visible_to_submitter);
}
if (includeMerge) Object.assign(parsed, mergeContext(row.id, admin));
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) {
return db
.prepare("SELECT * FROM feedback_attachments WHERE feedback_id = ? ORDER BY created_at ASC")
@ -2042,7 +2146,9 @@ module.exports = {
listMyFeedback,
listPublicFeedback,
markFeedbackViewed,
mergeFeedback,
notificationSummary,
supportFeedback,
unmergeFeedback,
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;
}
try {
const value = await definition.resolver({
const value = await withTimeout(Promise.resolve(definition.resolver({
user,
policy,
outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience),
runtimeContext,
token: match,
id
});
})), runtimeContext?.placeholder_timeout_ms);
return stringifyResolvedValue(value);
} 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;
}
});
@ -366,16 +366,31 @@ function stringifyResolvedValue(value) {
}
function replaceAsync(value, matcher, replacer) {
const matches = [];
value.replace(matcher, (...args) => {
matches.push(args);
return args[0];
const source = String(value || "");
const flags = matcher.flags.includes("g") ? matcher.flags : `${matcher.flags}g`;
const expression = new RegExp(matcher.source, flags);
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) => {
const current = await pending;
const replacement = await replacer(...args);
return current.replace(args[0], replacement);
}, Promise.resolve(value));
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
function registerCorePlaceholders() {
@ -385,7 +400,7 @@ function registerCorePlaceholders() {
field_type: "command_response",
output_audience: "user",
min_editor_role: "mod",
allowed_namespaces: ["core.main", "user.public"],
allowed_namespaces: ["core.main", "core.command", "custom", "user.public"],
max_sensitivity: "public_safe"
});
registerPlaceholders([
@ -415,6 +430,19 @@ function registerCorePlaceholders() {
example: "!",
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",
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 } = {}) {
@ -468,6 +565,15 @@ function discordPlatformPlaceholders(discordClient) {
}
};
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({
id: `${namespace}.name`,
namespace,
@ -564,6 +670,15 @@ function twitchPlatformPlaceholders(getTwitchClient) {
const namespace = "platform.twitch.channel";
const group = "Twitch channel";
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({
id: `${namespace}.primary_name`,
namespace,
@ -609,6 +724,17 @@ function youtubePlatformPlaceholders(getYouTubeClient) {
const namespace = "platform.youtube.channel";
const group = "YouTube channel";
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({
id: `${namespace}.id`,
namespace,
@ -653,12 +779,13 @@ function youtubePlatformPlaceholders(getYouTubeClient) {
async function resolveDiscordGuild(discordClient) {
const guildId = getSetting("discord_guild_id", "");
if (!discordClient || !guildId) return null;
const cached = discordClient.guilds?.cache?.get?.(guildId);
if (cached) return cached;
if (typeof discordClient.guilds?.fetch === "function") {
return discordClient.guilds.fetch(guildId).catch(() => null);
}
return null;
return discordClient.guilds?.cache?.get?.(guildId) || null;
}
function discordDataStatus(discordClient) {
const guildId = getSetting("discord_guild_id", "");
if (!guildId) return "unavailable";
return discordClient?.guilds?.cache?.get?.(guildId) ? "live_cache" : "configured_not_connected";
}
function countDiscordChannels(guild, kind) {
@ -696,12 +823,14 @@ module.exports = {
getFieldPolicy,
parsePlaceholders,
registerCorePlaceholders,
registerCustomPlaceholders,
registerFieldPolicy,
registerPlaceholder,
registerPlaceholders,
registerPlatformPlaceholders,
renderTemplate,
unregisterNamespace,
validateCustomPlaceholders,
unregisterPlaceholder,
validateTemplate,
_internals: {

View File

@ -1,6 +1,7 @@
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { writeJsonAtomicSync } = require("./safe-files");
const repoRoot = path.join(__dirname, "..", "..");
const recoveryDir = path.join(repoRoot, "data", "recovery");
@ -34,7 +35,7 @@ function writeRecoveryMarker(marker) {
updated_at: new Date().toISOString(),
...marker
};
fs.writeFileSync(markerPath, JSON.stringify(next, null, 2), "utf8");
writeJsonAtomicSync(markerPath, 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_bot_refresh_token: envString("YOUTUBE_BOT_REFRESH_TOKEN", ""),
youtube_bot_channel_id: envString("YOUTUBE_BOT_CHANNEL_ID", ""),
custom_placeholders: [],
localhost_login_username: "admin",
localhost_login_password: "admin",
theme_light_bg_1: "#ffe5c4",

View File

@ -2,6 +2,10 @@ const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const {
runFileOperationWithRetries,
writeJsonAtomicSync
} = require("./safe-files");
let AdmZip = null;
try {
AdmZip = require("adm-zip");
@ -77,7 +81,7 @@ function loadIndex() {
function saveIndex(entries) {
ensureSnapshotsDir();
fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), "utf8");
writeJsonAtomicSync(indexPath, entries);
}
async function backupDatabase(targetPath) {
@ -328,17 +332,67 @@ function addFolder(zip, folderPath, basePath, ignore) {
}
function resetCoreFiles() {
const ignore = new Set([".git", "node_modules", "data", "plugins"]);
const entries = fs.readdirSync(repoRoot, { withFileTypes: true });
resetDirectoryForFullUpdate(repoRoot, buildCorePreservePaths(repoRoot));
}
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) {
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;
}
const fullPath = path.join(repoRoot, entry.name);
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) {
return String(value || "").split(path.sep).join("/");
}
@ -421,19 +475,22 @@ function removeGeneratedPaths() {
function applyCoreFilesFromDirectory(rootPath) {
removeGeneratedPaths();
const preservePaths = buildCorePreservePaths(repoRoot);
resetDirectoryForFullUpdate(repoRoot, preservePaths);
copyDirectory(
rootPath,
repoRoot,
PRESERVE_RELATIVE_PATHS,
preservePaths,
{ base: rootPath }
);
}
function applyCorePatch(rootPath) {
const preservePaths = buildCorePreservePaths(repoRoot);
copyDirectory(
rootPath,
repoRoot,
PRESERVE_RELATIVE_PATHS,
preservePaths,
{ base: rootPath }
);
}
@ -476,19 +533,66 @@ function applyPluginFiles(rootPath, pluginId, options = {}) {
}
function replacePluginDirectory(rootPath, targetDir, options = {}) {
if (options.preserveData) {
resetPluginCode(targetDir);
} else {
fs.rmSync(targetDir, { recursive: true, force: true });
const parent = path.dirname(targetDir);
const name = path.basename(targetDir);
const nonce = `${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
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,
targetDir,
options.preserveData
? new Set(["node_modules", "data"])
: new Set(["node_modules"])
);
}
function moveDirectory(source, target, operation) {
runFileOperationWithRetries(() => fs.renameSync(source, target), {
target,
operation,
attempts: 8,
delayMs: 20
});
}
async function applyBotUpdate(zipPath, options = {}) {
@ -524,17 +628,29 @@ async function applyBotUpdate(zipPath, options = {}) {
recovery_marker_id: marker.id
}
});
let record = null;
try {
record = finalizeSnapshot(snapshot);
if (mode === "patch") {
applyCorePatch(rootPath);
} else {
applyCoreFilesFromDirectory(rootPath);
}
const record = finalizeSnapshot(snapshot);
markRecoveryMarkerComplete({ snapshot_id: record.id });
return record;
} 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);
throw error;
}
@ -575,15 +691,28 @@ async function applyPluginUpdate(zipPath, options = {}) {
recovery_marker_id: marker.id
}
});
let record = null;
try {
record = finalizeSnapshot(snapshot);
applyPluginFiles(rootPath, manifest.id, {
preserveData: snapshot.pluginExisted
});
const record = finalizeSnapshot(snapshot);
markRecoveryMarkerComplete({ snapshot_id: record.id });
return record;
} 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);
throw error;
}
@ -642,10 +771,13 @@ function restoreSnapshot(id, options = {}) {
throw new Error("Snapshot core archive missing.");
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
extractZip(restoreZip, tempDir);
const rootPath = resolveZipRoot(tempDir);
applyCoreFilesFromDirectory(rootPath);
fs.rmSync(tempDir, { recursive: true, force: true });
try {
extractZip(restoreZip, tempDir);
const rootPath = resolveZipRoot(tempDir);
applyCoreFilesFromDirectory(rootPath);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
if (entry.type === "plugin") {
@ -657,10 +789,13 @@ function restoreSnapshot(id, options = {}) {
throw new Error("Snapshot plugin archive missing.");
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
extractZip(pluginZip, tempDir);
const rootPath = resolvePluginRoot(tempDir);
applyPluginFiles(rootPath, entry.pluginId, { preserveData: true });
fs.rmSync(tempDir, { recursive: true, force: true });
try {
extractZip(pluginZip, tempDir);
const rootPath = resolvePluginRoot(tempDir);
applyPluginFiles(rootPath, entry.pluginId, { preserveData: true });
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
} else {
fs.rmSync(targetDir, { recursive: true, force: true });
}
@ -680,6 +815,8 @@ module.exports = {
applyCoreFilesFromDirectory,
applyPluginFiles,
resetPluginCode,
resetDirectoryForFullUpdate,
buildCorePreservePaths,
replacePluginDirectory,
listSnapshots,
restoreSnapshot

View File

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