From ddaffa9377e8c085ad6ae1047e27a87ba6c0d59f Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:24:46 -0500 Subject: [PATCH 1/3] feat(devvit): scaffold Devvit migration (requirements, research, architecture + compiling TS app) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-platform groundwork for moving RedditModLog from a self-hosted PRAW daemon to a Reddit-hosted Devvit app. Feasibility confirmed: getModerationLog, get/updateWikiPage, Redis (replaces SQLite), scheduler cron + onModAction trigger, and app settings cover every primitive the bot uses. - devvit-migration/docs/: requirements + feature-parity matrix, two research reports (exact Devvit API shapes; Redis model + scheduler + settings + UI), architecture (module layout, Redis key schema, execution model, gap list), STATUS, and stored Reddit API reference - devvit/: TypeScript app scaffold (storage, modlog, render, wiki, settings, menu, main + types) β€” type-checks clean (tsc --noEmit, 0 errors) - README: migration callout; anonymize-moderators invariant + 512KB cap + no-user-profile-links preserved by design Produced by a multi-agent scrum (requirements -> research -> architecture -> per-component implementation -> scaffold). Generated TS is a reviewed starting point; every Devvit API call must be validated in devvit playtest before publish. Existing Python app is untouched and remains the supported version. --- README.md | 2 + devvit-migration/README.md | 30 + devvit-migration/docs/01-requirements.md | 239 + .../docs/02-research-api-shapes.md | 183 + devvit-migration/docs/03-research-platform.md | 205 + devvit-migration/docs/04-architecture.md | 313 + devvit-migration/docs/STATUS.md | 170 + .../docs/reddit-api/getModerationLog.md | 21 + devvit/.gitignore | 22 + devvit/README.md | 142 + devvit/devvit.yaml | 12 + devvit/package-lock.json | 7780 +++++++++++++++++ devvit/package.json | 26 + devvit/src/main.ts | 200 + devvit/src/menu.ts | 220 + devvit/src/modlog.ts | 245 + devvit/src/render.ts | 423 + devvit/src/settings.ts | 387 + devvit/src/storage.ts | 406 + devvit/src/types.ts | 193 + devvit/src/wiki.ts | 211 + devvit/tsconfig.json | 10 + 22 files changed, 11440 insertions(+) create mode 100644 devvit-migration/README.md create mode 100644 devvit-migration/docs/01-requirements.md create mode 100644 devvit-migration/docs/02-research-api-shapes.md create mode 100644 devvit-migration/docs/03-research-platform.md create mode 100644 devvit-migration/docs/04-architecture.md create mode 100644 devvit-migration/docs/STATUS.md create mode 100644 devvit-migration/docs/reddit-api/getModerationLog.md create mode 100644 devvit/.gitignore create mode 100644 devvit/README.md create mode 100644 devvit/devvit.yaml create mode 100644 devvit/package-lock.json create mode 100644 devvit/package.json create mode 100644 devvit/src/main.ts create mode 100644 devvit/src/menu.ts create mode 100644 devvit/src/modlog.ts create mode 100644 devvit/src/render.ts create mode 100644 devvit/src/settings.ts create mode 100644 devvit/src/storage.ts create mode 100644 devvit/src/types.ts create mode 100644 devvit/src/wiki.ts create mode 100644 devvit/tsconfig.json diff --git a/README.md b/README.md index 921df78..823be7f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Automatically publishes Reddit moderation logs to a subreddit wiki page with modmail inquiry links. +> **πŸ§ͺ Devvit migration in progress.** A re-platform of this bot to a Reddit-hosted [Devvit](https://developers.reddit.com/) app (no self-hosting, no bot password, no SQLite) is being scaffolded under [`devvit/`](devvit/) with requirements, research, and architecture docs in [`devvit-migration/docs/`](devvit-migration/). The Python app documented below remains the supported version. See [`devvit-migration/README.md`](devvit-migration/README.md) for status. + ## Features * πŸ“Š Publishes modlogs as organized markdown tables with unique content tracking IDs diff --git a/devvit-migration/README.md b/devvit-migration/README.md new file mode 100644 index 0000000..7ba76e5 --- /dev/null +++ b/devvit-migration/README.md @@ -0,0 +1,30 @@ +# RedditModLog β†’ Devvit Migration + +Re-platforming the Python/PRAW **Reddit Modlog Wiki Publisher** into a **Devvit** app that runs on Reddit's Developer Platform β€” no self-hosted daemon, no bot-password credential, no SQLite. The published wiki output and its transparency/privacy guarantees stay recognizably identical. + +**Feasibility: confirmed.** Devvit provides every primitive the bot needs: + +| Python (PRAW) | Devvit equivalent | +| --- | --- | +| `subreddit.mod.log(limit=N)` | `reddit.getModerationLog({ subredditName, type?, limit })` | +| `subreddit.wiki[page].edit()` | `reddit.getWikiPage` / `reddit.updateWikiPage` (512 KB cap unchanged) | +| SQLite dedup + retention + hash-cache | `context.redis` (strings for dedup, sorted-set by timestamp for retention, hash for wiki-hash cache) | +| Continuous daemon (`update_interval`) | `Devvit.addSchedulerJob` + cron (1-min min granularity) + optional `onModAction` trigger | +| `config.json` / env / CLI (19 options) | Devvit app + per-install settings (`anonymize_moderators` hardcoded true) | +| Docker / s6 / systemd | Reddit-hosted (serverless, free); publish via app review | + +## Layout + +- [`docs/01-requirements.md`](docs/01-requirements.md) β€” product requirements, feature-parity matrix (MoSCoW), risk register, MVP acceptance criteria +- [`docs/02-research-api-shapes.md`](docs/02-research-api-shapes.md) β€” exact Devvit Reddit-API signatures (`getModerationLog`, `ModAction`, wiki) with Pythonβ†’Devvit field mapping +- [`docs/03-research-platform.md`](docs/03-research-platform.md) β€” Redis data model, scheduler/trigger, settings, webview/UI, publishing & limits +- [`docs/04-architecture.md`](docs/04-architecture.md) β€” module layout, Redis key schema, execution model, settings schema, phased plan, gap list +- [`docs/STATUS.md`](docs/STATUS.md) β€” what is scaffolded vs. TODO, mapped to the parity matrix +- [`docs/reddit-api/`](docs/reddit-api/) β€” stored Devvit API reference snippets +- [`../devvit/`](../devvit/) β€” the Devvit app scaffold (TypeScript; compiles clean via `tsc --noEmit`) + +## Status + +Scaffold stage: requirements + research + architecture complete; component modules (`storage`, `modlog`, `render`, `wiki`, `settings`, `menu`, `main`) written and type-checking clean. Not yet `devvit upload`-tested against a live test subreddit. See [`docs/STATUS.md`](docs/STATUS.md). + +This scaffold was produced by a multi-agent scrum (requirements β†’ research β†’ architecture β†’ per-component implementation β†’ scaffold). Treat the generated TypeScript as a reviewed starting point, not a shipped app β€” every Devvit API call should be validated in `devvit playtest` before publishing. diff --git a/devvit-migration/docs/01-requirements.md b/devvit-migration/docs/01-requirements.md new file mode 100644 index 0000000..9becd5f --- /dev/null +++ b/devvit-migration/docs/01-requirements.md @@ -0,0 +1,239 @@ +I have all the grounding I need. All primitives are confirmed: `getModerationLog` (with `type` filter and `subredditName`), `updateWikiPage`, `ModAction` fields (`createdAt`, `moderatorName`, `moderatorId`, `description`, `details`, `target`, `type`), `onModAction` trigger, scheduler with cron (`/internal/.+` endpoints, min 1-min cron), Redis (500MB/5MB limits, sorted sets, hashes, transactions), and settings (global/subreddit scopes, secrets via `isSecret`). Here is the requirements document. + +--- + +# RedditModLog β†’ Devvit Migration: Requirements Specification + +| Field | Value | +|---|---| +| Document type | Product Requirements (PO + BA) | +| Author role | Product Owner / Business Analyst | +| Status | Draft for review | +| Date | 2026-06-23 | +| Source of truth (current) | `/mnt/data/_development/RedditModLog/modlog_wiki_publisher.py` (1725 LOC, PRAW) | +| Target platform | Reddit Developer Platform (Devvit, TypeScript/Node, serverless) | +| Target code location | `/mnt/data/_development/RedditModLog/devvit/` | +| Branch | `feat/devvit-migration` | +| Devvit API version referenced | `@devvit/public-api` v0.13.x (verified via `reddit/devvit` + `reddit/devvit-docs`) | + +--- + +## 1. Product Vision + +**Today.** RedditModLog is a self-hosted Python/PRAW daemon. A subreddit's moderators run it on their own infrastructure (Docker + SQLite), supply a Reddit bot account's password credentials, and the daemon polls each subreddit's moderation log every ~10 minutes and republishes a transparency view of removals/approvals to a subreddit **wiki** page as dated markdown tables. Each table row carries a prefilled "removal inquiry" modmail link so affected users can contact mods. Moderator identities are always anonymized (enforced); user profiles are never linked. + +**Tomorrow.** The same transparency product, re-platformed as a **Devvit app** that runs *on Reddit's own infrastructure*. A moderator installs the app on their subreddit from the Apps directory, configures a few settings in the install UI, and the app maintains the wiki page automatically on a schedule β€” with **no servers, no bot account, no password credential, no Docker, no SQLite** to operate. The app inherits the installing moderator's permissions, persists state in Devvit Redis, runs work via the Devvit scheduler, and is distributed/updated through Reddit's app review pipeline. + +**Why migrate.** Eliminate operator burden (hosting, secrets, DB migrations, uptime), eliminate the password-grant security liability, gain first-class Reddit auth/permissions, and make the product installable by any moderator without engineering skills. The migration is a **re-platform, not a redesign**: the published wiki output and its transparency/privacy guarantees must remain recognizably identical. + +**Primary persona.** A subreddit moderator who wants public, auditable transparency of removal actions with a low-friction appeal path, and who does not want to run infrastructure. + +**Out of scope (non-goals).** No new analytics/dashboards; no cross-subreddit aggregation across installs (the Python "multi-subreddit single store" becomes "one isolated install per subreddit" β€” see FR-15 and Risk R-7); no migration/import of historical SQLite data into Devvit Redis (greenfield per install); no change to the wiki table schema or modmail link format beyond what platform constraints force. + +--- + +## 2. Feature Parity Matrix + +MoSCoW: **M**ust / **S**hould / **C**ould / **W**on't (this release). "Devvit can do" verified against the cited primitives. + +| # | Current Python behavior | Devvit requirement | MoSCoW | Devvit-can-do note | +|---|---|---|---|---| +| P-1 | Auth via OAuth password grant (`client_id/secret/username/password`) | Drop entirely. App acts under install permissions via `reddit.*` client; no credentials stored | **M** | Yes β€” Devvit injects auth context; password grant impossible/unneeded | +| P-2 | `subreddit.mod.log(limit=N)` poll, filtered to `wiki_actions` | `reddit.getModerationLog({ subredditName, type?, limit })` β†’ `Listing` | **M** | Verified: `getModerationLog(GetModerationLogOptions)`; supports `type` (single `ModActionType`) + `ListingFetchOptions` (limit/paging) | +| P-3 | Filter to action set `[removelink, removecomment, spamlink, spamcomment, addremovalreason, approvelink, approvecomment]` | Same default set; iterate listing and filter by `action.type` (and/or per-type fetch) | **M** | Yes β€” all 7 strings exist in `ModActionType` enum | +| P-4 | Action-field extraction: target id/type, moderator, datetime, removal reason | Map from `ModAction`: `id`, `type`, `moderatorName`, `moderatorId`, `createdAt: Date`, `description`/`details`, `target?: ModActionTarget` | **M** | Yes β€” fields confirmed in `ModAction` interface | +| P-5 | `display_id` with `P`/`C`/`U`/`A` prefix + short id | Reproduce from `target` kind + id | **S** | Yes β€” derivable; exact short-id rule is internal logic | +| P-6 | Moderator anonymization β†’ `HumanModerator`, keep `AutoModerator`/`Reddit` literal | Same mapping from `moderatorName`; **anonymize always on** | **M** | Yes β€” string mapping; see SEC-1 | +| P-7 | Removal-reason extraction priority (`description` for addremovalreason β†’ `mod_note` β†’ `details`) | Same priority over `ModAction.description`/`details` | **M** | Yes β€” `description`+`details` present; `mod_note` maps to details/description text | +| P-8 | Email censoring β†’ `[EMAIL]` regex over reason text | Identical regex applied to reason before render | **M** | Yes β€” pure string transform; see SEC-3 | +| P-9 | Pipe-escape (`\|` β†’ space) for markdown table safety | Identical sanitizer | **M** | Yes β€” pure string transform | +| P-10 | Markdown table build, grouped by date desc, columns `Time/Action/ID/Moderator/Content/Reason/Inquire` | Identical builder producing identical table layout | **M** | Yes β€” pure string assembly | +| P-11 | Content link β†’ post/comment permalink only; **never** user profile | Build permalink from `target`; reject `/u/` profiles | **M** | Yes β€” `target` exposes content permalink/id; see SEC-2 | +| P-12 | Prefilled modmail "removal inquiry" link (`/message/compose?to=/r/sub&subject=&message=`) | Identical URL builder | **M** | Yes β€” plain URL string | +| P-13 | Approval rows shown only if prior Reddit/AutoMod removal exists; combined removal+reason rows | Same correlation logic using Redis-stored prior actions | **S** | Yes β€” Redis lookup replaces SQLite `SELECT ... LIKE`; see Risk R-3 | +| P-14 | Wiki publish via `subreddit.wiki[page].edit()` | `reddit.updateWikiPage({ subredditName, page, content, reason })` | **M** | Verified: `updateWikiPage(UpdateWikiPageOptions{page,content,reason?})` + `getWikiPage` | +| P-15 | 512 KB (524288-byte) wiki cap with 90% trim-oldest-days logic | Identical byte cap + trim | **M** | Yes β€” pure size logic; cap unchanged by platform (NFR-2) | +| P-16 | SHA-256 wiki-hash cache to skip unchanged writes | Store last content hash in Redis hash; compare before write | **M** | Yes β€” `redis.hSet/hGet`; idempotency now mandatory (SEC-6) | +| P-17 | Dedup via `processed_actions(action_id UNIQUE)` | Redis string/set membership keyed by `ModAction.id` | **M** | Yes β€” `redis.get/set` or sorted-set membership | +| P-18 | Retention: delete rows older than `retention_days` (default 90) | Redis sorted set scored by `createdAt` epoch; `zRemRangeByScore` on schedule | **M** | Yes β€” sorted set + scheduled cleanup is the documented Devvit pattern | +| P-19 | Continuous daemon loop, `update_interval` 600s | `Devvit.addSchedulerJob` + cron (`/internal/...` endpoint) | **M** | Verified scheduler; **min cron granularity 1 minute** β†’ 600s expressed as `*/10 * * * *` | +| P-20 | Near-real-time freshness only at poll interval | **Add** `onModAction` trigger for low-latency incremental updates | **S** | Verified `onModAction` trigger exists; complements (not replaces) scheduler | +| P-21 | 19 config options via CLI > env > JSON precedence | App settings: `subreddit` scope (mod-editable) + `global` scope; no CLI/env/JSON | **M** | Yes β€” `devvit.json` settings, scopes `global`/`subreddit`, types string/boolean/select/multiSelect | +| P-22 | `wiki_actions` configurable + validated against known action list | `multiSelect` setting constrained to valid `ModActionType` values | **S** | Yes β€” `multiSelect` with fixed option list | +| P-23 | `ignored_moderators` (default `[AutoModerator]`) | `string`/`multiSelect` setting; filter by `moderatorName` | **S** | Yes β€” settings + in-code filter | +| P-24 | Config validation/limits (min/max clamping for 8 numeric keys) | `onValidate` setting validators + in-code clamps | **S** | Yes β€” settings support validation; clamps are code | +| P-25 | Multi-subreddit in one store; strict per-sub filtering; mixed-data guard | One install = one subreddit; Redis namespaced per install; cross-sub mixing structurally impossible | **M** | Yes β€” install isolation; see FR-15 / Risk R-7 | +| P-26 | `--test` connection check | "Run now / Test" UI action (menu/button) that does one fetch+report | **C** | Yes β€” menu action / blocks button | +| P-27 | `--force-modlog` / `--force-wiki` / `--force-all` rebuild | "Force rebuild" + "Force wiki write (bypass hash)" mod-only actions | **C** | Yes β€” menu actions invoking the same job code | +| P-28 | DB schema migrations v0β†’v5 | Redis key-schema `version` marker + forward migration on boot | **C** | Yes β€” store `schema_version` in Redis; greenfield = v1 | +| P-29 | Exponential backoff on continuous errors; `max_continuous_errors` | Rely on scheduler retry semantics + bounded per-run try/catch | **S** | Partial β€” no long-lived loop; per-invocation error handling + next-tick retry | +| P-30 | stdout/stderr logging, `logs/` dir, debug level | `console.*` logs (platform-captured); no filesystem | **M** | Yes β€” serverless: no FS; structured logging only | +| P-31 | Footer crediting GitHub repo; "Last Updated" header | Identical header/footer in generated content | **S** | Yes β€” pure string | +| P-32 | Docker/s6/PUID/Dockerfile/systemd deployment | Removed; replaced by Devvit publish + app review | **M** | Yes β€” platform-native distribution | +| P-33 | Config auto-update + `.backup` file write | Removed (no FS; settings are platform-managed) | **W** | N/A on platform | +| P-34 | Arbitrary outbound network / filesystem | Not available; not needed | **W** (out of scope) | Devvit sandbox forbids both by design | + +--- + +## 3. Functional Requirements (numbered mod stories) + +Format: **As a moderator, I want … so that …**, with acceptance criteria. + +**FR-1 β€” Install & configure.** As a mod, I want to install RedditModLog on my subreddit and set the wiki page name (default `modlog`), schedule cadence, retention days, and tracked action types in the install settings, so that the app publishes to the right place with my chosen behavior without editing code or files. +- AC: Settings UI exposes, at minimum: `wikiPage` (string, default `modlog`), `wikiActions` (multiSelect, default = the 7-action set), `ignoredModerators` (default `AutoModerator`), `retentionDays` (default 90, clamp 1–365), `updateCadence` (select/cron-backed, default ~10 min), `maxWikiEntriesPerPage` (default 1000, clamp 100–2000). +- AC: Invalid values are rejected at save (`onValidate`) or clamped at runtime with a logged warning. + +**FR-2 β€” Scheduled publish.** As a mod, I want the app to fetch new mod actions and rewrite the wiki page on a recurring schedule, so that the public modlog stays current without my intervention. +- AC: A scheduler cron job fetches via `getModerationLog`, builds content, and calls `updateWikiPage`. +- AC: Default cadence β‰ˆ10 minutes (`*/10 * * * *`); cadence is bounded β‰₯1 minute (platform minimum). + +**FR-3 β€” Incremental near-real-time update (Should).** As a mod, I want removals to appear on the wiki shortly after they happen, so that transparency is timely. +- AC: An `onModAction` trigger handler ingests qualifying actions into Redis and (debounced/coalesced) refreshes the wiki, guarded against acting on the app's own events. + +**FR-4 β€” Removal rows.** As a reader, I want each tracked removal/spam action rendered as a dated table row with time, action, short content id, anonymized moderator, content link, reason, and an inquiry link. +- AC: Output columns and ordering are byte-comparable to current Python output for the same input (date desc, time desc within date). + +**FR-5 β€” AutoModerator filter labeling.** As a mod, I want AutoModerator removals labeled `filter-`, so that automated filtering is distinguishable from human removals. +- AC: Removal actions whose moderator resolves to AutoModerator render `filter-removelink` etc. + +**FR-6 β€” Removal-reason resolution.** As a reader, I want the most meaningful reason text shown (added removal reason β†’ mod note β†’ details), so rows are informative. +- AC: Priority order matches P-7; missing reason renders `-`. + +**FR-7 β€” Combined removal+reason rows.** As a mod, I want a removal and its subsequent `addremovalreason` for the same content merged into one row, so the table isn't duplicated. +- AC: Per-content correlation merges reason into the removal row (P-13 logic) using Redis-stored context. + +**FR-8 β€” Conditional approval rows.** As a mod, I want an approval shown only when it reverses a prior Reddit/AutoMod removal, annotated "Approved removal[: reason]", so the log reflects meaningful reversals only. +- AC: Approval with no matching prior auto/Reddit removal in retention window is excluded. + +**FR-9 β€” Inquiry (modmail) link.** As an affected user, I want a one-click prefilled modmail link per row including content id, title, action type, and link, so I can appeal easily. +- AC: URL is `https://www.reddit.com/message/compose?to=/r/&subject=&message=`; subject carries `[ID: <8-char>]`; rendered as `[Contact Mods](...)`. + +**FR-10 β€” Dedup / idempotency.** As a mod, I want each mod action published at most once even when polling and the trigger overlap or a run retries, so rows aren't duplicated. +- AC: Action id present in Redis dedup store is skipped; concurrent/duplicate invocations converge to the same wiki content (SEC-6). + +**FR-11 β€” Unchanged-write skip.** As a mod, I want the wiki untouched when content hasn't changed, so I don't spam wiki revisions/rate limits. +- AC: SHA-256 of candidate content compared to Redis-cached hash; equal β†’ no `updateWikiPage` call (unless force). + +**FR-12 β€” Size cap & trim.** As a mod, I want the page to stay under Reddit's 512 KB wiki limit by trimming oldest days first, with an in-page notice, so writes never fail on size. +- AC: Candidate content >90% of 524288 bytes triggers oldest-day trimming + "N older day(s) trimmed" notice; final content ≀524288 bytes or the write is refused with a clear log. + +**FR-13 β€” Retention cleanup.** As a mod, I want stored actions older than `retentionDays` purged, so storage stays bounded. +- AC: Scheduled job removes Redis sorted-set members scored older than cutoff; default 90 days. + +**FR-14 β€” Manual actions (Could).** As a mod, I want menu/button actions to (a) run a publish now, (b) force a full rebuild, (c) force a wiki write bypassing the hash, so I can recover or test on demand. +- AC: Each action is mod-gated and reuses the scheduled job's code path. + +**FR-15 β€” Per-subreddit isolation.** As a mod, I want my install's data and output confined to my subreddit, so no other subreddit's actions can leak into my wiki. +- AC: All Redis keys are install/subreddit-namespaced; `getModerationLog` is called only with this install's `subredditName`; mixed-subreddit data is structurally impossible (replaces the Python runtime mixed-data guard). + +**FR-16 β€” Empty state.** As a reader, I want a clear "No recent moderation actions found." page when nothing qualifies, so the page is never broken/blank. +- AC: Header + empty message rendered; still hash-skippable. + +**FR-17 β€” Schema/version bootstrap.** As a maintainer, I want the app to record and forward-migrate its Redis key schema version, so future releases can evolve storage safely. +- AC: On first run, `schema_version` set; later versions run idempotent forward migrations. + +--- + +## 4. Non-Functional Requirements + +**NFR-1 β€” Platform constraints (hard).** No filesystem, no long-lived daemon, no arbitrary outbound network. All persistence via Devvit Redis; all recurring work via scheduler; all Reddit I/O via the `reddit` client. (Verified: serverless sandbox.) + +**NFR-2 β€” Wiki size limit.** 524288-byte (512 KB) cap preserved exactly; trimming threshold at 90%. This is a Reddit wiki constraint, independent of platform. + +**NFR-3 β€” Redis budget.** Each install has **500 MB** storage and **5 MB** request-size limits; transactions limited to **30 concurrent blocks / 5 s timeout**. Design dedup/retention to stay well under these; cap sorted sets to entries actually rendered; never load unbounded ranges in one request. (Verified from redis.mdx.) + +**NFR-4 β€” Scheduler granularity.** Minimum cron granularity is 1 minute; default cadence 10 min. Per-invocation work must complete within platform request limits β€” batch + cursor if a rebuild is large (documented Devvit pattern). + +**NFR-5 β€” Determinism / parity.** For an identical sequence of mod actions, generated wiki markdown must be byte-comparable to the Python output (golden-file tested), except where platform field availability forces a documented, reviewed deviation. + +**NFR-6 β€” Performance.** A normal incremental run (≀ batch size new actions) must complete within a single scheduler invocation and issue at most one `updateWikiPage` write (skipped when unchanged). + +**NFR-7 β€” Observability.** Structured `console` logging at info/warn/error; no secrets or raw moderator names in logs at default level (see SEC-1). No filesystem log dir. + +**NFR-8 β€” Maintainability / code org.** Per coding-style: many small TS modules (≀400 LOC typical, 800 max), immutable data flow, errors handled at every boundary, input from `ModAction`/settings validated before use. No single 1725-LOC file. + +**NFR-9 β€” Distribution.** Publishing requires Reddit app review; app declares only the capabilities it uses (reddit api, redis, scheduler, triggers, settings). Plan review lead time into the release. + +--- + +## 5. Security & Privacy Invariants (MUST preserve β€” non-negotiable) + +These are the product's safety contract. Any build that violates one is a release blocker. + +**SEC-1 β€” Moderator anonymization enforced.** Human moderators MUST render as `HumanModerator`; only `AutoModerator` and `Reddit` may render literally. In Python this is enforced by *refusing to start* if `anonymize_moderators=false`. **Devvit requirement:** anonymization is hard-coded (no setting to disable). Real `moderatorName`/`moderatorId` may be used internally for filtering/correlation but MUST NEVER be written to the wiki or to default-level logs. (`ModAction` exposes `moderatorName`/`moderatorId`; the guard is ours.) + +**SEC-2 β€” Never link user profiles.** Content links MUST point only to posts/comments (`/comments/...`). Any `/u/` or `/user/...` permalink MUST be rejected/suppressed. Derive links from `ModAction.target` content permalink only. + +**SEC-3 β€” Email censoring.** Removal-reason text MUST have email addresses replaced with `[EMAIL]` (existing regex) before rendering to the public wiki. + +**SEC-4 β€” Markdown injection / table safety.** All user/mod-derived text (reasons, titles) MUST be pipe-escaped and treated as untrusted before insertion into markdown tables. Validate all `ModAction`-sourced strings at the boundary. + +**SEC-5 β€” 512 KB cap as a safety limit.** The byte cap is also a guard against runaway writes; it MUST be enforced before every `updateWikiPage`. + +**SEC-6 β€” Idempotency without a daemon (NEW, elevated).** Because there is no single long-lived loop, the scheduler job AND the `onModAction` trigger can run concurrently/overlapping and runs can be retried by the platform. Dedup (per-action-id) and unchanged-hash-skip MUST make publishing idempotent: replays and overlaps converge to identical wiki content and never duplicate rows. Where correctness depends on read-check-write (e.g., "first writer wins" dedup, hash compare), use Redis transactions or single-command atomic ops per the Devvit transaction guidance. + +**SEC-7 β€” Least privilege / no stored credentials.** No password grant, no stored Reddit credentials. The app operates under install permissions. Any future secret (none required for core function) MUST use Devvit `isSecret` global settings, never plaintext settings or code. + +**SEC-8 β€” Per-install data isolation.** Redis keys MUST be namespaced per install/subreddit; no code path may read or write another subreddit's data (replaces SQLite "multi-subreddit single store" + mixed-data guard with structural isolation). + +--- + +## 6. Risk Register + +| ID | Risk | Likelihood | Impact | Mitigation | Owner | +|---|---|---|---|---|---| +| R-1 | `ModAction` lacks a field the Python relied on (e.g., exact `target_permalink`, comment-vs-post discrimination, AutoMod "filter" detection) β†’ output drift | Med | Med | Spike: dump real `ModAction` objects early; map every Python field to a `ModAction`/`target` field; document any deviation; golden-file diff (NFR-5) | Eng | +| R-2 | `getModerationLog` `type` filter is single-valued (`ModActionType`), but Python filters a 7-action set | High | Low | Fetch unfiltered listing and filter in code, OR issue one call per type and merge; verify paging/limit behavior | Eng | +| R-3 | Approval-correlation + combined-row logic depended on SQL `LIKE`/`SELECT`; Redis has no ad-hoc query | Med | Med | Maintain per-content secondary index in Redis (hash/sorted-set keyed by content id) capturing prior removal mod+reason; bound by retention | Eng | +| R-4 | Redis 500 MB / 5 MB / 30-txn limits hit on busy subreddits or large rebuilds | Low | High | Cap sorted sets to rendered entries; batch+cursor rebuilds via scheduler; TTL temp keys; user-facing fallback on write-full (NFR-3) | Eng | +| R-5 | Scheduler min cadence (1 min) + per-invocation time limit insufficient for force-full-rebuild | Med | Med | Incremental design; force-rebuild processes bounded batches with a saved cursor across ticks | Eng | +| R-6 | Trigger + scheduler concurrency causes duplicate rows / racey wiki writes | Med | High | SEC-6 idempotency (dedup store + hash skip + transaction on read-check-write); coalesce trigger-driven writes | Eng | +| R-7 | Loss of cross-subreddit single-store capability changes product semantics for multi-sub operators | Low | Low | Reframe as feature (isolation, SEC-8); document that each subreddit installs independently; no shared store | PO | +| R-8 | App review rejects/ delays publish (privacy of modlog data, wiki writes) | Med | High | Lead with privacy invariants (SEC-1/2/3) in review notes; request only needed capabilities; budget review time (NFR-9) | PO | +| R-9 | Wiki write permission/visibility differs under app identity vs bot account (page must be mod-readable/public per sub policy) | Med | Med | Verify `updateWikiPage` + page permission/listing behavior under install perms in a test sub before GA | Eng | +| R-10 | No historical SQLite import β†’ existing operators start with an empty wiki history | High | Low | Documented as greenfield-per-install; optional future backfill from live modlog on first run within retention window | PO | +| R-11 | Devvit API surface churn (pre-1.0, v0.13.x) breaks build | Med | Med | Pin `@devvit/public-api`; CI build; track changelog | Eng | +| R-12 | Email/PII regex or pipe-escape regression silently leaks data to a public page | Low | High | Port regexes verbatim; unit tests as guard; security review gate before publish | Eng | + +--- + +## 7. MVP Acceptance Criteria + +The MVP is shippable when **all** of the following hold: + +1. **Install & settings.** App installs on a test subreddit; settings (FR-1) are editable by a mod and persisted; invalid values rejected/clamped. +2. **Scheduled publish (FR-2).** A cron job fetches via `getModerationLog`, builds content, and writes via `updateWikiPage` to the configured page on the default ~10-min cadence. +3. **Output parity (NFR-5, FR-4..FR-9).** For a fixed fixture of mod actions, the generated markdown is byte-comparable to the Python output (golden file), including: 7-action default filter, date-desc/time-desc grouping, 7-column table, `filter-` AutoMod labeling, removal-reason priority, combined removal+reason rows, conditional approval rows, and prefilled modmail links. +4. **Security invariants (SEC-1..SEC-8) verified by tests:** + - No human moderator name appears in wiki output or default logs. + - No `/u/` or `/user/` link appears in any content cell. + - Emails in reasons render as `[EMAIL]`. + - All reason/title cells are pipe-safe. + - Every write is ≀524288 bytes. + - Redis keys are subreddit-namespaced; no cross-sub read/write path exists. + - No Reddit credentials are stored anywhere; no password grant. +5. **Idempotency (SEC-6, FR-10/FR-11).** Running the job twice back-to-back, and simulating an `onModAction` overlapping a scheduled run, produces no duplicate rows and at most one wiki write when content is unchanged (hash-skip proven). +6. **Size cap & trim (FR-12).** A fixture exceeding 90% of 512 KB trims oldest days, emits the trim notice, and the final write is ≀512 KB. +7. **Retention (FR-13).** A scheduled cleanup removes entries older than `retentionDays` from Redis (verified via store inspection). +8. **Empty state (FR-16).** Zero qualifying actions renders the "No recent moderation actions found." page without error. +9. **No-deviation log.** Any field that could not be reproduced from `ModAction`/`target` (R-1) is documented with the chosen fallback and signed off by PO. +10. **Buildable & reviewable.** App builds against pinned `@devvit/public-api`, declares only the capabilities it uses (reddit api, redis, scheduler, triggers, settings), and is packaged for app-review submission. + +**Deferred beyond MVP (Should/Could):** `onModAction` near-real-time trigger (FR-3) if not ready at MVP may ship in fast-follow provided the scheduler path already satisfies idempotency; manual menu actions (FR-14); SQLite history backfill (R-10). + +--- + +### Appendix A β€” Verified Devvit primitive mapping (evidence) + +| Python primitive | Devvit replacement | Evidence (repo) | +|---|---|---| +| `subreddit.mod.log(limit=N)` | `reddit.getModerationLog({subredditName, type?, ...ListingFetchOptions}) : Listing` | `reddit/devvit` `models/ModAction.ts`, `RedditClient.ts`; docs `GetModerationLogOptions.md` | +| `subreddit.wiki[p].edit(content,reason)` | `reddit.updateWikiPage({page,content,reason?})`; `getWikiPage(sub,page)` | `RedditClient.ts`, `RedditAPIClient.ts`; docs `UpdateWikiPageOptions.md` | +| action fields (`mod`, `created_utc`, `details`, target) | `ModAction.{moderatorName,moderatorId,createdAt:Date,description,details,target,type,id}` | docs `ModAction.md` | +| action type filter set | `ModActionType` enum incl. all 7 default actions | docs `ModActionType.md` | +| SQLite dedup/retention/hash-cache | Devvit Redis: strings (dedup/hash), sorted sets scored by time (retention via `zRemRangeByScore`), hashes | docs `capabilities/server/redis.mdx` (500 MB/5 MB/30-txn limits) | +| daemon loop / `update_interval` | `addSchedulerJob` + cron (`/internal/...`, min 1-min) | docs `capabilities/server/scheduler.mdx` | +| (new) low-latency updates | `onModAction` trigger | docs `capabilities/server/triggers.mdx` | +| CLI/env/JSON config | `devvit.json` settings, scopes `global`/`subreddit`, types string/boolean/select/multiSelect, secrets via `isSecret` | docs `capabilities/server/settings-and-secrets.mdx` | + +Relevant file paths: existing app `/mnt/data/_development/RedditModLog/modlog_wiki_publisher.py` (read-only, do not modify); existing config template `/mnt/data/_development/RedditModLog/config_template.json`; target Devvit code root `/mnt/data/_development/RedditModLog/devvit/` (to be created; a `devvit-migration/` directory currently exists at repo root). diff --git a/devvit-migration/docs/02-research-api-shapes.md b/devvit-migration/docs/02-research-api-shapes.md new file mode 100644 index 0000000..37b694e --- /dev/null +++ b/devvit-migration/docs/02-research-api-shapes.md @@ -0,0 +1,183 @@ +I have all the API shapes needed. Writing the reference now. + +--- + +# Devvit API Reference β€” RedditModLog Migration + +Source-of-truth: `reddit/devvit` @ `packages/reddit/src/` (verified 2026-06-23). Doc cross-refs from `reddit/devvit-docs`. All types below are quoted from the actual TypeScript sources, not docs prose. + +## 1. `getModerationLog` β€” replaces `subreddit.mod.log(limit=N)` + +```ts +// reddit.getModerationLog(options): Listing +getModerationLog(options: GetModerationLogOptions): Listing + +export type GetModerationLogOptions = ListingFetchOptions & { + subredditName: string; // REQUIRED + moderatorUsernames?: string[]; // optional mod filter (joined with ',') + type?: ModActionType; // SINGLE action type only (see note) +}; + +export type ListingFetchOptions = { + after?: string; + before?: string; + limit?: number; // default = Infinity (NOT a small default!) + pageSize?: number; // default = 100 + more?: MoreObject; +}; +``` + +Usage (from RedditClient.ts JSDoc): + +```ts +import { reddit } from '@devvit/reddit'; + +const modActions = await reddit.getModerationLog({ + subredditName: 'memes', + limit: 1000, + pageSize: 100, +}).all(); // .all() drains the Listing into ModAction[] +``` + +`Listing` is async-iterable and exposes `.all(): Promise`. `DEFAULT_PAGE_SIZE = 100`, `DEFAULT_LIMIT = Infinity`. **Always set an explicit `limit`** to mirror the Python `limit=N` β€” an unset limit will page the entire log. + +### CRITICAL DIFFERENCE β€” `type` is single-valued + +The Python app filters to a **list** of 7 `wiki_actions` (`removelink`, `removecomment`, `spamlink`, `spamcomment`, `addremovalreason`, `approvelink`, `approvecomment`). Devvit's `type?` accepts **one** `ModActionType`, not an array (it maps to the upstream `type=` query param). Two options for the migration: + +- **Fetch unfiltered** (`type` omitted), pull a page batch, and filter client-side against the `wiki_actions` set β€” matches current multi-type behavior in one call. **Recommended.** +- Or issue 7 separate `getModerationLog` calls (one per type) and merge β€” more network calls, redundant against the free serverless budget; not recommended. + +## 2. `ModAction` interface β€” field mapping + +```ts +export interface ModAction { + id: string; // "ModAction_1b1af634-..." β€” the modlog entry id + type: ModActionType; + moderatorName: string; + moderatorId: T2; // "t2_..." + createdAt: Date; // native Date (Python had to parse epoch) + subredditName: string; + subredditId: T5; // "t5_..." + description?: string; // e.g. "Page X edited" + details?: string; // e.g. removal-reason details / context + target?: ModActionTarget; +} + +export type ModActionTarget = { + id: string; // fullname: t3_/t1_/t2_/t5_... + author?: string; // username the action was taken upon + body?: string; // bodytext of the targeted item + permalink?: string; // relative permalink (no scheme/host) + title?: string; // title of the targeted item +}; +``` + +### Python-read field β†’ Devvit field + +| Python (PRAW `ModAction` / extraction) | Devvit field | Notes | +|---|---|---| +| `action.id` (dedup `action_id`) | `ModAction.id` | Format `ModAction_`; PRAW used `ModAction_` too. Use as Redis dedup key. | +| `action.action` (`action_type`) | `ModAction.type` | Enum, see Β§3. | +| `action.mod` (moderator name) | `ModAction.moderatorName` | Anonymize before render (INVARIANT). | +| `action.created_utc` (`created_at`) | `ModAction.createdAt` | Native `Date`; Python parsed epoch float. `createdAt.getTime()/1000` for Redis sorted-set score. | +| `action.subreddit` | `ModAction.subredditName` | | +| `action.target_fullname` β†’ `target_id` | `ModAction.target?.id` | `t3_=link/post (P)`, `t1_=comment (C)`, `t2_=user (U)`, else (A/other). Derive `display_id` P/C/U/A prefix from the fullname prefix. | +| `target_type` | derived from `target.id` prefix | No dedicated field β€” compute it. | +| `action.target_permalink` (`target_permalink`) | `ModAction.target?.permalink` | Relative (no `https://www.reddit.com`); prepend host for links. INVARIANT: only post/comment permalinks, never user profiles. | +| `action.target_author` (`target_author`) | `ModAction.target?.author` | Used for modmail prefill. | +| removal reason text (`removal_reason`) | `ModAction.details` (+ maybe `description`) | **FLAG β€” see below.** PRAW exposed `action.details` / `action.description`; same here. Apply email-censor + pipe-escape. | + +### MISSING / FLAGGED fields (verify against live data before relying) + +- **`removal_reason`**: There is **no dedicated `removalReason` field**. The Python code extracted the reason from `details` / `description`. Devvit exposes exactly `description?` and `details?` β€” same raw strings PRAW surfaced. For `addremovalreason` actions the human-readable reason lives in `details`. **Action: port the existing string-extraction logic verbatim onto `ModAction.details` (fallback `description`).** Confirm shape with one real `addremovalreason` entry on the target sub before shipping. +- **`target_title` / `target_body`**: `target.title` / `target.body` exist (not read by the Python app, but available if you want richer rows). +- **`target_type`**: not a field β€” must be derived from `target.id`'s fullname prefix (Python did the same from `target_fullname`). +- **No raw OAuth / API-call surface**: auth, rate-limit, and HTTP are handled by Devvit's serverless reddit client. The Python password-grant flow has **no equivalent and must be dropped** (the app runs as the install's mod context). + +## 3. `ModActionType` enum (the action "type" values) + +String-literal union (full list in `ModAction.ts`). The 7 the app cares about (`wiki_actions`) are all present: + +``` +'removelink' | 'removecomment' | 'spamlink' | 'spamcomment' +| 'addremovalreason' | 'approvelink' | 'approvecomment' +``` + +Other notable members: `banuser`, `unbanuser`, `addmoderator`, `distinguish`, `lock`, `unlock`, `sticky`, `editflair`, `createremovalreason`, `updateremovalreason`, `deleteremovalreason`, `wikirevise`, plus `dev_platform_app_*`. The Python `ignored_moderators` default (`[AutoModerator]`) is filtered on `moderatorName`, not on `type` β€” unchanged logic. + +## 4. Wiki β€” replaces `subreddit.wiki[page].edit()` + +```ts +// READ +async getWikiPage( + subredditName: string, + page: string, + revisionId?: WikiPageRevisionId +): Promise + +// WRITE (update existing) +async updateWikiPage(options: UpdateWikiPageOptions): Promise +export type UpdateWikiPageOptions = { + subredditName: string; + page: string; + content: string; // markdown + reason?: string; +}; + +// CREATE (first-time; getWikiPage throws if page absent) +async createWikiPage(options: CreateWikiPageOptions): Promise +export type CreateWikiPageOptions = { + subredditName: string; page: string; content: string; reason?: string; +}; +``` + +`WikiPage` getters: `name`, `subredditName`, `content` (markdown), `contentHtml`, `revisionId`, `revisionDate: Date`, `revisionReason`, `revisionAuthor`. Instance helpers: `page.update(content, reason)`, `page.getRevisions(...)`, `page.revertTo(revisionId)`, `page.getSettings()`, `page.updateSettings(...)`. + +### Read / write snippet + +```ts +import { reddit } from '@devvit/reddit'; + +const sub = 'mysub'; +const pageName = 'modlog'; +const markdown = buildTables(actions); // enforce 512KB / 524288-byte cap first + +// CREATE-or-UPDATE pattern (getWikiPage throws if the page doesn't exist): +let existing: WikiPage | undefined; +try { + existing = await reddit.getWikiPage(sub, pageName); +} catch { + existing = undefined; +} + +if (!existing) { + await reddit.createWikiPage({ + subredditName: sub, page: pageName, content: markdown, + reason: 'RedditModLog initial publish', + }); +} else if (existing.content !== markdown) { // hash-skip equivalent + await reddit.updateWikiPage({ + subredditName: sub, page: pageName, content: markdown, + reason: 'RedditModLog update', + }); +} +``` + +Notes for migration: +- **512KB cap still applies** β€” Devlatform does not lift Reddit's 524288-byte wiki limit. Keep the trim logic. +- **Hash-skip**: `WikiPage.content` returns the current markdown, so you can compare directly (or keep the SHA-256 cache in Redis hash). The `wiki_hash_cache` table maps to a Redis hash keyed by `subreddit:wiki_page β†’ content_hash`. +- `getWikiPage` for a non-existent page throws β€” guard with try/catch and fall back to `createWikiPage` (Python's `wiki[page].edit()` auto-created). To restrict visibility use `WikiPagePermissionLevel` (`SUBREDDIT_PERMISSIONS=0`, `APPROVED_CONTRIBUTORS_ONLY=1`, `MODS_ONLY=2`) via `updateWikiPageSettings`. + +## 5. Trigger / scheduling notes (for the orchestration layer) + +- **Scheduler**: `reddit/devvit-docs/docs/capabilities/server/scheduler.mdx` covers cron jobs β€” replaces the `update_interval` (600s) daemon loop. Declare a cron-scheduled endpoint in `devvit.json` (the modern config-file model; the older `Devvit.addSchedulerJob` API also exists). +- **`onModAction` trigger** exists (`devvit.json` `"triggers": { "onModAction": "/internal/on-mod-action" }`; proto `OnModActionDefinition`). It fires per mod action and could drive event-driven publishing instead of/alongside polling. **However**, the wiki write is the expensive, rate-limited op and the table is a full re-render β€” a debounced cron (every N min) is the cleaner mapping of the 600s loop. Recommend: **cron-driven publish**, optionally with `onModAction` only to set a "dirty" Redis flag the cron checks (avoids rewriting unchanged pages). Confirm the `ModAction` trigger payload shape (it mirrors the `ModAction` interface above) against a live event before wiring dedup off it. + +### Net migration deltas to flag for the implementer +1. `type` filter is single-valued β†’ filter the 7 `wiki_actions` **client-side**. +2. No `removalReason` field β†’ reuse Python's `details`/`description` extraction. +3. No OAuth/password-grant β†’ drop entirely (serverless mod context). +4. `target_type` is derived, not a field. +5. `limit` defaults to `Infinity` β†’ always pass an explicit limit. +6. `getWikiPage` throws on missing page β†’ create/update branch required. diff --git a/devvit-migration/docs/03-research-platform.md b/devvit-migration/docs/03-research-platform.md new file mode 100644 index 0000000..a3158fe --- /dev/null +++ b/devvit-migration/docs/03-research-platform.md @@ -0,0 +1,205 @@ +I have everything needed. Writing the report. + +--- + +# Devvit Platform Research: RedditModLog Migration + +Sources verified against `reddit/devvit-docs` (latest `docs/`) and `reddit/devvit` (`packages/reddit/src/`) on 2026-06-23. Doc commit pin: `69f35f2`. + +## 0. Architecture note β€” this is "Devvit Web", not classic Devvit + +The current platform is **Devvit Web**: a serverless Node HTTP server (Hono or Express) where capabilities are declared in **`devvit.json`** and wired to `/internal/...` POST endpoints. The classic `Devvit.addSchedulerJob` / `Devvit.addSettings` / `Devvit.addTrigger` builder API (from version-0.11 docs) is the *old* model. **Target the Devvit Web model.** Imports come from `@devvit/web/server`, `@devvit/redis`, `@devvit/web/shared`, and `@devvit/reddit`. + +The 3 Reddit calls map cleanly: +- `subreddit.mod.log(limit=N)` β†’ `reddit.getModerationLog({ subredditName, limit, pageSize, type?, moderatorUsernames? })` returns `Listing`; call `.all()` or paginate. +- `subreddit.wiki[page].edit()` β†’ `reddit.getWikiPage(sub, page, revisionId?)` + `reddit.updateWikiPage({ subredditName, page, content, reason? })` returns `WikiPage`. (Use `createWikiPage` first-time; `updateWikiPage` fails if page absent β€” catch and create.) +- OAuth password-grant β†’ **gone**. The app runs as the installed app identity on Reddit infra; no auth code, no client secret, no refresh tokens. This deletes ~all of the Python auth layer. + +`ModAction` shape (from `models/ModAction.ts`) gives you everything the Python field-extractor needs natively: `id`, `type` (`ModActionType` union β€” includes all 7 `wiki_actions` defaults: `removelink`, `removecomment`, `spamlink`, `spamcomment`, `addremovalreason`, `approvelink`, `approvecomment`), `moderatorName`, `moderatorId` (T2), `createdAt: Date`, `subredditName`, `subredditId`, `description`, `details`, and `target?: { id, author?, body?, permalink?, title? }`. The `target.permalink` is relative (no `https://www.reddit.com` prefix) β€” matches the Python "never link profiles, only post/comment permalinks" invariant since `target` only carries content permalinks. `display_id` P/C/U/A prefixing is derived from `target.id` fullname prefix (`t3_`/`t1_`) client-side as today. + +--- + +## 1. Redis: command set, limits, and the SQLite β†’ Redis data model + +### Supported commands (Devvit subset) +- **Simple:** `get`, `set`, `exists`, `del`, `type`, `rename` +- **Batch:** `mGet`, `mSet` +- **Strings:** `getRange`, `setRange`, `strLen` +- **Numbers:** `incrBy` +- **Hash:** `hGet`, `hMGet` (allowlisted β€” may be disabled), `hSet`, `hSetNX`, `hDel`, `hGetAll`, `hKeys`, `hScan`, `hIncrBy`, `hLen` +- **Sorted set:** `zAdd`, `zCard`, `zRange` (by `score`/`lex`/`rank`), `zRem`, `zScore`, `zRank`, `zIncrBy`, `zScan`, `zRemRangeByLex`, `zRemRangeByRank`, **`zRemRangeByScore`** +- **Expiration:** `expire`, `expireTime` (seconds; per-key TTL supported) +- **Transactions:** `watch` β†’ `multi`/`exec`/`discard`/`unwatch` (optimistic, WATCH-based) +- **Bitfield:** `bitfield` +- **NOT supported:** plain SETs, LISTs, pub/sub, `KEYS`/global scan, pipelining, `SCAN` over all keys. + +### Limits (verified, latest) +| Limit | Value | +|---|---| +| Storage per installation | **500 MB** | +| Request size | **5 MB** | +| Command throughput | 40,000 cmds/s per installation | +| Per-setting/value | governed by 5 MB request cap; use `redisCompressed` (gzip proxy) for large values | +| Transactions | 20–30 concurrent blocks, 5 s execution timeout | +| Hash size | ~4.2 B field-value pairs | +| `zRange` BYSCORE/BYLEX | LIMIT capped at 1000/call | +| TTL | per-key via `expire` (seconds), no max documented | + +**Critical design constraint:** Redis is **namespaced per installation (per-subreddit)** β€” there is NO shared cross-subreddit store. This *changes the Python "multi-subreddit single store" invariant*: each subreddit install has its own siloed Redis. The app is still multi-subreddit (one app, many installs), but state is naturally partitioned. This is actually simpler β€” drop the `subreddit` column entirely; it's implicit. No `KEYS` scan means **you must track collection keys explicitly** (use hashes/sorted-sets as known collection roots, never one-key-per-record that you'd later need to enumerate). + +### Concrete data model (replaces SQLite schema v5) + +All keys are implicitly per-subreddit. Per wiki page (the Python app supports a configurable wiki page name), namespace with the page name. + +**(a) Dedup β€” replaces `processed_actions(action_id UNIQUE)`** +The cheapest correct dedup is a **string key with TTL** (auto-handles 90-day retention for the dedup check itself): +```ts +const key = `seen:${actionId}`; // ModAction.id +const isNew = await redis.set(key, "1", { nx: true, expiration: ... }); +// or: if (!(await redis.exists(key))) { ...process...; await redis.set(key,"1"); await redis.expire(key, 90*86400); } +``` +Use `exists`/`set nx` for the UNIQUE-constraint semantics. TTL of `retention_days*86400` makes dedup keys self-expire, so old action IDs can recur after retention (matches Python behavior where retention prunes the table). + +**(b) Per-subreddit action records β€” replaces the row columns** +Store the rendered/extracted record in a **hash keyed by page**, field = action_id: +```ts +// hash: actions: field: value: JSON(record) +await redis.hSet(`actions:${page}`, { [actionId]: JSON.stringify(record) }); +``` +`record` carries the columns you actually render: `created_at, action_type, moderator(anon-id), target_id, target_type, display_id, target_permalink, removal_reason, target_author`. Iterate with `hScan`/`hGetAll` to rebuild the table. For large logs use `redisCompressed.hSet`. This is the "stable collection key" pattern the docs mandate (no global key scan). + +**(c) Retention β€” replaces `DELETE WHERE created_at < now-90d` via sorted set + `zRemRangeByScore`** +Maintain a time index so you can prune both the hash and the index without scanning: +```ts +// zAdd member=actionId, score=createdAt_epoch_seconds +await redis.zAdd(`actions_by_time:${page}`, { member: actionId, score: createdAtSec }); + +// retention job (scheduler): find + remove old, then hDel from the record hash +const cutoff = nowSec - retentionDays*86400; +const old = await redis.zRange(`actions_by_time:${page}`, 0, cutoff, { by: "score" }); // members < cutoff +if (old.length) { + await redis.hDel(`actions:${page}`, old.map(o => o.member)); + await redis.zRemRangeByScore(`actions_by_time:${page}`, 0, cutoff); +} +``` +This is exactly the FIFO/retention pattern the docs recommend (sorted-set timestamp score β†’ `zRange` oldest β†’ remove). Wrap the hDel+zRemRange in a `watch`/`multi`/`exec` transaction if concurrent writers are a concern, but a scheduled single-writer cleanup avoids that need. + +**(d) Wiki-hash cache β€” replaces `wiki_hash_cache` (skip unchanged writes)** +Single hash, one field per page: +```ts +// hash: wiki_hash field: value: +const prev = await redis.hGet("wiki_hash", page); +if (prev !== newHash) { + await reddit.updateWikiPage({ subredditName, page, content, reason: "modlog update" }); + await redis.hSet("wiki_hash", { [page]: newHash }); +} +``` +SHA-256 in TS: `crypto.subtle.digest` (Web Crypto is available in the Node runtime) or `node:crypto`. The 512 KB / 524288-byte wiki cap still applies β€” keep the trim-to-512KB logic verbatim. + +--- + +## 2. Scheduler vs. onModAction trigger + +### Scheduler (Devvit Web) +Declared in `devvit.json`, handled at a `/internal/scheduler/...` endpoint: +```json +"scheduler": { "tasks": { + "publish-modlog": { "endpoint": "/internal/scheduler/publish-modlog", "cron": "*/10 * * * *" } +}} +``` +- **Cron:** standard 5-field UNIX cron. **Experimental 6-field (seconds) granularity** exists (`*/30 * * * * *`). +- **Min interval:** effectively **1 minute** for standard cron (jobs run once/minute); sub-minute only via the experimental seconds field, and actual cadence depends on job duration/parallelism. +- **One-off jobs** at runtime: `scheduler.runJob({ name, data, runAt })` β†’ returns jobId; `scheduler.cancelJob(jobId)`; `scheduler.listJobs()`. +- **Limits (per installation):** max **10 live recurring actions**; `runJob()` creation rate **60/min**; delivery rate **60/min**. Request execution has a ~30 s wall-clock limit (use daisy-chained one-off jobs for long work β€” see the Redis migration example pattern). + +`update_interval=600s` β†’ `cron: "*/10 * * * *"`. This is the direct, simple replacement for the daemon loop. + +### onModAction trigger β€” it EXISTS +`onModAction` is a supported trigger (full list includes `onModAction`, `onModMail`, plus per-post/comment create/delete/report/update). Declared in `devvit.json`: +```json +"triggers": { "onModAction": "/internal/on-mod-action" } +``` +Payload is a `ModAction` (see `ModActions` model). **Caveat (from docs):** *triggers are NOT exactly-once* β€” "Triggers are not guaranteed to deliver only once... checking if content has been recently actioned before taking action again." Your Redis dedup (`seen:`) already handles this. + +### Recommendation: **cron-primary, trigger-optional (hybrid)** +- **Use cron (`*/10`) as the system of record** for publishing. It preserves the existing batched-publish semantics, the SHA-256 hash-skip, and the 512KB-trim in one place; it's resilient to missed/duplicate trigger deliveries; and it respects the wiki-write rate naturally (one write per interval, only if changed). This is the lowest-risk 1:1 port. +- **Optionally add `onModAction`** purely as an **ingest fast-path**: on each event, dedup + write the record to the `actions:` hash + `actions_by_time` zset, but **do NOT publish from the trigger** (publishing on every mod action would hammer the wiki and risk the write rate). Let cron own the wiki publish. +- If you want lower latency than 10 min without trigger complexity, just lower the cron to `*/5` or `*/2`. Given the 10-recurring-jobs cap and 60/min rate, cron alone is sufficient; the trigger is an optimization, not a requirement. **Start cron-only; add the trigger later if latency matters.** + +--- + +## 3. App settings β€” mapping the 19 Python config options + +Two scopes (declared in `devvit.json` under `settings`): +- **`global`** β€” set by the developer, shared across all installs; **secrets** live here (`isSecret: true`, CLI-only, encrypted). At least one install must exist before secrets can be set. +- **`subreddit`** β€” per-install, editable by moderators in the Install Settings UI. + +Types: `string`, `boolean`, `number`, `select` (single), `multiSelect`. Optional `validationEndpoint` (`/internal/settings/validate-*`). Read via `import { settings } from "@devvit/web/server"; await settings.get("key")`. + +**Limits:** max **2 KB per setting value**; secrets are **global-only**; secret values are not fully surfaced in CLI; `.env` only works during playtest. + +### Mapping (illustrative; the Python app has 19 opts across auth/behavior/lists) +| Python config | Devvit scope | Type | Notes | +|---|---|---|---| +| client_id / client_secret / username / password / user_agent (OAuth) | **DROP** | β€” | No auth needed; app runs as installed identity | +| subreddits (list) | **DROP** | β€” | One install per subreddit; implicit. Multi-sub = install in each sub | +| wiki_page (name) | `subreddit` | `string` | Per-install page name | +| update_interval (600) | (app) | β€” | Express as `cron` in `devvit.json`, not a runtime setting | +| retention_days (90) | `subreddit` | `number` | + `validationEndpoint` for bounds | +| wiki_actions (list, 7 defaults) | `subreddit` | `multiSelect` | options = ModActionType values | +| ignored_moderators (`[AutoModerator]`) | `subreddit` | `string` (CSV) or `multiSelect` | parse client-side | +| anonymize_moderators (**enforced true**) | **DROP / hardcode true** | β€” | Don't expose as a setting; keep the invariant in code so it can't be disabled | +| censor emails / pipe-escape toggles | hardcode | β€” | invariants, not user-facing | +| max wiki bytes (512KB) | hardcode const | β€” | platform cap, not configurable | +| db path / log level / daemon flags | **DROP** | β€” | serverless; use `console.log` + `devvit logs` | + +**Key decision:** the `anonymize_moderators=true` "refuse start if false" invariant is best preserved by **not making it a setting at all** β€” bake it in. Settings are mod-editable; a security invariant must not be toggleable. Same for the never-link-profiles rule. + +--- + +## 4. UI options, publish/review, resource limits + +### UI options +- **Menu actions** (recommended primary UI): three-dot menu items declared in `devvit.json` `menu.items[]`, with `location: comment|post|subreddit`, `forUserType: moderator`, `endpoint: /internal/menu/...`. Respond with a `UiResponse` (`showToast`, `showForm`, navigation). This is the right fit: a moderator-only **subreddit** menu item like "Publish modlog now" (force a cron-out-of-band run) and "Configure". Note the **10-minute completion window** when a mod opens a form from a `forUserType: moderator` menu action. +- **Forms** β€” declared under `forms`, opened via `showForm` from a menu endpoint; fields `string`/`number`/`boolean`/`select`. Good for an ad-hoc "republish with options" action. +- **Toasts** β€” lightweight feedback (`showToast: { text, appearance: success|neutral }`). +- **Webview / interactive post (blocks)** β€” supported but **overkill** for this app. RedditModLog has no per-post UI; its output is a wiki page. Recommend **no custom post / no webview** β€” just menu actions + a status toast. (A webview would only make sense if you wanted an in-app dashboard; the wiki page already is the UI.) + +### Publish / app-review process +- Submit via CLI: **`npx devvit publish`** (`--bump major|minor|patch`, default patch; or `--version 1.0.1`). Must add a user-facing `README.md` first. +- **Playtest** before publish: hot-reload via `npm run dev`; unpublished apps can only install on subreddits with **< 200 members**. +- Enters Reddit's **review queue**: team reviews code, example posts, and docs. Email on approval; Modmail/chat if more info needed. **Review time 1–2 business days** typically; longer for higher-risk features (payments, fetch) β€” RedditModLog uses neither, so it's low-risk. Reviews pause during certain holiday periods. +- **By default published apps are unlisted** (community-specific). For a general-purpose mod tool installable by any subreddit, run **`npx devvit publish --public`** to request App Directory listing β€” requires a detailed `README.md` (overview, installer instructions, changelog). RedditModLog is a general mod tool β†’ `--public` is appropriate. +- Compliance with **Devvit Rules** streamlines review. One rule is directly relevant: *"if your app stores user content from Reddit, remove it when deleted from Reddit."* RedditModLog stores `target_author`, `body`-derived removal reasons, and permalinks β€” **respect `onCommentDelete`/`onPostDelete` triggers** (or rely on the 90-day retention + the fact that mod-log records are mod-metadata) to stay compliant. Worth confirming during review; the anonymize-moderators invariant already aligns with privacy expectations. + +### Resource limits (consolidated) +| Resource | Limit | +|---|---| +| Redis storage / install | 500 MB | +| Redis request size | 5 MB | +| Redis throughput | 40k cmd/s | +| Redis transactions | 20–30 concurrent, 5 s timeout | +| Setting value | 2 KB | +| Recurring scheduler jobs / install | 10 | +| `runJob` create / deliver rate | 60/min each | +| Request execution wall-clock | ~30 s (daisy-chain for longer) | +| Wiki page content | 512 KB / 524288 bytes (unchanged) | +| Runtime | Node/TS serverless, no filesystem, no arbitrary outbound network except declared HTTP Fetch domains | + +--- + +## Migration summary (what changes vs. stays) + +- **Deleted entirely:** OAuth/PRAW auth layer, SQLite/DB-path config, daemon loop, multi-subreddit single-store (now per-install silos), ~5 auth config options. +- **Direct 1:1 ports:** mod-log poll β†’ `getModerationLog`; wiki edit β†’ `getWikiPage`/`updateWikiPage`; SHA-256 hash-skip; 512KB trim; dedup; 90-day retention; removal-reason extraction + email-censor + pipe-escape; modmail prefill link (build the same `/message/compose` URL string). +- **New idioms:** `devvit.json` declares scheduler/triggers/settings/menu; `/internal/...` POST handlers (Hono or Express); Redis hash+sorted-set+string+TTL model; settings via `settings.get()`. +- **Recommended stack:** cron `*/10` publish job (primary) + optional `onModAction` ingest fast-path; menu actions (moderator) for "publish now" / config; no webview/custom post; `--public` publish for App Directory listing. +- **Invariants to bake into code (not settings):** `anonymize_moderators=true`, never-link-profiles, 512KB cap, email-censor. + +Relevant local files: existing reference at `/mnt/data/_development/RedditModLog/modlog_wiki_publisher.py` (do not modify); new code target `/mnt/data/_development/RedditModLog/devvit/`. + +Sources: +- [Devvit launch guide (publish/review)](https://github.com/reddit/devvit-docs/blob/main/docs/guides/launch/launch-guide.md) +- [Developer Platform & Accessing Reddit Data – Reddit Help](https://support.reddithelp.com/hc/en-us/articles/14945211791892-Developer-Platform-Accessing-Reddit-Data) +- reddit/devvit-docs: `docs/capabilities/server/{redis,scheduler,triggers,settings-and-secrets}.mdx`, `docs/capabilities/client/menu-actions.mdx` +- reddit/devvit: `packages/reddit/src/RedditClient.ts`, `packages/reddit/src/models/ModAction.ts` diff --git a/devvit-migration/docs/04-architecture.md b/devvit-migration/docs/04-architecture.md new file mode 100644 index 0000000..9670887 --- /dev/null +++ b/devvit-migration/docs/04-architecture.md @@ -0,0 +1,313 @@ +I have all the grounding I need from the requirements and research docs. Writing the architecture markdown now. + +# RedditModLog β†’ Devvit: Architecture Specification + +| Field | Value | +|---|---| +| Document type | Technical Architecture (Lead Architect) | +| Status | Draft for build | +| Date | 2026-06-23 | +| Target | Devvit Web (`@devvit/web/server`, `@devvit/reddit`, `@devvit/redis`), TypeScript/Node serverless | +| Code root | `/mnt/data/_development/RedditModLog/devvit/` | +| Branch | `feat/devvit-migration` | +| Source of truth (legacy) | `modlog_wiki_publisher.py` (read-only reference; DO NOT modify) | + +--- + +## 0. Invariants (binding β€” carried verbatim from the Python app) + +These are non-negotiable and every module below is constrained by them: + +- **INV-1 β€” Anonymize moderators ALWAYS.** Real moderator names are mapped to `HumanModerator` before render. `AutoModerator` and `Reddit` are kept literal. There is NO config toggle (Python refused start if `anonymize_moderators=false`; here it is hardcoded `true`, the flag is dropped). Render layer must NEVER receive a real mod name in an output column. +- **INV-2 β€” NEVER link user profiles.** Only post (`t3_`) and comment (`t1_`) permalinks are emitted as content links. Any `target` whose fullname is `t2_` (user) or `t5_` (subreddit) produces NO hyperlink. Profile URLs (`/u/`, `/user/`) must never appear in output. +- **INV-3 β€” 512 KB wiki cap.** Content is hard-capped at 524288 bytes (UTF-8). Over-cap content is trimmed oldest-day-first to ≀90% of cap before write. +- **INV-4 β€” Email censor + pipe-escape.** Reason text passes the email-censor regex (`β†’ [EMAIL]`) and pipe-escape (`|` β†’ space) before entering any markdown cell. +- **INV-5 β€” Dedup by action id.** Every `ModAction.id` is processed at most once into a record. +- **INV-6 β€” Wiki hash-skip.** Never write the wiki if the SHA-256 of the rendered content equals the last written hash. +- **INV-7 β€” Default tracked actions** = `[removelink, removecomment, spamlink, spamcomment, addremovalreason, approvelink, approvecomment]`. +- **INV-8 β€” Default ignored moderators** = `[AutoModerator]` (filtered by `moderatorName`). +- **INV-9 β€” Per-subreddit isolation.** One install = one subreddit. Redis is implicitly namespaced per install; cross-subreddit data mixing is structurally impossible (drop the `subreddit` column entirely). + +--- + +## 1. Module Layout (`devvit/src/`) + +Seven implementation modules plus the `devvit.json` manifest and HTTP entrypoint. Dependency direction is strictly **downward** (no cycles): `main` β†’ {`menu`, settings, modlog, render, wiki, storage}; `modlog`/`wiki` β†’ `storage`; `render` is pure (no I/O). `storage` depends only on `@devvit/redis`. + +``` +devvit/ +β”œβ”€β”€ devvit.json # manifest: settings, scheduler, triggers, menu, permissions +β”œβ”€β”€ package.json +β”œβ”€β”€ tsconfig.json +└── src/ + β”œβ”€β”€ types.ts # shared interfaces/enums (ModRecord, AppConfig, constants) + β”œβ”€β”€ storage.ts # Redis data-access layer (Repository pattern) + β”œβ”€β”€ modlog.ts # fetch + filter + field-extract ModAction β†’ ModRecord + β”œβ”€β”€ render.ts # PURE: ModRecord[] β†’ markdown (tables, links, censor, cap) + β”œβ”€β”€ wiki.ts # read/create/update wiki page + hash-skip + trim orchestration + β”œβ”€β”€ settings.ts # settings read + validation + defaults/clamps β†’ AppConfig + β”œβ”€β”€ menu.ts # mod-only menu actions (run-now, force-rebuild, force-wiki, test) + └── main.ts # HTTP entrypoint: /internal/* handlers (scheduler, trigger, menu, validate) +``` + +`types.ts` is added (not in the prompt's file list) because every module shares `ModRecord`/`AppConfig`/constant definitions; per the coding-style rule (many small focused files, no duplication) the shared contract must live in one place rather than being re-declared. It exports types and constants only β€” no logic, no I/O. + +### 1.1 `types.ts` β€” shared contracts and constants + +**Responsibility:** Single source of truth for cross-module types and frozen constants. No runtime behavior. + +**Exports:** +- `const WIKI_BYTE_CAP = 524288;` and `const WIKI_TRIM_TARGET = Math.floor(WIKI_BYTE_CAP * 0.9);` +- `const DEFAULT_WIKI_ACTIONS: ModActionType[]` (the 7 from INV-7) +- `const DEFAULT_IGNORED_MODS = ['AutoModerator'];` +- `const ANON_LABEL = 'HumanModerator';` `const LITERAL_MODS = new Set(['AutoModerator', 'Reddit']);` +- `const SCHEMA_VERSION = 1;` +- `type DisplayKind = 'P' | 'C' | 'U' | 'A';` +- `interface ModRecord` β€” the persisted, render-ready record: + ```ts + interface ModRecord { + id: string; // ModAction.id (dedup key) + createdAtSec: number; // epoch seconds (sorted-set score) + actionType: ModActionType; + moderator: string; // ALREADY anonymized (INV-1) β€” never a real name + targetId?: string; // fullname (t3_/t1_/...) + displayKind: DisplayKind; + displayId?: string; // short id w/ P/C/U/A prefix + permalink?: string; // post/comment only (INV-2); undefined for profiles + targetAuthor?: string; // for modmail prefill + reason?: string; // RAW reason; censor+escape applied at render time + } + ``` +- `interface AppConfig` β€” resolved settings: + ```ts + interface AppConfig { + wikiPage: string; + wikiActions: ModActionType[]; + ignoredModerators: string[]; + retentionDays: number; // clamped 1..365 + maxWikiEntries: number; // clamped 100..2000 + fetchLimit: number; // explicit getModerationLog limit + subredditName: string; // from context + } + ``` + +> Design note: `ModRecord.moderator` stores the **already-anonymized** label, not the raw name. INV-1 is enforced at ingest (`modlog.ts`), so a real name can never reach Redis or render. `reason` is stored raw and sanitized at render to keep storage idempotent and let the censor regex evolve without a data migration. + +### 1.2 `storage.ts` β€” Redis Repository + +**Responsibility:** All Redis I/O. The ONLY module that imports `@devvit/redis`. Encapsulates dedup, the record collection, the time index, the wiki-hash cache, the dirty flag, and the schema-version marker. Repository pattern (per `patterns.md`): callers depend on these functions, not on Redis commands. No `KEYS`/global scan anywhere (Devvit forbids it) β€” all collections are rooted at explicit known keys. + +**Exported functions (all `async`, take a `redis` handle + `AppConfig`/`page` as needed):** + +- `markSeen(redis, page, actionId, retentionDays): Promise` β€” atomic dedup (INV-5). Uses `set(`seen:${page}:${actionId}`, "1", { nx: true, expiration: retentionDays*86400 })`; returns `true` if newly inserted (caller should process), `false` if already seen. TTL self-prunes dedup keys at retention. +- `putRecord(redis, page, rec: ModRecord): Promise` β€” `hSet(`actions:${page}`, { [rec.id]: JSON.stringify(rec) })` AND `zAdd(`actions_by_time:${page}`, { member: rec.id, score: rec.createdAtSec })`. (Wrap the two writes in `watch/multi/exec` only if the trigger + cron can interleave; with single-writer cron a plain pair is acceptable β€” see Β§3.) +- `getAllRecords(redis, page): Promise` β€” `hGetAll(`actions:${page}`)`, JSON-parse values, sort by `createdAtSec` desc. (Use `hScan` paging if field count is large; cap to `maxWikiEntries` newest.) +- `pruneRetention(redis, page, retentionDays, nowSec): Promise` β€” INV-9/P-18. `zRange(by score, 0..cutoff)` β†’ `hDel(actions, members)` β†’ `zRemRangeByScore(actions_by_time, 0, cutoff)`; returns count removed. Honor the `zRange` 1000-member LIMIT by looping until empty. +- `getWikiHash(redis, page): Promise` / `setWikiHash(redis, page, hash): Promise` β€” INV-6, single hash `wiki_hash` field=page. +- `setDirty(redis, page): Promise` / `isDirty(redis, page): Promise` / `clearDirty(redis, page): Promise` β€” `dirty:${page}` flag the trigger sets and cron consumes (Β§3). +- `getSchemaVersion(redis): Promise` / `setSchemaVersion(redis, v): Promise` / `migrate(redis): Promise` β€” P-28; greenfield = write `SCHEMA_VERSION` if absent; forward-migration hook for future versions. + +**Inputs:** `redis` handle, page name, `ModRecord`, retention/limits. +**Outputs:** booleans/records/counts. Never throws on "not found" β€” returns `undefined`/empty. + +### 1.3 `modlog.ts` β€” fetch, filter, extract + +**Responsibility:** Turn raw `getModerationLog` output into deduped, anonymized `ModRecord[]`. Owns INV-1, INV-2 (permalink gating), INV-5, INV-7, INV-8, and `display_id` derivation. Imports `@devvit/reddit` (read) and `storage` (dedup + persist). + +**Exported functions:** + +- `fetchActions(reddit, cfg: AppConfig): Promise` β€” `reddit.getModerationLog({ subredditName: cfg.subredditName, limit: cfg.fetchLimit, pageSize: 100 }).all()`. **`type` is omitted** β€” single-valued filter can't express 7 types; filter client-side (research delta #1). Always passes an explicit `limit` (research delta #5; default is `Infinity`). +- `extractRecord(action: ModAction, cfg: AppConfig): ModRecord | null` β€” PURE mapper: + - Returns `null` if `action.type βˆ‰ cfg.wikiActions` (INV-7) OR `action.moderatorName ∈ cfg.ignoredModerators` (INV-8). + - `moderator` = `anonymizeMod(action.moderatorName)` (INV-1): literal if in `LITERAL_MODS`, else `ANON_LABEL`. + - `createdAtSec` = `Math.floor(action.createdAt.getTime()/1000)`. + - `displayKind`/`displayId` derived from `action.target?.id` fullname prefix (`t3_`β†’P, `t1_`β†’C, `t2_`β†’U, else A). + - `permalink` = `action.target?.permalink` ONLY when `displayKind ∈ {P, C}` (INV-2); otherwise `undefined`. + - `reason` = `extractReason(action)` (research delta #2): priority `details` β†’ `description` (for `addremovalreason` the human reason is in `details`). Stored raw. + - `targetAuthor` = `action.target?.author`. +- `ingest(reddit, redis, cfg): Promise<{ added: number }>` β€” orchestrates: `fetchActions` β†’ for each, `extractRecord`; skip nulls; `markSeen` gate; `putRecord` for new ones. Returns count added. This is the shared ingest path used by both cron and the trigger. +- `anonymizeMod(name): string` and `deriveDisplay(targetId?): {kind, displayId?}` exported for unit testing. + +**Inputs:** `reddit` client, `redis`, `AppConfig`. +**Outputs:** `ModRecord[]` persisted via storage; counts. + +### 1.4 `render.ts` β€” PURE markdown builder + +**Responsibility:** `ModRecord[] β†’ string` (markdown). **Zero I/O, zero Reddit/Redis imports** β€” fully unit-testable, deterministic. Owns INV-3 (cap/trim), INV-4 (censor/escape), INV-2 (link emission), the table layout (P-10), modmail link (P-12), header/footer (P-31), and approval-correlation render (P-13). + +**Exported functions:** + +- `buildContent(records: ModRecord[], cfg: AppConfig, nowIso: string): string` β€” top-level. Groups by date desc, renders one table per day with columns `Time | Action | ID | Moderator | Content | Reason | Inquire`, prepends "Last Updated" header, appends GitHub-credit footer, then applies `enforceByteCap`. +- `renderRow(rec, cfg): string` β€” one table row. Calls `censorEmail` + `escapePipes` on reason; `contentLink(rec)` (emits `[displayId](https://www.reddit.com{permalink})` only when permalink present per INV-2, else plain `displayId`); `modmailLink(cfg.subredditName, rec)`. +- `censorEmail(text): string` (INV-4), `escapePipes(text): string` (INV-4) β€” pure string transforms, ported verbatim from Python regex. +- `modmailLink(sub, rec): string` β€” `https://www.reddit.com/message/compose?to=/r/${sub}&subject=...&message=...` URL-encoded (P-12). +- `enforceByteCap(markdown, dayBlocks): string` β€” INV-3. UTF-8 byte length check; if over `WIKI_BYTE_CAP`, drop oldest day-blocks until ≀ `WIKI_TRIM_TARGET`. Operates on pre-assembled day blocks so trimming is oldest-day-first. +- `contentHash(markdown): Promise` β€” SHA-256 hex via Web Crypto (`crypto.subtle.digest`) for INV-6. (Async because `subtle.digest` is async; lives here so render owns "what was rendered β†’ its hash".) + +**Inputs:** `ModRecord[]`, `AppConfig`, timestamp string. +**Outputs:** capped markdown string; hash. + +> Immutability (coding-style rule): `render.ts` never mutates input records; it maps to new strings/arrays. + +### 1.5 `wiki.ts` β€” wiki publish orchestration + +**Responsibility:** The create-or-update + hash-skip publish flow (P-14, P-16, INV-6). Imports `@devvit/reddit` and `storage` + `render` (`contentHash`). + +**Exported functions:** + +- `publish(reddit, redis, cfg, content: string, opts?: { bypassHash?: boolean }): Promise<{ wrote: boolean; reason: string }>`: + 1. `hash = await contentHash(content)`. + 2. Unless `bypassHash`: `prev = getWikiHash`; if `prev === hash` return `{ wrote:false, reason:'unchanged' }` (INV-6). + 3. `getWikiPage(sub, page)` in try/catch (research delta #6: throws if absent). + - absent β†’ `createWikiPage({...})`. + - present & `existing.content !== content` β†’ `updateWikiPage({...})`. + - present & equal β†’ skip (defensive double-check of INV-6). + 4. `setWikiHash(page, hash)`; return `{ wrote:true, reason:'created'|'updated' }`. +- `publishFromStore(reddit, redis, cfg, opts?): Promise<{ wrote, reason }>` β€” convenience: `getAllRecords` β†’ `render.buildContent` β†’ `publish`. This is the single code path cron, trigger-coalesced refresh, and menu force-write all call. + +**Inputs:** clients, `AppConfig`, content (or store-derived). +**Outputs:** `{ wrote, reason }` for logging/menu feedback. + +### 1.6 `settings.ts` β€” config resolution + validation + +**Responsibility:** Read Devvit settings, apply defaults, clamp numerics, validate, and produce a frozen `AppConfig` (P-21..P-24). Imports `@devvit/web/server` (`settings`, `context`). + +**Exported functions:** + +- `loadConfig(): Promise` β€” reads each setting via `settings.get`, applies defaults from `types.ts`, clamps `retentionDays` to `[1,365]` and `maxWikiEntries` to `[100,2000]` (log a warning on clamp, P-24), parses `ignoredModerators` (CSV/multiSelect), validates `wikiActions` βŠ† `ModActionType` (drop unknowns, P-22), reads `subredditName` from `context.subredditName`. Returns `Object.freeze(cfg)` (immutability). +- `validateRetention(value): string | void`, `validateMaxEntries(value): string | void`, `validateWikiPage(value): string | void` β€” handlers for the settings `validationEndpoint` (P-24); return an error string to reject at save, `void` to accept. + +**Inputs:** Devvit settings store + context. +**Outputs:** validated, frozen `AppConfig`; validation verdicts. + +### 1.7 `menu.ts` β€” moderator actions + +**Responsibility:** Mod-only menu/button actions (P-26, P-27), and their handler bodies. Imports clients + `modlog`/`wiki`/`settings`. + +**Exported functions:** + +- `handleRunNow(reddit, redis): Promise` β€” P-26/P-27 "Run now": `loadConfig` β†’ `ingest` β†’ `publishFromStore` β†’ toast with `{ added, wrote, reason }`. +- `handleForceRebuild(reddit, redis): Promise` β€” P-27 `--force-modlog`/`--force-all`: re-ingest (records are idempotent by id) β†’ `publishFromStore`. +- `handleForceWiki(reddit, redis): Promise` β€” P-27 `--force-wiki`: `publishFromStore(..., { bypassHash: true })` (INV-6 deliberately bypassed). +- `handleTest(reddit): Promise` β€” P-26 `--test`: one `getModerationLog` fetch of a small limit; report count + sample types; performs NO write. + +All return a Devvit `UiResponse` (toast). Each handler is restricted to moderators via the menu item's manifest config (mod-only context). + +### 1.8 `main.ts` β€” HTTP entrypoint + +**Responsibility:** The serverless HTTP server wiring `/internal/*` POST endpoints declared in `devvit.json` to handler bodies. No business logic β€” thin adapters that build `reddit`/`redis`/`context`, call into the modules, and shape responses. Owns per-invocation try/catch (P-29: no daemon loop; rely on scheduler retry + bounded error handling) and structured `console.*` logging (P-30). + +**Endpoints (each a small handler):** +- `POST /internal/scheduler/publish-modlog` β†’ `migrate` (once) β†’ `loadConfig` β†’ `pruneRetention` β†’ `ingest` β†’ `if isDirty || added>0` β†’ `publishFromStore` β†’ `clearDirty`. (Β§3) +- `POST /internal/on-mod-action` β†’ `loadConfig` β†’ `extractRecord`+`markSeen`+`putRecord` for the single event payload β†’ `setDirty`. **No wiki write here** (Β§3). +- `POST /internal/menu/run-now` | `/force-rebuild` | `/force-wiki` | `/test` β†’ corresponding `menu.ts` handler. +- `POST /internal/settings/validate-retention` | `validate-max-entries` | `validate-wiki-page` β†’ `settings.ts` validators. + +**Inputs:** HTTP request (Devvit-injected context). +**Outputs:** HTTP responses / `UiResponse`. + +--- + +## 2. Redis Key Schema (exact) + +All keys are **implicitly per-installation (per-subreddit)** β€” no `subreddit` segment (INV-9). `${page}` = configured wiki page name (default `modlog`) so a future multi-page install never collides. No key is ever enumerated via `KEYS`/`SCAN`-all (forbidden); every collection is rooted at a fixed key below. + +| Purpose | Type | Key | Member/Field β†’ Value | Lifecycle | +|---|---|---|---|---| +| Dedup (INV-5, P-17) | string | `seen:${page}:${actionId}` | `"1"` | TTL = `retentionDays*86400`; self-expires | +| Record store (P-4) | hash | `actions:${page}` | field=`actionId` β†’ `JSON(ModRecord)` | field removed by retention prune | +| Time index (P-18) | sorted set | `actions_by_time:${page}` | member=`actionId`, score=`createdAtSec` | `zRemRangeByScore` on prune | +| Wiki hash cache (INV-6, P-16) | hash | `wiki_hash` | field=`${page}` β†’ SHA-256 hex | overwritten each successful write | +| Dirty flag (Β§3) | string | `dirty:${page}` | `"1"` | set by trigger; `del` after cron publish | +| Schema marker (P-28) | string | `schema_version` | `"1"` | written on first boot; bumped on migration | + +**Rationale for the dual record-store + time-index:** `hGetAll(actions:${page})` rebuilds the full render set without any global scan; the parallel sorted set gives O(log n) retention pruning by `createdAtSec` via `zRemRangeByScore` (the documented Devvit FIFO/retention pattern). The hash field key (`actionId`) and zset member (`actionId`) are identical, so prune removes from both with the same id list. Dedup is a **separate** TTL string (not derived from the hash) so that the UNIQUE-constraint check is a single O(1) `set nx` and old ids can legitimately recur after retention β€” matching Python's "retention prunes the table, then ids can reappear" behavior. + +**Size budget:** 500 MB/install. A `ModRecord` JSON is ~300–500 bytes; at `maxWikiEntries`=2000 cap the `actions` hash is <1 MB. Dedup TTL keys at 90-day retention with heavy mod volume stay well under budget. If a record ever approaches the 5 MB request cap (it won't), `redisCompressed.hSet` is the escape hatch β€” not needed at MVP. + +--- + +## 3. Execution Model Decision β€” Cron-primary, trigger as dirty-flag ingest + +**Decision: cron is the system of record for publishing; `onModAction` is an OPTIONAL ingest fast-path that only sets a dirty flag and never writes the wiki.** + +### Rationale + +| Factor | Cron-only | Trigger-publishes | **Chosen: cron-primary + trigger-ingest** | +|---|---|---|---| +| 1:1 map of Python 600s loop | βœ… `*/10 * * * *` | ❌ event-driven, different semantics | βœ… cron owns publish cadence | +| Wiki write pressure / rate limit | βœ… one write/interval, only if changed | ❌ a write per mod action β€” hammers wiki, risks write-rate cap | βœ… trigger never writes; cron coalesces | +| Exactly-once correctness | βœ… batch dedup | ⚠️ triggers are NOT exactly-once (docs) | βœ… Redis `markSeen` dedup covers trigger redelivery | +| Full re-render cost (table is whole-page rebuild) | βœ… amortized per interval | ❌ re-render on every action | βœ… render only when cron sees `dirty` or new records | +| Latency | ⚠️ up to interval | βœ… near-real-time | βœ… records ingested at event time; publish at next tick (P-3 "shortly after", P-20) | +| Self-loop guard (app's own `dev_platform_app_*`/wiki edits) | n/a | needs explicit guard | βœ… trigger filters to `wikiActions` only; app wiki edits aren't in the set | + +**Mechanics:** +1. **`onModAction` (`/internal/on-mod-action`)** β€” per event: `extractRecord` (drops non-tracked types, drops ignored mods, INV-1/2 applied), `markSeen` gate (handles non-exactly-once redelivery), `putRecord`, then `setDirty(page)`. It does **NOT** call `getModerationLog` and does **NOT** publish. Cheap, idempotent, self-loop-safe (the app's own wiki edits aren't in `wikiActions`). +2. **Cron (`/internal/scheduler/publish-modlog`, `*/10 * * * *`)** β€” the authoritative path: `pruneRetention` β†’ `ingest` (catches anything the trigger missed/dropped) β†’ if `added>0 || isDirty(page)` then `publishFromStore` (hash-skip still applies, INV-6) β†’ `clearDirty`. Default `*/10` mirrors `update_interval=600`; bounded β‰₯1 min (platform minimum). Lowering to `*/5`/`*/2` is a config change, not code. + +**Single-writer note:** publishing happens ONLY in cron. The trigger only writes per-record Redis entries (different keys, idempotent by id). Therefore `putRecord`'s two-key write does not require a transaction at MVP; a `watch/multi/exec` wrapper is added only if a future design lets two writers publish concurrently. Retention prune runs single-writer in cron β€” no transaction needed. + +**MVP simplification:** ship **cron-only** first (trigger omitted) for the lowest-risk 1:1 port; add the trigger in the parity phase purely as a latency optimization. The cron path is correct and complete without it. + +--- + +## 4. Devvit Settings Schema (`devvit.json` β†’ `settings`) + +Scope `subreddit` = moderator-editable in Install Settings UI; values resolved by `settings.ts` into `AppConfig`. Auth/`subreddits`/`update_interval`/`anonymize_moderators` are **dropped** (P-1, P-25, P-19, INV-1). All numeric bounds enforced both by `validationEndpoint` (reject at save) and runtime clamp (defense in depth, P-24). + +| Setting key | Scope | Type | Default | Constraint / validation | Maps to (Python) | +|---|---|---|---|---|---| +| `wikiPage` | subreddit | `string` | `modlog` | non-empty, no spaces/slashes; `validate-wiki-page` | `wiki_page` | +| `wikiActions` | subreddit | `multiSelect` | the 7 (INV-7) | options = the 7 `ModActionType` literals only | `wiki_actions` | +| `ignoredModerators` | subreddit | `string` (CSV) | `AutoModerator` | parsed/trimmed client-side (INV-8) | `ignored_moderators` | +| `retentionDays` | subreddit | `number` | `90` | clamp/validate `1..365`; `validate-retention` | `retention_days` | +| `maxWikiEntries` | subreddit | `number` | `1000` | clamp/validate `100..2000`; `validate-max-entries` | `max_wiki_entries_per_page` | +| `fetchLimit` | subreddit | `number` | `1000` | clamp `100..5000`; explicit `getModerationLog` limit (delta #5) | `modlog_limit` | + +**Cadence** is NOT a runtime setting β€” it is the `cron` string in `devvit.json` `scheduler.tasks.publish-modlog` (P-19). Changing cadence is a manifest change + redeploy, not a mod-editable field (platform model). If mod-editable cadence is later required, expose a `select` of allowed crons mapped to `scheduler.runJob` β€” deferred (GAP-3). + +**Dropped settings (explicit):** `client_id`, `client_secret`, `username`, `password`, `user_agent` (P-1, no auth); `subreddits` (P-25, implicit per install); `anonymize_moderators` (INV-1, hardcoded true); `update_interval` (β†’ cron); `log_level`/`logs_dir`/config-file paths (P-30/P-33, serverless no-FS); `max_continuous_errors`/backoff (P-29, scheduler retry semantics). + +--- + +## 5. Phased Plan + +### Phase 1 β€” MVP (cron-only, core transparency loop) +**Goal:** Installable app that publishes the modlog to the wiki on schedule, with all privacy invariants intact. +- `types.ts`, `storage.ts` (dedup/record/time-index/wiki-hash/schema), `modlog.ts` (fetch/filter/extract/ingest), `render.ts` (tables/links/censor/cap/hash), `wiki.ts` (create-or-update + hash-skip), `settings.ts` (load+clamp+validate), `main.ts` (scheduler + validate endpoints only). +- `devvit.json`: `settings`, `scheduler.tasks.publish-modlog` (`*/10 * * * *`), permissions (read modlog, manage wiki). +- **Enforced invariants:** INV-1..INV-9 ALL active at MVP (they are privacy/correctness, not features). +- **Done when:** install on a test sub β†’ cron writes a wiki page matching the legacy table layout; hash-skip prevents redundant writes; retention prune runs; no real mod names, no profile links in output. +- **Verification:** unit tests on `render.ts` (pure) and `modlog.extractRecord`/`anonymizeMod`/`deriveDisplay`/`extractReason` (pure); golden-file compare of rendered markdown against legacy Python output for a fixed `ModRecord[]` fixture; live playtest fetch+publish on a sandbox sub; confirm one real `addremovalreason` entry's reason lands in `details` (research delta #2). + +### Phase 2 β€” Parity (UX + latency + completeness) +**Goal:** Close remaining MoSCoW Should/Could parity items. +- `menu.ts` + menu endpoints (P-26 test, P-27 force-rebuild/force-wiki/run-now). +- `onModAction` trigger (`/internal/on-mod-action`) + dirty-flag coalescing (Β§3, P-20/P-3). +- Approval-correlation render (P-13): cron looks back in the record store for a prior Reddit/AutoMod removal before emitting approval rows (Redis lookup replaces SQLite `LIKE`). +- Settings `validationEndpoint`s fully wired (P-24); schema-version migration path exercised (P-28). +- **Done when:** mods can force a rebuild/test from the UI; removals appear shortly after action via trigger ingest; approval rows obey the correlation rule. +- **Verification:** trigger redelivery dedup test (fire same action twice β†’ one record); force-wiki bypasses hash; menu actions mod-gated. + +### Phase 3 β€” Publish (review + distribution) +**Goal:** Public listing in the Apps directory. +- App metadata, icon, description, privacy statement (transparency/anonymization guarantees), required-permissions justification. +- Pre-submission audit against INV-1/INV-2 (reviewer-visible privacy posture), 512KB handling, error logging. +- Submit to Reddit app review; address feedback; publish; document install/upgrade. +- **Done when:** app is listed and installable by any moderator; upgrade path documented. +- **Verification:** review passes; fresh install on an unrelated sub reproduces MVP behavior greenfield (schema v1). + +--- + +## 6. GAP List (explicit β€” unknowns/risks to resolve in build) + +- **GAP-1 β€” `addremovalreason` reason field (delta #2).** No dedicated `removalReason` field; reason assumed in `details` (fallback `description`). **MUST confirm against one live `addremovalreason` event** before Phase 1 sign-off. If shape differs, `extractReason` priority order changes β€” localized to `modlog.ts`. +- **GAP-2 β€” `onModAction` payload shape.** Assumed to mirror `ModAction`. Confirm field availability (esp. `target.permalink`/`target.author`) on a live trigger payload before relying on trigger-ingest in Phase 2; cron path is unaffected. +- **GAP-3 β€” Mod-editable cadence.** Cron lives in the manifest, not settings; mods cannot change cadence without redeploy. Deferred. If required, expose a `select`β†’`scheduler.runJob` mapping (β‰₯1 min floor). +- **GAP-4 β€” Approval correlation cost (P-13).** SQLite `LIKE` becomes a Redis record-store lookup. With `hGetAll` already loaded in cron, correlation is in-memory over the loaded set β€” but only sees records within retention. Behavior at the retention boundary may differ from Python (which queried the full table up to 90d). Document the bound; acceptable since retention is the same 90d. +- **GAP-5 β€” `getModerationLog` paging vs ~30s wall clock.** `fetchLimit` default 1000 / pageSize 100 = ~10 pages. Large/backfilled logs could approach the per-invocation wall-clock limit. Mitigation: cap `fetchLimit`, rely on dedup so successive ticks make progress; for first-run backfill on a high-volume sub, daisy-chain one-off jobs (`scheduler.runJob`). Confirm timing in playtest. +- **GAP-6 β€” `hMGet`/hash command allowlisting.** Research notes `hMGet` may be disabled in the Devvit subset. `storage.ts` uses `hGetAll`/`hSet`/`hDel`/`hGet` only (all in the confirmed set); avoid `hMGet`. Verify the full hash command set at build start. +- **GAP-7 β€” SHA-256 availability.** `crypto.subtle.digest` (Web Crypto) assumed present in the Devvit Node runtime; `node:crypto` is the fallback. Confirm one is importable in the sandbox at Phase 1 start (INV-6 depends on it). +- **GAP-8 β€” Initial wiki page permissions.** Python's `wiki[page].edit()` auto-created; here `createWikiPage` is explicit. Decide default visibility (`SUBREDDIT_PERMISSIONS` vs `MODS_ONLY`) β€” transparency intent implies public/SUBREDDIT_PERMISSIONS. Set via `updateWikiPageSettings` post-create if needed. +- **GAP-9 β€” No historical SQLite import (non-goal, restated).** Each install starts greenfield at schema v1; legacy data is not migrated. Operators of the Python app accept a fresh start. diff --git a/devvit-migration/docs/STATUS.md b/devvit-migration/docs/STATUS.md new file mode 100644 index 0000000..a2396df --- /dev/null +++ b/devvit-migration/docs/STATUS.md @@ -0,0 +1,170 @@ +# RedditModLog β†’ Devvit: Migration Status + +| Field | Value | +|---|---| +| Status | Scaffolded β€” **type-checks clean** against `@devvit/public-api@0.13.5` (`npm run type-check` passes, `dist/` emits); not yet playtested on a live subreddit | +| Date | 2026-06-23 | +| Branch | `feat/devvit-migration` | +| Code root | `devvit/` (classic `@devvit/public-api` 0.13.5 model) | +| Legacy source | `modlog_wiki_publisher.py` (read-only reference) | + +This document tracks what is implemented vs. outstanding, mapped to the parity +matrix and the binding invariants (INV-1..INV-9). + +--- + +## 1. Build artifacts (this pass) + +| File | State | Notes | +|---|---|---| +| `devvit/package.json` | DONE | `redditmodlog-devvit`, dep `@devvit/public-api@0.13.5`, scripts: `deploy`/`dev`/`playtest`/`login`/`launch`/`type-check`/`test`. | +| `devvit/devvit.yaml` | DONE | `name: redditmodlog`. Unique-name claim happens on first `devvit upload` β€” rename here first if taken. | +| `devvit/tsconfig.json` | DONE | Extends `@devvit/public-api/devvit.tsconfig.json` (module/moduleResolution = **NodeNext**). | +| `devvit/.gitignore` | DONE | `node_modules`, `dist`, `.devvit`, `.env*`. | +| `devvit/src/main.ts` | DONE | Entrypoint: `Devvit.configure` + scheduler job + triggers + settings/menu registration + the shared publish cycle. | +| `devvit/README.md` | DONE | Upload/playtest, settings table, parity/invariant matrix. | +| `devvit/src/types.ts` | DONE (NEW) | The shared contract every module imported but which did not exist β€” see Β§3. | + +--- + +## 2. Component modules (authored separately; wired this pass) + +| Module | State | Owner-of (invariants) | +|---|---|---| +| `storage.ts` | DONE + adapter layer added (Β§3) | INV-5, INV-6, INV-9, retention | +| `modlog.ts` | DONE | INV-1, INV-2 (gating), INV-5, INV-7, INV-8 | +| `render.ts` | DONE (1 import fixed, Β§3) | INV-2 (emit), INV-3, INV-4, INV-6 (hash) | +| `wiki.ts` | DONE | INV-3 (guard), INV-6 | +| `settings.ts` | DONE | INV-1, INV-7, INV-8, INV-9 | +| `menu.ts` | DONE (signature drift fixed, Β§3) | β€” (thin adapter) | + +--- + +## 3. Contract reconciliation performed this pass + +The component modules were authored in parallel against the architecture spec, +but the spec's idealized names and `storage.ts`'s actual implementation had +**drifted**. The whole project would not have compiled or linked. The following +minimal, non-logic reconciliations were made so it builds: + +1. **`types.ts` created** β€” `modlog.ts`, `render.ts`, and `settings.ts` all + `import` from `./types.js`, but the file did not exist. Created it as the + single source of truth for `ModRecord`, `AppConfig`, `ModActionType`, + `DisplayKind`, and all constants (`WIKI_BYTE_CAP`, `WIKI_TRIM_TARGET`, + `ANON_LABEL`, `LITERAL_MODS`, `ANONYMIZE_MODERATORS`, `DEFAULT_*`, + `*_MIN/_MAX`, `VALID_MODLOG_ACTIONS`, `SCHEMA_VERSION`). No `@devvit/*` import + so the pure render layer stays platform-free. + +2. **Storage spec-name adapter layer** (appended to `storage.ts`) β€” the spec / + sibling modules call `markSeen` / `putRecord` / `getAllRecords` / + `getStatus` / `recordRunStarted` / `recordPublished`, but the implementation + defined `isProcessed` / `recordAction` / `getRecentActions` and **no** status + accessors. Added thin adapters: + - `markSeen` = `!isProcessed` (inverse sense: returns `true` when NEW). + - `putRecord` β†’ `recordAction`. + - `getAllRecords` β†’ `getRecentActions` with a default cap. + - `getStatus` / `recordRunStarted` / `recordPublished` β†’ new `status` hash. + No existing logic was rewritten. + +3. **`render.ts` import extension** β€” `from './types'` β†’ `from './types.js'`. + Under the Devvit base tsconfig's **NodeNext** resolution, relative specifiers + MUST carry the `.js` extension; the bare specifier was a compile error. + +### Reconciliations performed this pass (compiler-verified) + +4. **`modlog.ts` package + client threading fixed** β€” it imported + `{ reddit }` and `ModAction` from `@devvit/reddit` (the split-package / + Devvit-Web name), which does NOT exist in the classic + `@devvit/public-api@0.13.5` model and failed with `TS2307`. The classic model + has **no `reddit` singleton** β€” the Reddit client is `context.reddit` + (`RedditAPIClient`), and `ModAction`/`ModActionType` are re-exported from + `@devvit/public-api`. Fixed by importing types from `@devvit/public-api` and + threading a `RedditAPIClient` argument through `fetchActions(reddit, cfg)` and + `ingest(reddit, redis, cfg)` (mirrors `wiki.ts`). Callers updated: + `main.ts` (`runPublishCycle` + `ModAction` trigger now destructure `reddit` + and pass it), `menu.ts` (`handlePublishNow` passes `reddit`). + +5. **`menu.ts` signature drift fixed** β€” it called `loadConfig()` (0 args) and + `ingest(reddit, redis, cfg)` against a then-2-arg `ingest`. Now calls + `loadConfig(context.settings, context.subredditName)` (with an INV-9 + no-subreddit guard) and `ingest(reddit, redis, cfg)` against the corrected + 3-arg signature. + +After (4)+(5): `npm run type-check` passes with zero errors and `dist/` emits +for all 8 modules (forced clean rebuild verified). + +### Known residual drift (cosmetic β€” non-blocking) + +- **`ModRecord` (types) vs `ModActionRecord` (storage)** are structurally + identical, so cross-passing type-checks today. Consider collapsing to one + named type to avoid future drift. +- **`wiki.ts` header comments** still reference the `@devvit/reddit` / + `@devvit/redis` split packages as a Devvit-Web TODO; the actual imports + correctly use `@devvit/public-api`. Documentation-only; harmless. + +--- + +## 4. Parity matrix (Python β†’ Devvit) + +| Capability | Python | Devvit | State | +|---|---|---|---| +| Auth | password-grant OAuth | platform-managed | DONE (no code) | +| Mod-log fetch | `subreddit.mod.log(limit)` | `reddit.getModerationLog({limit,pageSize})` | DONE | +| Action filter (7 types) | client-side | client-side (`type` filter is single-valued) | DONE | +| Anonymize (INV-1) | enforced | hardcoded, no toggle | DONE | +| Profile-link ban (INV-2) | enforced | permalink only for t1/t3 | DONE | +| Reason censor/escape (INV-4) | regex | ported regex (`render`) | DONE | +| Markdown tables | per-day | per-day (`render.buildContent`) | DONE | +| Modmail prefill link | yes | `render.modmailLink` | DONE | +| 512 KB cap + trim (INV-3) | yes | `render.enforceByteCap` + `wiki` guard | DONE | +| Dedup (INV-5) | SQLite UNIQUE | Redis atomic NX (`markSeen`) | DONE | +| Wiki hash-skip (INV-6) | SHA-256 cache | SHA-256 cache (`wiki`/`storage`) | DONE | +| Retention (90d) | row delete | zset prune by score (`cleanupOld`) | DONE | +| Daemon loop | `update_interval` 600s | scheduler cron `*/10 * * * *` | DONE | +| Prompt-fast on action | n/a | `ModAction` trigger (ingest only) | DONE | +| Config (19 opts) | CLI/env/JSON | 6 install settings + 1 hardcoded | DONE | +| Multi-subreddit | single store | one install per sub (isolation) | DONE (by design) | +| CLI `--test` / `--force-*` | yes | menu "Publish now" (force/test variants partial) | PARTIAL | + +--- + +## 5. Outstanding TODO before a real deploy + +1. ~~Fix `menu.ts` signature drift~~ β€” **DONE** (Β§3.4/Β§3.5); project type-checks + end-to-end. +2. ~~Install deps + type-check~~ β€” **DONE**: `npm install` (457 pkgs) + + `npm run type-check` pass clean against `@devvit/public-api@0.13.5`; `dist/` + emits. The import surface and the `getModerationLog` / `getWikiPage` / + `createWikiPage` / `updateWikiPage` / scheduler / settings call shapes are now + compiler-validated against the installed SDK types. +3. **Verify remaining runtime call shapes against a live install** β€” types + compile, but these need playtest confirmation (behavior, not just types): + - `reddit.getModerationLog({ subredditName, limit, pageSize })` + `.all()` + (Listing drain β€” some versions use `for await` instead). + - `reddit.getWikiPage(sub, page)` throwing on absence; `createWikiPage` / + `updateWikiPage` option shapes (`{ subredditName, page, content, reason }`). + - `context.scheduler.listJobs()` / `cancelJob(id)` / `runJob({name, cron})`. + - `Devvit.addTrigger({ events: ['AppInstall','AppUpgrade'] })` and + `event: 'ModAction'` payload fields. + - `context.settings.get`, `context.subredditName` on scheduler/trigger ctx. +4. **Cron cadence**: confirm `*/10 * * * *` is permitted for the app tier; + tighten/loosen as policy allows (Python used 600s). +5. **Playtest** on a test subreddit (`npm run playtest`) β€” exercise: install β†’ + settings save (validators) β†’ menu "Publish now" β†’ wiki page created β†’ + second run hash-skips β†’ mod action triggers ingest β†’ retention prune. +6. **Unit tests** for the pure layers (`render.*`, `modlog.anonymizeMod` / + `deriveDisplay` / `extractRecord`, `settings` validators). `vitest` is wired + in `package.json`; no test files written yet. +7. **App review** before public listing (`devvit publish`). + +--- + +## 6. Dropped legacy options (intentional, no parity needed) + +`client_id`, `client_secret`, `username`, `password` (platform auth); +`source_subreddit` (install context, INV-9); `update_interval` (scheduler); +`wiki_display_days` (folded into `retention_days`); `max_continuous_errors`, +`rate_limit_buffer`, `max_batch_retries` (no daemon; platform-managed retry/ +rate-limit); `archive_threshold_days`, `database_path` (Redis, no SQLite); +`display_format` (fixed render). `anonymize_moderators` is hardcoded `true` +(INV-1), not a setting. diff --git a/devvit-migration/docs/reddit-api/getModerationLog.md b/devvit-migration/docs/reddit-api/getModerationLog.md new file mode 100644 index 0000000..9284471 --- /dev/null +++ b/devvit-migration/docs/reddit-api/getModerationLog.md @@ -0,0 +1,21 @@ +[**@devvit/public-api v0.13.6-dev**](../../README.md) + +*** + +# Function: getModerationLog() + +> **getModerationLog**(`options`, `metadata`): [`Listing`](../classes/Listing.md)\<[`ModAction`](../interfaces/ModAction.md)\> + +## Parameters + +### options + +[`GetModerationLogOptions`](../type-aliases/GetModerationLogOptions.md) + +### metadata + +`undefined` | `Metadata` + +## Returns + +[`Listing`](../classes/Listing.md)\<[`ModAction`](../interfaces/ModAction.md)\> diff --git a/devvit/.gitignore b/devvit/.gitignore new file mode 100644 index 0000000..3a56540 --- /dev/null +++ b/devvit/.gitignore @@ -0,0 +1,22 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +*.tsbuildinfo + +# Devvit CLI state / local config +.devvit/ +.env +.env.* +!.env.example + +# Logs +*.log +npm-debug.log* + +# Editor / OS +.DS_Store +.idea/ +.vscode/ +*.swp diff --git a/devvit/README.md b/devvit/README.md new file mode 100644 index 0000000..11af86f --- /dev/null +++ b/devvit/README.md @@ -0,0 +1,142 @@ +# RedditModLog (Devvit) + +A [Reddit Devvit](https://developers.reddit.com) app that publishes a +subreddit's **moderation log** to a subreddit **wiki page** as markdown tables, +each row carrying a prefilled "removal inquiry" modmail link. + +This is the Devvit port of the legacy self-hosted Python/PRAW daemon +(`../modlog_wiki_publisher.py`). It runs entirely on Reddit's Developer Platform +(serverless TypeScript/Node) β€” **no server, no database, no credentials to +manage**. Reddit OAuth, scheduling, and storage are provided by the platform. + +> Anonymization is mandatory and non-configurable. Real moderator names are +> never published; user profiles are never linked. These are hard invariants +> (see [Parity & invariants](#parity--invariants)). + +--- + +## How it works + +``` + Reddit mod log ──getModerationLog──▢ modlog.ingest ──▢ Redis (dedup + records) + β”‚ + scheduler cron (every 10 min) ──▢ wiki.publishFromStore β—€β”€β”€β”€β”€β”˜ + β”‚ + render.buildContent (markdown, capped) + β”‚ + reddit.create/updateWikiPage (hash-skipped) +``` + +- **Scheduler job** (`publish-modlog`, cron `*/10 * * * *`) runs the full cycle: + ingest new actions β†’ render β†’ publish (skipped if unchanged) β†’ prune old + records past the retention window. +- **`ModAction` trigger** does a cheap incremental ingest on each moderation + action so removals are captured promptly; the actual wiki write is coalesced + into the next cron run (no write-per-action churn). +- **Menu items** (moderator-only): *Mod Log: Publish now* and *Mod Log: Show + status*. + +### Module map (`src/`) + +| File | Responsibility | +|---|---| +| `types.ts` | Shared types + frozen constants (single source of truth). No I/O. | +| `storage.ts` | Redis data-access (Repository pattern). The only Redis module. | +| `modlog.ts` | Fetch + filter + extract `ModAction` β†’ `ModRecord` (anonymize, dedup). | +| `render.ts` | **Pure** markdown builder: tables, links, censor, byte-cap, hash. | +| `wiki.ts` | Read/create/update wiki page + hash-skip orchestration. | +| `settings.ts` | Settings schema + validation β†’ frozen `AppConfig`. | +| `menu.ts` | Moderator menu actions (publish now / show status). | +| `main.ts` | Entrypoint: `Devvit.configure` + all capability registrations. | + +--- + +## Develop & deploy + +Prerequisites: Node 18+, a Reddit account enrolled in the Developer Platform, +and the Devvit CLI (installed as a dev dependency here, or globally via +`npm i -g devvit`). + +```bash +cd devvit +npm install + +# One-time: authenticate the CLI with your Reddit account. +npm run login # devvit login + +# Type-check. +npm run type-check # tsc --build + +# Playtest on a TEST subreddit you moderate (live install, hot-reload). +npm run playtest # devvit playtest + +# Upload a new version to Reddit (private, installable on subs you mod). +npm run deploy # devvit upload + +# Submit for review to make it publicly installable (requires app review). +npm run launch # devvit publish +``` + +> `devvit playtest` and `devvit upload` install/run the app on a subreddit you +> moderate. Use a throwaway test subreddit first. Publishing to the public app +> directory requires Reddit's app-review process. + +### App name + +`devvit.yaml` sets `name: redditmodlog`. This name is **globally unique** and is +claimed on first `devvit upload`. If it's taken, change it in `devvit.yaml` +before the first upload (renaming after publish is disruptive). + +--- + +## Configuration (per-install settings) + +Configured per subreddit on the app's install **Settings** page. The 19 legacy +Python options collapse to **6 settings** (Devvit owns OAuth, scheduling, and +storage; one option is hardcoded; the rest are obsolete on-platform). + +| Setting | Default | Range | Legacy option | +|---|---|---|---| +| Wiki page name | `modlog` | slug | `wiki_page` | +| Action types to publish | the 7 below | multi-select | `wiki_actions` | +| Additional ignored moderators | *(none)* | CSV usernames | `ignored_moderators` | +| Retention (days) | `90` | 1–365 | `retention_days` | +| Max entries on page | `1000` | 100–2000 | `max_wiki_entries_per_page` | +| Mod-log fetch per run | `500` | 50–1000 | `batch_size` | + +Default tracked actions (INV-7): `removelink`, `removecomment`, `spamlink`, +`spamcomment`, `addremovalreason`, `approvelink`, `approvecomment`. + +`AutoModerator` is **always** excluded (INV-8); the CSV setting only *adds* to +that list. + +--- + +## Parity & invariants + +These rules are carried verbatim from the Python app and enforced in code: + +| # | Invariant | Where enforced | +|---|---|---| +| INV-1 | **Anonymize moderators ALWAYS** β€” human mods β†’ `HumanModerator`; `AutoModerator`/`Reddit` kept literal. No toggle. | `modlog.anonymizeMod` (at ingest) | +| INV-2 | **Never link user profiles** β€” only post/comment permalinks become hyperlinks. | `modlog.extractRecord` + `render.contentLink` | +| INV-3 | **512 KB wiki cap** β€” content trimmed oldest-day-first to ≀90% of cap. | `render.enforceByteCap` (+ `wiki` guard) | +| INV-4 | **Email censor + pipe-escape** on all free text. | `render.censorEmail` / `escapePipes` | +| INV-5 | **Dedup by `ModAction.id`** β€” each action processed once. | `storage.markSeen` (atomic NX) | +| INV-6 | **Wiki hash-skip** β€” never write unchanged content (SHA-256). | `wiki.publish` + `storage.getWikiHash` | +| INV-7 | **Default tracked actions** (7 types). | `types.DEFAULT_WIKI_ACTIONS` | +| INV-8 | **Default ignored mods** = `[AutoModerator]`. | `types.DEFAULT_IGNORED_MODS` + `settings` | +| INV-9 | **One install == one subreddit** β€” Redis is install-scoped; no cross-sub mixing. | platform + `storage` key layout | + +### What changed from the Python app + +- **Auth**: password-grant OAuth β†’ platform-managed (no credentials). +- **Storage**: SQLite (schema v5) β†’ Redis KV (dedup hash, records hash, + time-sorted set, wiki-hash cache). +- **Loop**: continuous daemon (`update_interval`) β†’ scheduler cron + + `ModAction` trigger. +- **Config**: CLI/env/JSON (19 opts) β†’ 6 install settings + 1 hardcoded. +- **Multi-subreddit**: one shared store β†’ one install per subreddit (isolation + is structural). + +See `../devvit-migration/docs/STATUS.md` for the full scaffolded-vs-TODO matrix. diff --git a/devvit/devvit.yaml b/devvit/devvit.yaml new file mode 100644 index 0000000..6174633 --- /dev/null +++ b/devvit/devvit.yaml @@ -0,0 +1,12 @@ +# Devvit app manifest (classic @devvit/public-api model). +# +# `name` MUST be globally unique across the Reddit Developer Platform and is +# claimed on first `devvit upload`. If "redditmodlog" is taken, pick a unique +# variant (e.g. "redditmodlog-wiki") here BEFORE the first upload β€” renaming a +# published app is disruptive. +# +# Settings, the scheduler job, the menu items, and the triggers are declared in +# code (src/main.ts via Devvit.addSettings / addSchedulerJob / addMenuItem / +# addTrigger), not in this file. The CLI infers required permissions (Reddit API, +# Redis, scheduler) from the registered capabilities at upload time. +name: redditmodlog diff --git a/devvit/package-lock.json b/devvit/package-lock.json new file mode 100644 index 0000000..c91f1ff --- /dev/null +++ b/devvit/package-lock.json @@ -0,0 +1,7780 @@ +{ + "name": "redditmodlog-devvit", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "redditmodlog-devvit", + "version": "0.0.1", + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/public-api": "0.13.5" + }, + "devDependencies": { + "devvit": "0.13.5", + "typescript": "5.8.3", + "vitest": "3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@devvit/build-pack": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@devvit/build-pack/-/build-pack-0.13.5.tgz", + "integrity": "sha512-QMAnu+AscQA07hKN7BS68OCno99RuNgdcI0jf2FF8iSU/iA0k0MdqGFlqLvQMjGBxG6PR7fs7QhaSYspikh0Ig==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/payments": "0.13.5", + "@devvit/protos": "0.13.5", + "@devvit/shared-types": "0.13.5", + "@types/node": "20.14.12", + "esbuild": "0.25.9", + "rxjs": "7.8.1", + "tsv": "0.2.0", + "typescript": "5.8.3" + }, + "peerDependencies": { + "@devvit/server": "*", + "@devvit/shared": "*" + } + }, + "node_modules/@devvit/build-pack/node_modules/@types/node": { + "version": "20.14.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", + "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@devvit/build-pack/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@devvit/builders": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@devvit/builders/-/builders-0.13.5.tgz", + "integrity": "sha512-LLNi6n8D2Ejh+icRvSDHemsMAycSuxg3GKFCXHlkph4xvEwkCFOb9RFgQlsi/12EgUChpPVNXcZTsJ9zx99a4g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/build-pack": "0.13.5", + "@devvit/linkers": "0.13.5", + "@devvit/protos": "0.13.5", + "@devvit/shared-types": "0.13.5" + } + }, + "node_modules/@devvit/cli": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@devvit/cli/-/cli-0.13.5.tgz", + "integrity": "sha512-QpT4JF5GmTxhnwzupehsEZo5GP38WJjVJpC5xiaXH0gNHhepj14+Qn1okELvOBXCOIS2PzpXp5oQP6AenCJ/sA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/build-pack": "0.13.5", + "@devvit/builders": "0.13.5", + "@devvit/linkers": "0.13.5", + "@devvit/protos": "0.13.5", + "@devvit/public-api": "0.13.5", + "@devvit/shared-types": "0.13.5", + "@improbable-eng/grpc-web": "0.15.0", + "@improbable-eng/grpc-web-node-http-transport": "0.15.0", + "@oclif/core": "2.9.4", + "@oclif/plugin-autocomplete": "2.3.3", + "@oclif/plugin-help": "5.2.14", + "@oclif/plugin-not-found": "2.3.34", + "@oclif/plugin-warn-if-update-available": "2.0.45", + "@types/ws": "8.5.12", + "chalk": "4.1.2", + "chokidar": "3.5.3", + "date-fns": "2.29.3", + "dotenv": "16.5.0", + "execa": "9.6.1", + "file-type": "21.3.2", + "ignore": "7.0.5", + "image-size": "2.0.2", + "inquirer": "9.1.4", + "isomorphic-git": "1.33.1", + "js-yaml": "4.1.1", + "jsdom": "24.1.0", + "jszip": "3.10.1", + "mime-types": "3.0.2", + "mustache": "4.2.0", + "open": "10.1.0", + "rxjs": "7.8.1", + "semver": "7.6.3", + "string-length": "5.0.1", + "tiny-glob": "0.2.9", + "twirp-ts": "2.5.0", + "ws": "8.20.1" + }, + "bin": { + "devvit-cli": "bin/devvit.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@devvit/linkers": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@devvit/linkers/-/linkers-0.13.5.tgz", + "integrity": "sha512-bxYST5K3Wq0PrGuR1wz3rWYS2opwe6XGNwaAeI1VRPF2fpA9HTD6eglaR3Tt7b34gCdsA0teIzs19ycTXiH7Pg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/build-pack": "0.13.5", + "@devvit/protos": "0.13.5", + "@devvit/shared-types": "0.13.5" + } + }, + "node_modules/@devvit/metrics": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@devvit/metrics/-/metrics-0.13.5.tgz", + "integrity": "sha512-KXzMbR/8oGkVEFLphjnSXnJOjyfH37e7A7QHwzuY/SQWgX/UW3s/HRMTV5Y85Hs/hxh8QTo2+K63hUKldE9ZVg==", + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/protos": "0.13.5" + } + }, + "node_modules/@devvit/payments": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@devvit/payments/-/payments-0.13.5.tgz", + "integrity": "sha512-ZWF9SW1rGxc3AOQQlqzbrY87O/Aujj4Q6AdXfbLOTMLdJa/uWqLM8R02CVgBYgQukmYzfHIBXbiUJDNprA5WEw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/protos": "0.13.5", + "@devvit/public-api": "0.13.5", + "@devvit/server": "0.13.5", + "@devvit/shared-types": "0.13.5" + } + }, + "node_modules/@devvit/protos": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@devvit/protos/-/protos-0.13.5.tgz", + "integrity": "sha512-MZaqEX0XVwofCKMIdTew5z8aNv6M2uBhsoT91YEy2JNckrPcNJKdhg98WZ2+bQvtxq9Omh7mWx2+13KWdkQd9w==", + "license": "BSD-3-Clause", + "dependencies": { + "protobufjs": "7.5.8", + "rxjs": "7.8.1" + }, + "peerDependencies": { + "twirp-ts": "^2.5.0" + }, + "peerDependenciesMeta": { + "twirp-ts": { + "optional": true + } + } + }, + "node_modules/@devvit/public-api": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@devvit/public-api/-/public-api-0.13.5.tgz", + "integrity": "sha512-/i+EMoHRgY0vI4xfRpdBidOtxXtwI2vyQ6SuMIUnWvELWrCeFHlAhmbHQGiAmGoEYkpWP/XPGj+9S1fCz5Q7Yw==", + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/metrics": "0.13.5", + "@devvit/protos": "0.13.5", + "@devvit/shared": "0.13.5", + "@devvit/shared-types": "0.13.5", + "base64-js": "1.5.1", + "clone-deep": "4.0.1", + "jwt-decode": "4.0.0", + "moderndash": "4.0.0" + } + }, + "node_modules/@devvit/server": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@devvit/server/-/server-0.13.5.tgz", + "integrity": "sha512-Fl+GgfsJa4ATHycpYaDdvbQvb1whBq8IN8vGgQFmwXlYHc/SHtYieM8+HsT/s1dEY2DKUyj7t7ZdK3VGW6ExaA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/protos": "0.13.5", + "@devvit/public-api": "0.13.5", + "@devvit/shared": "0.13.5", + "@devvit/shared-types": "0.13.5" + } + }, + "node_modules/@devvit/shared": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@devvit/shared/-/shared-0.13.5.tgz", + "integrity": "sha512-RcJWMadvrJBXB7MR4YqHzmUSef24SLdMwtcNBYM5Hh4SKtXu+dV/6sr+tdNdo4R4j8B/B47cUcsDUccm8bdITA==", + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/protos": "0.13.5", + "@devvit/shared-types": "0.13.5" + } + }, + "node_modules/@devvit/shared-types": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@devvit/shared-types/-/shared-types-0.13.5.tgz", + "integrity": "sha512-yQM7TReqkqGpBPYXDyx/aKC0FguTVYBzy1NhwJj2rdF3db+MKU3hIml9ZFu3f8cVHpgCxcC0Wh/p5e/zPfCjOQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/protos": "0.13.5", + "jsonschema": "1.4.1", + "uuid": "14.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@improbable-eng/grpc-web": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@improbable-eng/grpc-web/-/grpc-web-0.15.0.tgz", + "integrity": "sha512-ERft9/0/8CmYalqOVnJnpdDry28q+j+nAlFFARdjyxXDJ+Mhgv9+F600QC8BR9ygOfrXRlAk6CvST2j+JCpQPg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "browser-headers": "^0.4.1" + }, + "peerDependencies": { + "google-protobuf": "^3.14.0" + } + }, + "node_modules/@improbable-eng/grpc-web-node-http-transport": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@improbable-eng/grpc-web-node-http-transport/-/grpc-web-node-http-transport-0.15.0.tgz", + "integrity": "sha512-HLgJfVolGGpjc9DWPhmMmXJx8YGzkek7jcCFO1YYkSOoO81MWRZentPOd/JiKiZuU08wtc4BG+WNuGzsQB5jZA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@improbable-eng/grpc-web": ">=0.13.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oclif/color": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@oclif/color/-/color-1.0.13.tgz", + "integrity": "sha512-/2WZxKCNjeHlQogCs1VBtJWlPXjwWke/9gMrwsVsrUt00g2V6LUBvwgwrxhrXepjOmq4IZ5QeNbpDMEOUlx/JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.2.1", + "chalk": "^4.1.0", + "strip-ansi": "^6.0.1", + "supports-color": "^8.1.1", + "tslib": "^2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@oclif/core": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-2.9.4.tgz", + "integrity": "sha512-eFRRpV+tJ6nMkhay2M9IppjSF3atRrgj6Qo83qUslaFSAW3NAl4mIhx1mKmTwQx5rgSrar03xICtSAWJ6gZtag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cli-progress": "^3.11.0", + "ansi-escapes": "^4.3.2", + "ansi-styles": "^4.3.0", + "cardinal": "^2.1.1", + "chalk": "^4.1.2", + "clean-stack": "^3.0.1", + "cli-progress": "^3.12.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "fs-extra": "^9.1.0", + "get-package-type": "^0.1.0", + "globby": "^11.1.0", + "hyperlinker": "^1.0.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "js-yaml": "^3.14.1", + "natural-orderby": "^2.0.3", + "object-treeify": "^1.1.33", + "password-prompt": "^1.1.2", + "semver": "^7.5.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "supports-color": "^8.1.1", + "supports-hyperlinks": "^2.2.0", + "ts-node": "^10.9.1", + "tslib": "^2.5.0", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oclif/core/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@oclif/core/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@oclif/plugin-autocomplete": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@oclif/plugin-autocomplete/-/plugin-autocomplete-2.3.3.tgz", + "integrity": "sha512-4/7l3YrACS7KfONoams7+xAbVfo7tje0t2Qp84/KAUpY1Yb/11AMPHtzgoRp5A5Y3OFPmLCfRF1YUydz+jGuwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oclif/core": "^2.8.2", + "chalk": "^4.1.0", + "debug": "^4.3.4", + "fs-extra": "^9.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@oclif/plugin-help": { + "version": "5.2.14", + "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-5.2.14.tgz", + "integrity": "sha512-7hMLc6zqxeRfG4nvHHQPpbaBj60efM3ULFkCpHZkdLms/ezIkNo40F661QuraIjMP/NN+U6VSfBCGuPkRyxVkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oclif/core": "^2.9.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@oclif/plugin-not-found": { + "version": "2.3.34", + "resolved": "https://registry.npmjs.org/@oclif/plugin-not-found/-/plugin-not-found-2.3.34.tgz", + "integrity": "sha512-uXUpw6o2e0aqnNn+XkGL7LbL+Th2rBD1JGtFbb6anmvUvz2skiGz0o23BYmrQW8tvU92ajPOykfClKD75ptZcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oclif/color": "^1.0.9", + "@oclif/core": "^2.9.4", + "fast-levenshtein": "^3.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@oclif/plugin-warn-if-update-available": { + "version": "2.0.45", + "resolved": "https://registry.npmjs.org/@oclif/plugin-warn-if-update-available/-/plugin-warn-if-update-available-2.0.45.tgz", + "integrity": "sha512-MEncCUHW1vCOQdvt1z46jAblwvuGcs3Q1Gjl8IghazGJ0GRHzGOMILABpqVWR5uH/YJ3gfs05Tt7M4LdZ40N3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oclif/core": "^2.9.4", + "chalk": "^4.1.0", + "debug": "^4.1.0", + "fs-extra": "^9.0.1", + "http-call": "^5.2.2", + "lodash": "^4.17.21", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@protobuf-ts/plugin-framework": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@protobuf-ts/plugin-framework/-/plugin-framework-2.11.0.tgz", + "integrity": "sha512-D6Mno2i/NcRrDzbTq/2QSXUheXds6Ye+uaBSAHvPy2GAre4vjz1S0B6JTzVbU/B8hI3Msc/zMQSHb5Ak5Iy97g==", + "devOptional": true, + "license": "(Apache-2.0 AND BSD-3-Clause)", + "dependencies": { + "@protobuf-ts/runtime": "^2.11.0", + "typescript": "^3.9" + } + }, + "node_modules/@protobuf-ts/plugin-framework/node_modules/typescript": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz", + "integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/@protobuf-ts/runtime": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz", + "integrity": "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==", + "devOptional": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cli-progress": { + "version": "3.11.6", + "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.6.tgz", + "integrity": "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansicolors": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", + "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", + "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-headers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/browser-headers/-/browser-headers-0.4.1.tgz", + "integrity": "sha512-CA9hsySZVo9371qEHjHZtYxV2cFtVj5Wj/ZHi8ooEsrtm4vOnl9Y9HmyYWk9q+05d7K3rdoAE0j3MVEFVvtQtg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/cardinal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", + "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansicolors": "~0.3.2", + "redeyed": "~2.1.0" + }, + "bin": { + "cdl": "bin/cdl.js" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/char-regex": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.2.tgz", + "integrity": "sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/clean-git-ref": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", + "integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/clean-stack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", + "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/date-fns": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", + "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/devvit": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/devvit/-/devvit-0.13.5.tgz", + "integrity": "sha512-F1YWy3PFN/Hj1MPoImh73w2+vF3D03ZsZHJv31EZEehWQNS7M8Uah5zQ+z0IccnFSqaQMH2Ug8ME9gXvI/PK9g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/cli": "0.13.5" + }, + "bin": { + "devvit": "bin/devvit.js" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff3": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", + "integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-object": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/dot-object/-/dot-object-2.1.5.tgz", + "integrity": "sha512-xHF8EP4XH/Ba9fvAF2LDd5O3IITVolerVV6xvkxoM8zlGEiCUrggpAnHyOoKJKCrhvPcGATFAUwIujj7bRG5UA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "commander": "^6.1.0", + "glob": "^7.1.6" + }, + "bin": { + "dot-object": "bin/dot-object" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-levenshtein": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz", + "integrity": "sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fastest-levenshtein": "^1.0.7" + } + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-type": { + "version": "21.3.2", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz", + "integrity": "sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "devOptional": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globalyzer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", + "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/google-protobuf": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz", + "integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==", + "dev": true, + "license": "(BSD-3-Clause AND Apache-2.0)", + "peer": true + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hotscript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/hotscript/-/hotscript-1.0.13.tgz", + "integrity": "sha512-C++tTF1GqkGYecL+2S1wJTfoH6APGAsbb7PAWQ3iVIwgG/EFseAfEVOKFgAFq4yK3+6j1EjUD4UQ9dRJHX/sSQ==", + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-call": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/http-call/-/http-call-5.3.0.tgz", + "integrity": "sha512-ahwimsC23ICE4kPl9xTBjKB4inbRaeLyZeRunC/1Jy/Z6X8tv22MEAjK+KBOMSVLaqXPTTmd8638waVIKLGx2w==", + "dev": true, + "license": "ISC", + "dependencies": { + "content-type": "^1.0.4", + "debug": "^4.1.1", + "is-retry-allowed": "^1.1.0", + "is-stream": "^2.0.0", + "parse-json": "^4.0.0", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-call/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/hyperlinker": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hyperlinker/-/hyperlinker-1.0.0.tgz", + "integrity": "sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "dev": true, + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "devOptional": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.1.4.tgz", + "integrity": "sha512-9hiJxE5gkK/cM2d1mTEnuurGTAoHebbkX0BYl3h7iEg7FYfuNIom+nDfBCSWtvSnoSrWCeBxqqBZu26xdlJlXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^6.0.0", + "chalk": "^5.1.2", + "cli-cursor": "^4.0.0", + "cli-width": "^4.0.0", + "external-editor": "^3.0.3", + "figures": "^5.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^6.1.2", + "run-async": "^2.4.0", + "rxjs": "^7.5.7", + "string-width": "^5.1.2", + "strip-ansi": "^7.0.1", + "through": "^2.3.6", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer/node_modules/ansi-escapes": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", + "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inquirer/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/inquirer/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inquirer/node_modules/figures": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz", + "integrity": "sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^5.0.0", + "is-unicode-supported": "^1.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inquirer/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inquirer/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inquirer/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-retry-allowed": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", + "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isomorphic-git": { + "version": "1.33.1", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.33.1.tgz", + "integrity": "sha512-Fy5rPAncURJoqL9R+5nJXLl5rQH6YpcjJd7kdCoRJPhrBiLVkLm9b+esRqYQQlT1hKVtKtALbfNtpHjWWJgk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-lock": "^1.4.1", + "clean-git-ref": "^2.0.1", + "crc-32": "^1.2.0", + "diff3": "0.0.3", + "ignore": "^5.1.4", + "minimisted": "^2.0.0", + "pako": "^1.0.10", + "path-browserify": "^1.0.1", + "pify": "^4.0.1", + "readable-stream": "^3.4.0", + "sha.js": "^2.4.12", + "simple-get": "^4.0.1" + }, + "bin": { + "isogit": "cli.cjs" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/isomorphic-git/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.0.tgz", + "integrity": "sha512-6gpM7pRXCwIOKxX47cgOyvyQDN/Eh0f1MeKySBV2xGdKtqJBLj8P25eY3EVCWo2mglDDzozR2r2MW4T+JiNUZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.4", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.10", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.17.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonschema": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.4.1.tgz", + "integrity": "sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/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==", + "dev": true, + "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/jszip/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-5.1.0.tgz", + "integrity": "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.0.0", + "is-unicode-supported": "^1.1.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimisted": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz", + "integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + } + }, + "node_modules/moderndash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/moderndash/-/moderndash-4.0.0.tgz", + "integrity": "sha512-77kEJCsBo3YzqIrO4ZWHo1h7q8f8ZIRxQ0DE0fEvj3rZWsfTMLeRzctBjkz2zKM1BAIQsOqB79jm3dZ4RDFmqw==", + "license": "MIT", + "dependencies": { + "hotscript": "1.0.13", + "type-fest": "4.27.0" + }, + "engines": { + "node": ">=20", + "npm": ">=10" + } + }, + "node_modules/moderndash/node_modules/type-fest": { + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.27.0.tgz", + "integrity": "sha512-3IMSWgP7C5KSQqmo1wjhKrwsvXAtF33jO3QY+Uy++ia7hqvgSK6iXbbg5PbDBc1P2ZbNEDgejOrN4YooXvhwCw==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-orderby": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz", + "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", + "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open/node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-6.3.1.tgz", + "integrity": "sha512-ERAyNnZOfqM+Ao3RAvIXkYh5joP220yf59gVe2X/cI6SiCxIdi4c9HZKZD8R6q/RDXEje1THBju6iExiSsgJaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.0.0", + "cli-cursor": "^4.0.0", + "cli-spinners": "^2.6.1", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^1.1.0", + "log-symbols": "^5.1.0", + "stdin-discarder": "^0.1.0", + "strip-ansi": "^7.0.1", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/password-prompt": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/password-prompt/-/password-prompt-1.1.3.tgz", + "integrity": "sha512-HkrjG2aJlvF0t2BMH0e2LB/EHf3Lcq3fNMzy4GYHcQblAvOl+QQji1Lx7WRBMqpVK8p+KR7bCg7oqAMXtdgqyw==", + "dev": true, + "license": "0BSD", + "dependencies": { + "ansi-escapes": "^4.3.2", + "cross-spawn": "^7.0.3" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "devOptional": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", + "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.1", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redeyed": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", + "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esprima": "~4.0.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "dev": true, + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", + "integrity": "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", + "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^2.0.0", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-glob": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", + "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "globalyzer": "0.1.0", + "globrex": "^0.1.2" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-poet": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-4.15.0.tgz", + "integrity": "sha512-sLLR8yQBvHzi9d4R1F4pd+AzQxBfzOSSjfxiJxQhkUoH5bL7RsAC6wgvtVUQdGqiCsyS9rT6/8X2FI7ipdir5g==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.15", + "prettier": "^2.5.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsv": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/tsv/-/tsv-0.2.0.tgz", + "integrity": "sha512-GG6xbOP85giXXom0dS6z9uyDsxktznjpa1AuDlPrIXDqDnbhjr9Vk6Us8iz6U1nENL4CPS2jZDvIjEdaZsmc4Q==", + "dev": true, + "license": "MIT (ricardo.mit-license.org)" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/twirp-ts": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/twirp-ts/-/twirp-ts-2.5.0.tgz", + "integrity": "sha512-JTKIK5Pf/+3qCrmYDFlqcPPUx+ohEWKBaZy8GL8TmvV2VvC0SXVyNYILO39+GCRbqnuP6hBIF+BVr8ZxRz+6fw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@protobuf-ts/plugin-framework": "^2.0.7", + "camel-case": "^4.1.2", + "dot-object": "^2.1.4", + "path-to-regexp": "^6.2.0", + "ts-poet": "^4.5.0", + "yaml": "^1.10.2" + }, + "bin": { + "protoc-gen-twirp_ts": "protoc-gen-twirp_ts" + }, + "peerDependencies": { + "@protobuf-ts/plugin": "^2.5.0", + "ts-proto": "^1.81.3" + }, + "peerDependenciesMeta": { + "@protobuf-ts/plugin": { + "optional": true + }, + "ts-proto": { + "optional": true + } + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/vite-node/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "extraneous": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/vitest/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "extraneous": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/devvit/package.json b/devvit/package.json new file mode 100644 index 0000000..3953507 --- /dev/null +++ b/devvit/package.json @@ -0,0 +1,26 @@ +{ + "private": true, + "name": "redditmodlog-devvit", + "version": "0.0.1", + "license": "BSD-3-Clause", + "type": "module", + "description": "Publishes a subreddit's moderation log to a wiki page (Reddit Devvit app).", + "main": "src/main.ts", + "scripts": { + "deploy": "devvit upload", + "dev": "devvit playtest", + "playtest": "devvit playtest", + "login": "devvit login", + "launch": "devvit publish", + "type-check": "tsc --build", + "test": "vitest run" + }, + "dependencies": { + "@devvit/public-api": "0.13.5" + }, + "devDependencies": { + "devvit": "0.13.5", + "typescript": "5.8.3", + "vitest": "3.2.4" + } +} diff --git a/devvit/src/main.ts b/devvit/src/main.ts new file mode 100644 index 0000000..eab53cf --- /dev/null +++ b/devvit/src/main.ts @@ -0,0 +1,200 @@ +/** + * main.ts β€” Devvit app entrypoint and wiring. + * + * This is the ONLY module that registers Devvit capabilities. It wires the + * shared pipeline modules together; it contains no business logic of its own + * beyond orchestration and per-invocation error handling (P-29: no daemon loop β€” + * rely on the scheduler's retry semantics + bounded try/catch). + * + * Capabilities registered here: + * - Devvit.configure β€” enable Reddit API + Redis. + * - settings.registerSettings β€” the per-install configuration form. + * - menu.registerMenuItems β€” moderator-only "Publish now" / "Show status". + * - Devvit.addSchedulerJob β€” the recurring ingest+publish+retention job. + * - Devvit.addTrigger β€” AppInstall/AppUpgrade (schedule the cron) and + * ModAction (cheap incremental ingest). + * + * Pipeline (the single shared path, run by both the cron job and the menu): + * loadConfig -> ingest (fetch/filter/anonymize/dedup/persist) + * -> publishFromStore (render -> hash-skip -> create/update wiki) + * -> cleanupOld (retention prune) + * + * INVARIANTS are enforced in the modules this file calls, NOT here: + * INV-1 anonymize (modlog), INV-2 link-gating (modlog/render), + * INV-3 byte cap (render/wiki), INV-4 censor/escape (render), + * INV-5 dedup (storage/modlog), INV-6 hash-skip (wiki), + * INV-7/8 filters (modlog/settings), INV-9 per-install isolation (platform). + * + * Dependency direction (downward only): main -> { settings, menu, modlog, wiki, + * storage }. No module imports main. + */ + +import { Devvit } from '@devvit/public-api'; +import type { ScheduledJobEvent, TriggerContext } from '@devvit/public-api'; + +import { registerSettings, loadConfig } from './settings.js'; +import { registerMenuItems } from './menu.js'; +import { ingest } from './modlog.js'; +import { publishFromStore } from './wiki.js'; +import { + cleanupOld, + recordRunStarted, + recordPublished, +} from './storage.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Scheduler job name. Stable identifier β€” referenced by runJob/cancelJob. */ +const PUBLISH_JOB_NAME = 'publish-modlog'; + +/** + * Cron schedule for the recurring publish job. The legacy Python daemon polled + * every 600s (10 min); the closest sane cron that respects Devvit's scheduler + * cadence is every 10 minutes. Adjust here if the platform imposes a minimum + * interval for a given app tier. + */ +const PUBLISH_CRON = '*/10 * * * *'; + +// --------------------------------------------------------------------------- +// Devvit configuration +// --------------------------------------------------------------------------- + +Devvit.configure({ + redditAPI: true, + redis: true, +}); + +// --------------------------------------------------------------------------- +// Core pipeline β€” shared by the scheduler job and (manually) the menu action. +// --------------------------------------------------------------------------- + +/** + * Run one full publish cycle for the install's subreddit. + * + * Resolves config, ingests new mod-log actions, publishes the rendered wiki + * page (hash-skipped if unchanged), then prunes records past the retention + * window. Returns a small summary for logging. Never throws β€” callers in a + * serverless context must not propagate (the scheduler will retry on its own + * cadence). + */ +async function runPublishCycle(context: TriggerContext): Promise { + const { reddit, redis, settings, subredditName } = context; + + if (!subredditName) { + console.error('[main] runPublishCycle: no subredditName in context; skipping.'); + return; + } + + try { + await recordRunStarted(redis, Date.now()); + + // 1. Resolve config (per-install settings + context subreddit, INV-9). + const cfg = await loadConfig(settings, subredditName); + + // 2. Ingest new actions (fetch -> filter/anonymize -> dedup -> persist). + const { added, scanned } = await ingest(reddit, redis, cfg); + + // 3. Render the store and publish (hash-skip honored unless content changed). + const { wrote, reason } = await publishFromStore(reddit, redis, cfg); + if (wrote) { + await recordPublished(redis, Date.now()); + } + + // 4. Retention prune (INV-9 / time-based). nowSec injected for determinism. + const nowSec = Math.floor(Date.now() / 1000); + const removed = await cleanupOld(redis, cfg.wikiPage, cfg.retentionDays, nowSec); + + console.info( + `[main] cycle complete sub="${subredditName}" page="${cfg.wikiPage}" ` + + `scanned=${scanned} added=${added} wrote=${wrote} reason=${reason} pruned=${removed}`, + ); + } catch (err) { + // P-29/P-30: log rich context, swallow so the platform retries on schedule. + console.error(`[main] runPublishCycle failed for sub="${subredditName}":`, err); + } +} + +// --------------------------------------------------------------------------- +// Scheduler job +// --------------------------------------------------------------------------- + +Devvit.addSchedulerJob({ + name: PUBLISH_JOB_NAME, + onRun: async (_event: ScheduledJobEvent, context) => { + await runPublishCycle(context); + }, +}); + +// --------------------------------------------------------------------------- +// Lifecycle triggers β€” (re)schedule the recurring job on install/upgrade. +// --------------------------------------------------------------------------- + +/** + * Cancel any pre-existing instances of the publish job, then schedule a fresh + * cron instance. Running on both AppInstall and AppUpgrade keeps exactly one + * scheduled job alive and lets a cron-schedule change take effect on upgrade. + */ +async function schedulePublishJob(context: TriggerContext): Promise { + const { scheduler } = context; + try { + // Remove stale instances so an upgrade doesn't stack duplicate jobs. + const existing = await scheduler.listJobs(); + for (const job of existing) { + if (job.name === PUBLISH_JOB_NAME) { + await scheduler.cancelJob(job.id); + } + } + + await scheduler.runJob({ name: PUBLISH_JOB_NAME, cron: PUBLISH_CRON }); + console.info(`[main] scheduled "${PUBLISH_JOB_NAME}" cron="${PUBLISH_CRON}".`); + } catch (err) { + console.error('[main] failed to schedule publish job:', err); + } +} + +Devvit.addTrigger({ + events: ['AppInstall', 'AppUpgrade'], + onEvent: async (_event, context) => { + await schedulePublishJob(context); + }, +}); + +// --------------------------------------------------------------------------- +// ModAction trigger β€” cheap incremental ingest. +// --------------------------------------------------------------------------- +// +// On each moderation action we run an ingest pass so newly-removed content is +// captured promptly without waiting up to a full cron interval. We deliberately +// DO NOT publish from the trigger: publishing is coalesced into the next cron +// run to avoid a wiki write per individual mod action (rate + churn). Ingest is +// idempotent by ModAction.id (INV-5), so trigger + cron overlap is safe. + +Devvit.addTrigger({ + event: 'ModAction', + onEvent: async (_event, context) => { + const { reddit, redis, settings, subredditName } = context; + if (!subredditName) { + return; + } + try { + const cfg = await loadConfig(settings, subredditName); + const { added } = await ingest(reddit, redis, cfg); + if (added > 0) { + console.info(`[main] ModAction trigger ingested ${added} new action(s) for r/${subredditName}.`); + } + } catch (err) { + console.error(`[main] ModAction trigger failed for sub="${subredditName}":`, err); + } + }, +}); + +// --------------------------------------------------------------------------- +// Settings + menu registration (side-effecting; order after configure). +// --------------------------------------------------------------------------- + +registerSettings(); +registerMenuItems(); + +export default Devvit; diff --git a/devvit/src/menu.ts b/devvit/src/menu.ts new file mode 100644 index 0000000..6bff13d --- /dev/null +++ b/devvit/src/menu.ts @@ -0,0 +1,220 @@ +/** + * menu.ts β€” Moderator-only menu actions for RedditModLog (Devvit). + * + * Provides two subreddit menu items, both restricted to moderators + * (`forUserType: 'moderator'`): + * + * 1. "Mod Log: Publish now" β€” manually triggers the full ingest + + * wiki-publish pipeline (the same path the scheduler runs), then + * reports the result via a toast. + * + * 2. "Mod Log: Show status" β€” a tiny read-only status view (last run + * time, last published time, stored entry count) read straight from + * Redis storage. Rendered as a toast to keep the UI minimal, per the + * architecture spec ("Keep UI minimal (blocks or a toast)"). + * + * This module owns NO business logic of its own β€” it is a thin adapter that + * wires Devvit menu events to the shared pipeline modules: + * settings.loadConfig() -> modlog.ingest() -> wiki.publishFromStore() + * and to the storage status accessors. That keeps the manual "Publish now" + * action behaviourally identical to the scheduled job (single code path, + * per the architecture's design note for `publishFromStore`). + * + * Dependency direction (downward only, no cycles): + * menu -> { settings, modlog, wiki, storage } + * + * Registration: import this file from `main.ts`; calling `registerMenuItems()` + * (or simply importing for its side effects) installs the menu items via + * `Devvit.addMenuItem`. We expose an explicit `registerMenuItems()` function + * rather than registering at import time so `main.ts` controls ordering + * (e.g. after `Devvit.configure`). + */ + +import { Devvit } from '@devvit/public-api'; +import type { Context, MenuItemOnPressEvent } from '@devvit/public-api'; + +import { loadConfig } from './settings.js'; +import { ingest } from './modlog.js'; +import { publishFromStore } from './wiki.js'; +import { + getStatus, + recordRunStarted, + recordPublished, +} from './storage.js'; + +/** + * Shape of the lightweight status snapshot read from Redis. + * + * Implemented by `storage.getStatus()`. Fields are optional because a + * freshly-installed app has never run or published yet. + * + * NOTE (storage contract): the Β§1.2 storage spec enumerates dedup / record / + * time-index / wiki-hash / dirty / schema keys. The "last run" and "last + * published" status fields are status metadata that `getStatus` is expected to + * surface. If `storage.ts` names these accessors differently, only the three + * imports above need to change β€” the handlers below are otherwise decoupled. + */ +interface AppStatus { + /** Epoch ms of the last time the ingest+publish pipeline started. */ + lastRunAtMs?: number; + /** Epoch ms of the last time the wiki page was actually written. */ + lastPublishedAtMs?: number; + /** Number of mod-action records currently retained in storage. */ + entryCount: number; +} + +// -------------------------------------------------------------------------- +// Formatting helpers (pure) +// -------------------------------------------------------------------------- + +/** + * Render an epoch-ms timestamp as a compact, human-readable UTC string, or a + * placeholder when the event has never occurred. Toast text is short-lived + * and space-constrained, so we keep this terse. + */ +function formatTimestamp(epochMs?: number): string { + if (!epochMs || !Number.isFinite(epochMs)) { + return 'never'; + } + // ISO 8601 trimmed to minute precision in UTC, e.g. "2026-06-23 14:05Z". + return new Date(epochMs).toISOString().replace('T', ' ').slice(0, 16) + 'Z'; +} + +/** + * Build the single-line status string shown in the "Show status" toast. + * Kept as a pure function so it is trivially unit-testable. + */ +function formatStatusText(status: AppStatus): string { + const entries = `${status.entryCount} entr${status.entryCount === 1 ? 'y' : 'ies'}`; + return ( + `Mod Log status β€” ${entries} stored. ` + + `Last run: ${formatTimestamp(status.lastRunAtMs)}. ` + + `Last published: ${formatTimestamp(status.lastPublishedAtMs)}.` + ); +} + +// -------------------------------------------------------------------------- +// Handlers +// -------------------------------------------------------------------------- + +/** + * Handle the "Publish now" menu action. + * + * Runs the same pipeline as the scheduled job: + * 1. Resolve config from app/install settings. + * 2. Ingest new mod-log actions into storage (deduped, anonymized). + * 3. Render the store to markdown and publish to the wiki, honouring the + * SHA-256 hash-skip (INV-6) so an unchanged page is not rewritten. + * + * All failures are caught and surfaced to the moderator as a neutral toast; + * a serverless menu invocation must never throw unhandled (P-29). Detailed + * context is logged server-side via `console.error` (P-30, coding-style: + * "Log detailed error context on the server side"). + */ +async function handlePublishNow( + _event: MenuItemOnPressEvent, + context: Context, +): Promise { + const { reddit, redis, settings, subredditName, ui } = context; + + // INV-9: the subreddit comes from the install context, never a setting. + if (!subredditName) { + console.error('[menu] Publish now: no subredditName in context; skipping.'); + ui.showToast({ + text: 'Mod Log: could not determine subreddit β€” see app logs.', + appearance: 'neutral', + }); + return; + } + + try { + // Mark the run start so "Show status" reflects manual runs too. + await recordRunStarted(redis, Date.now()); + + const cfg = await loadConfig(settings, subredditName); + + // Ingest is idempotent by ModAction.id (INV-5), so a manual run that + // overlaps the scheduler cannot double-insert records. The Reddit client + // comes from context (classic model has no `reddit` singleton). + const { added } = await ingest(reddit, redis, cfg); + + // Single shared publish path: render store -> hash-skip -> create/update. + const { wrote, reason } = await publishFromStore(reddit, redis, cfg); + + if (wrote) { + // Stamp the published time only when the wiki was actually written. + await recordPublished(redis, Date.now()); + } + + const summary = wrote + ? `Published (${reason}). ${added} new action${added === 1 ? '' : 's'} ingested.` + : `Wiki unchanged (${reason}). ${added} new action${added === 1 ? '' : 's'} ingested.`; + + ui.showToast({ text: `Mod Log: ${summary}`, appearance: 'success' }); + } catch (err) { + console.error('[menu] Publish now failed:', err); + ui.showToast({ + text: 'Mod Log: publish failed β€” see app logs for details.', + appearance: 'neutral', + }); + } +} + +/** + * Handle the "Show status" menu action. + * + * Read-only: pulls the status snapshot from Redis and renders it as a toast. + * Performs no Reddit calls and no writes, so it is safe to invoke freely. + */ +async function handleShowStatus( + _event: MenuItemOnPressEvent, + context: Context, +): Promise { + const { redis, ui } = context; + + try { + const status = (await getStatus(redis)) as AppStatus; + ui.showToast({ text: formatStatusText(status), appearance: 'neutral' }); + } catch (err) { + console.error('[menu] Show status failed:', err); + ui.showToast({ + text: 'Mod Log: could not read status β€” see app logs for details.', + appearance: 'neutral', + }); + } +} + +// -------------------------------------------------------------------------- +// Registration +// -------------------------------------------------------------------------- + +/** + * Register all moderator menu items. Call once from `main.ts`, after + * `Devvit.configure(...)`. + * + * Both items are scoped to `location: 'subreddit'` (the subreddit "..." menu) + * and `forUserType: 'moderator'` so only mods see/trigger them β€” this is the + * Devvit-native authorization gate for menu actions (no manual mod-check + * needed in the handler). + */ +export function registerMenuItems(): void { + Devvit.addMenuItem({ + label: 'Mod Log: Publish now', + description: 'Manually ingest the mod log and publish it to the wiki page.', + location: 'subreddit', + forUserType: 'moderator', + onPress: handlePublishNow, + }); + + Devvit.addMenuItem({ + label: 'Mod Log: Show status', + description: 'Show last run time, last published time, and stored entry count.', + location: 'subreddit', + forUserType: 'moderator', + onPress: handleShowStatus, + }); +} + +// Exported for unit testing of the pure formatting layer. +export { formatStatusText, formatTimestamp }; +export type { AppStatus }; diff --git a/devvit/src/modlog.ts b/devvit/src/modlog.ts new file mode 100644 index 0000000..27f1486 --- /dev/null +++ b/devvit/src/modlog.ts @@ -0,0 +1,245 @@ +/** + * modlog.ts β€” Moderation-log ingestion. + * + * Turns raw `reddit.getModerationLog` output into deduped, anonymized, + * render-ready `ModRecord`s and persists them via the storage layer. + * + * This module owns several binding invariants from the legacy Python app + * (see devvit architecture spec / modlog_wiki_publisher.py β€” read-only ref): + * + * INV-1 Anonymize moderators ALWAYS. Real names never leave this module; + * `ModRecord.moderator` always holds an anonymized label. + * INV-2 NEVER link user profiles. Only post (t3_) and comment (t1_) + * targets get a permalink; user (t2_) / subreddit (t5_) / other + * targets get `permalink: undefined`. + * INV-5 Dedup by ModAction.id β€” each action processed at most once. + * INV-7 Default tracked action types (client-side filter). + * INV-8 Default ignored moderators (filtered by moderatorName). + * + * Dependency direction (downward only): modlog -> { reddit, storage, types }. + * `extractRecord`, `anonymizeMod`, and `deriveDisplay` are pure and exported + * for unit testing. + */ + +import type { ModAction, RedditAPIClient, RedisClient } from '@devvit/public-api'; + +import type { AppConfig, DisplayKind, ModRecord } from './types.js'; +import { ANON_LABEL, LITERAL_MODS } from './types.js'; +import * as storage from './storage.js'; + +/** + * INV-1 β€” Map a raw moderator username to its render-safe label. + * + * `AutoModerator` and `Reddit` (the platform's own automated actor) are kept + * literal so their actions remain attributable to automation; every human + * moderator collapses to a single shared `HumanModerator` label so individual + * mods can never be singled out from the published log. + * + * A missing/empty name is treated as the platform actor and labeled literally + * as `Reddit` (matches legacy behavior where unattributed actions are system + * actions, never a human). + */ +export function anonymizeMod(name: string | undefined | null): string { + const raw = (name ?? '').trim(); + if (raw.length === 0) { + return 'Reddit'; + } + if (LITERAL_MODS.has(raw)) { + return raw; + } + return ANON_LABEL; +} + +/** + * Derive the display kind + short display id from a target fullname. + * + * Reddit fullnames are prefixed by type: + * t1_ -> comment (C) t3_ -> post (P) t2_ -> user (U) t5_ -> subreddit + * Anything else (or a missing target) is a non-content "action" target (A). + * + * Ported from the legacy `generate_display_id`: post/comment ids are shortened + * to the first 6 chars of the bare id when the bare id exceeds 8 chars; user + * and action ids are passed through (with their prefix stripped) verbatim. + * + * Returns the *display* kind/id only β€” link gating (INV-2) is decided in + * `extractRecord`, which has the full target context. + */ +export function deriveDisplay(targetId: string | undefined | null): { + kind: DisplayKind; + displayId?: string; +} { + const fullname = (targetId ?? '').trim(); + if (fullname.length === 0) { + return { kind: 'A' }; + } + + // Split the t#_ prefix from the bare id, if present. + const match = /^t(\d)_(.+)$/.exec(fullname); + const typeNum = match ? match[1] : undefined; + const bareId = match ? match[2] : fullname; + + let kind: DisplayKind; + switch (typeNum) { + case '3': + kind = 'P'; // post + break; + case '1': + kind = 'C'; // comment + break; + case '2': + kind = 'U'; // user + break; + default: + kind = 'A'; // subreddit / award / unknown β€” treated as a generic action + break; + } + + // Shorten long content ids for display (post/comment only), matching the + // legacy 8-char threshold -> 6-char truncation. + let shortBare = bareId; + if ((kind === 'P' || kind === 'C') && bareId.length > 8) { + shortBare = bareId.slice(0, 6); + } + + return { kind, displayId: `${kind}${shortBare}` }; +} + +/** + * Extract the human-readable removal/mod-reason text from a ModAction. + * + * Priority mirrors the legacy publisher: + * 1. `details` β€” for `addremovalreason` the operator-typed reason lands + * here; for most other actions this is the richest field. + * 2. `description` β€” fallback summary string. + * + * The text is stored RAW. Email censoring and pipe-escaping (INV-4) are + * applied later in the pure render layer so the censor regex can evolve + * without a data migration. + */ +function extractReason(action: ModAction): string | undefined { + const details = action.details?.trim(); + if (details) { + return details; + } + const description = action.description?.trim(); + if (description) { + return description; + } + return undefined; +} + +/** + * PURE mapper: ModAction -> ModRecord, or `null` if the action is filtered + * out (untracked type per INV-7, or ignored moderator per INV-8). + * + * No I/O. Dedup (INV-5) and persistence happen in `ingest`. + */ +export function extractRecord(action: ModAction, cfg: AppConfig): ModRecord | null { + // INV-7 β€” only configured action types are tracked. + if (!cfg.wikiActions.includes(action.type)) { + return null; + } + + // INV-8 β€” drop actions performed by ignored moderators (e.g. AutoModerator). + // Compare on the RAW name before anonymization so the ignore list stays + // meaningful. + const rawModerator = (action.moderatorName ?? '').trim(); + if (rawModerator.length > 0 && cfg.ignoredModerators.includes(rawModerator)) { + return null; + } + + const targetId = action.target?.id; + const { kind, displayId } = deriveDisplay(targetId); + + // INV-2 β€” only post/comment targets are ever linkable. A user (U) or + // generic-action (A) target must never produce a profile/permalink. + const permalink = kind === 'P' || kind === 'C' ? action.target?.permalink : undefined; + + const record: ModRecord = { + id: action.id, + createdAtSec: Math.floor(action.createdAt.getTime() / 1000), + actionType: action.type, + moderator: anonymizeMod(action.moderatorName), // INV-1 β€” anonymized at ingest + targetId: targetId, + displayKind: kind, + displayId: displayId, + permalink: permalink, + targetAuthor: action.target?.author, + reason: extractReason(action), + }; + + return record; +} + +/** + * Fetch raw moderation-log actions for the configured subreddit. + * + * Notes on the API shape (verified against @devvit/reddit ModAction model): + * - `type` is a SINGLE-valued filter on GetModerationLogOptions, so it + * cannot express the 7-type tracked set. We omit it and filter + * client-side in `extractRecord`. + * - `limit` must be passed explicitly; the listing default is unbounded + * (Infinity), which we never want. `pageSize` caps per-request fan-out. + * + * Returns the fully-resolved array (newest first, as Reddit returns it). + * + * In the classic `@devvit/public-api` model there is no `reddit` singleton; the + * client is `context.reddit` (a `RedditAPIClient`) threaded in by the caller β€” + * mirrors the pattern in `wiki.ts`. + */ +export async function fetchActions( + reddit: RedditAPIClient, + cfg: AppConfig, +): Promise { + const listing = reddit.getModerationLog({ + subredditName: cfg.subredditName, + limit: cfg.fetchLimit, + pageSize: 100, + }); + + return listing.all(); +} + +/** + * Orchestrate one ingestion pass β€” the single shared path used by both the + * scheduler cron and the onModAction trigger. + * + * fetch -> extract (filter/anonymize/normalize) -> dedup gate -> persist + * + * `markSeen` is an atomic NX set (INV-5): it returns `true` only the first + * time a given action id is seen, so concurrent cron/trigger invocations can + * never double-insert. Records that fail extraction (untracked type / ignored + * mod) are still marked seen so we don't re-evaluate them every pass. + * + * Returns the number of NEW records persisted this pass. On a partial failure + * mid-batch we surface the error to the caller (main.ts), which is responsible + * for logging and letting the scheduler retry β€” there is no daemon loop here. + */ +export async function ingest( + reddit: RedditAPIClient, + redis: RedisClient, + cfg: AppConfig, +): Promise<{ added: number; scanned: number }> { + const actions = await fetchActions(reddit, cfg); + + let added = 0; + for (const action of actions) { + // INV-5 β€” atomic dedup. Skip anything we've already accounted for. + const isNew = await storage.markSeen(redis, cfg.wikiPage, action.id, cfg.retentionDays); + if (!isNew) { + continue; + } + + const record = extractRecord(action, cfg); + if (record === null) { + // Filtered out (untracked type or ignored mod). It is now marked seen, + // so we won't reconsider it on the next pass. + continue; + } + + await storage.putRecord(redis, cfg.wikiPage, record); + added += 1; + } + + return { added, scanned: actions.length }; +} diff --git a/devvit/src/render.ts b/devvit/src/render.ts new file mode 100644 index 0000000..36a3342 --- /dev/null +++ b/devvit/src/render.ts @@ -0,0 +1,423 @@ +/** + * render.ts β€” PURE markdown renderer. + * + * Ported from the legacy Python `modlog_wiki_publisher.py`: + * - build_wiki_content -> buildContent / enforceByteCap + * - format_modlog_entry -> renderRow + * - format_content_link -> contentLink + * - generate_modmail_link-> modmailLink + * - censor_email_addresses -> censorEmail + * - sanitize_for_markdown -> escapePipes + * - get_content_hash -> contentHash + * + * INVARIANTS enforced here: + * INV-2 Never link user profiles. Only records carrying a `permalink` + * (post/comment) emit a hyperlink. Profile records have no permalink. + * INV-3 512 KB wiki cap. Content is trimmed oldest-day-first to <= 90% of cap. + * INV-4 Email censor + pipe-escape applied to every cell of free text. + * INV-6 contentHash() exposes the SHA-256 the wiki layer uses to hash-skip. + * + * This module has ZERO Reddit/Redis I/O and ZERO `@devvit/*` runtime imports. + * It is fully deterministic and unit-testable. The only external dependency is + * the Web Crypto API (`crypto.subtle`), which is available in the Devvit + * serverless runtime. + * + * Types are imported from the shared contract module so the renderer never + * redeclares the record/config shapes (coding-style: no duplication). + */ + +import { + type AppConfig, + type ModRecord, + WIKI_BYTE_CAP, + WIKI_TRIM_TARGET, +} from './types.js'; + +// --------------------------------------------------------------------------- +// Constants ported verbatim from the Python source. +// --------------------------------------------------------------------------- + +/** Reddit base URL used to absolutize relative permalinks. */ +const REDDIT_BASE_URL = 'https://www.reddit.com'; + +/** + * Email-detection regex (INV-4). Ported from Python: + * r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" + * The literal `|` inside the final character class is preserved exactly as the + * Python original wrote it (it matches a literal pipe as well as letters β€” a + * quirk we keep for byte-for-byte output parity). Global flag so every address + * in a multi-address reason is censored. + */ +const EMAIL_REGEX = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g; + +/** Placeholder substituted for any detected email address (INV-4). */ +const EMAIL_PLACEHOLDER = '[EMAIL]'; + +/** Empty-cell marker matching the Python "-" sentinel. */ +const EMPTY_CELL = '-'; + +/** Max characters of a content title before it is ellipsized in modmail. */ +const MAX_TITLE_LENGTH = 50; + +/** + * Human-readable removal-type labels keyed by action type. Ported from the + * Python `type_map` in generate_modmail_link. + */ +const REMOVAL_TYPE_LABELS: Readonly> = Object.freeze({ + removelink: 'Post', + removepost: 'Post', + removecomment: 'Comment', + spamlink: 'Spam Post', + spamcomment: 'Spam Comment', + removecontent: 'Content', + addremovalreason: 'Removal Reason', +}); + +/** Table header + separator rows. Column order ported verbatim. */ +const TABLE_HEADER = '| Time | Action | ID | Moderator | Content | Reason | Inquire |'; +const TABLE_SEPARATOR = '|------|--------|----|-----------|---------|--------|---------|'; + +/** GitHub-credit footer (P-31), ported verbatim. */ +const FOOTER_LINES: readonly string[] = Object.freeze([ + '---', + '', + '*This modlog is automatically maintained by ' + + '[RedditModLog](https://github.com/bakerboy448/RedditModLog) bot.*', +]); + +// --------------------------------------------------------------------------- +// Pure string transforms (INV-4). +// --------------------------------------------------------------------------- + +/** + * Censor email addresses in free text (INV-4). + * Ported from `censor_email_addresses`. Returns the input unchanged when empty. + */ +export function censorEmail(text: string | undefined | null): string { + if (!text) { + return text ?? ''; + } + // `replace` does not mutate the input string (immutability). + return text.replace(EMAIL_REGEX, EMAIL_PLACEHOLDER); +} + +/** + * Escape pipe characters so reason/title text cannot break a markdown table + * cell (INV-4). Ported from `sanitize_for_markdown` β€” pipes become spaces. + * `null`/`undefined` collapse to empty string. + */ +export function escapePipes(text: string | undefined | null): string { + if (text === null || text === undefined) { + return ''; + } + return String(text).replace(/\|/g, ' '); +} + +/** Apply both censor + pipe-escape, the full per-cell sanitization (INV-4). */ +function sanitizeCell(text: string | undefined | null): string { + return escapePipes(censorEmail(text ?? '')); +} + +// --------------------------------------------------------------------------- +// Per-record rendering helpers. +// --------------------------------------------------------------------------- + +/** + * Render the "Content" column hyperlink for a record (INV-2). + * + * A hyperlink is emitted ONLY when the record carries a `permalink`. Per the + * ingest layer (modlog.ts), `permalink` is populated solely for post (`t3_`) + * and comment (`t1_`) targets β€” user/subreddit targets arrive with no + * permalink, so this function can never produce a profile link. Relative + * permalinks are absolutized against the Reddit base URL; already-absolute + * permalinks are used as-is. + * + * The link text is the (sanitized) `displayId` when present, otherwise a + * generic content label. When there is no permalink, the bare display id is + * returned (no link), or the empty-cell marker if even that is absent. + */ +export function contentLink(rec: ModRecord): string { + const label = rec.displayId ? sanitizeCell(rec.displayId) : sanitizeCell('content'); + + if (!rec.permalink) { + // INV-2: no permalink -> never a hyperlink (covers user/subreddit targets). + return rec.displayId ? label : EMPTY_CELL; + } + + const href = rec.permalink.startsWith('http') + ? rec.permalink + : `${REDDIT_BASE_URL}${rec.permalink}`; + + // Markdown link. `href` is a reddit content URL (never a profile, per INV-2). + return `[${label}](${href})`; +} + +/** + * Build the prefilled modmail "removal inquiry" link (P-12). + * Ported from `generate_modmail_link`. + * + * The subject embeds the content id for tracking; the body is a templated + * inquiry. Subject and body are URL-encoded via `encodeURIComponent`, which is + * the JS equivalent of Python's `urllib.parse.quote` for these payloads. + */ +export function modmailLink(sub: string, rec: ModRecord): string { + const removalType = REMOVAL_TYPE_LABELS[rec.actionType] ?? 'Content'; + const contentId = rec.displayId ?? EMPTY_CELL; + + // Title falls back to "Content by u/" then "Unknown content". + let title = rec.targetAuthor + ? `Content by u/${rec.targetAuthor}` + : 'Unknown content'; + if (title.length > MAX_TITLE_LENGTH) { + title = `${title.slice(0, MAX_TITLE_LENGTH - 3)}...`; + } + + // Absolutize the permalink for the body link, if any. + const url = rec.permalink + ? rec.permalink.startsWith('http') + ? rec.permalink + : `${REDDIT_BASE_URL}${rec.permalink}` + : ''; + + const subject = `${removalType} Removal Inquiry - ${title} [ID: ${contentId}]`; + const body = + `Hello Moderators of /r/${sub},\n\n` + + `I would like to inquire about the recent removal of the following ${removalType.toLowerCase()}:\n\n` + + `**Content ID:** ${contentId}\n\n` + + `**Title:** ${title}\n\n` + + `**Action Type:** ${rec.actionType}\n\n` + + `**Link:** ${url}\n\n` + + 'Please provide details regarding this action.\n\n' + + 'Thank you!'; + + const composeUrl = + `${REDDIT_BASE_URL}/message/compose?to=/r/${sub}` + + `&subject=${encodeURIComponent(subject)}` + + `&message=${encodeURIComponent(body)}`; + + return `[Contact Mods](${composeUrl})`; +} + +/** + * Format a record's UTC time-of-day cell (e.g. "14:03:21 UTC"). + * Pure: derived only from the record's epoch-seconds timestamp. + */ +function formatTimeCell(createdAtSec: number): string { + const d = new Date(createdAtSec * 1000); + const hh = String(d.getUTCHours()).padStart(2, '0'); + const mm = String(d.getUTCMinutes()).padStart(2, '0'); + const ss = String(d.getUTCSeconds()).padStart(2, '0'); + return `${hh}:${mm}:${ss} UTC`; +} + +/** Derive the "YYYY-MM-DD" UTC day key used to group rows into tables. */ +function dayKey(createdAtSec: number): string { + return new Date(createdAtSec * 1000).toISOString().slice(0, 10); +} + +/** + * Render a single markdown table row for one record. + * Ported from `format_modlog_entry` + the row-join in `build_wiki_content`. + * + * Note INV-1: `rec.moderator` is ALREADY anonymized at ingest, so the renderer + * simply prints it β€” a real moderator name can never reach this layer. + */ +export function renderRow(rec: ModRecord, cfg: AppConfig): string { + const time = formatTimeCell(rec.createdAtSec); + const action = sanitizeCell(rec.actionType); + const id = rec.displayId ? sanitizeCell(rec.displayId) : EMPTY_CELL; + const moderator = sanitizeCell(rec.moderator) || 'Unknown'; // INV-1 (pre-anonymized) + const content = contentLink(rec); + const reason = rec.reason ? sanitizeCell(rec.reason) : EMPTY_CELL; + const inquire = modmailLink(cfg.subredditName, rec); + + return `| ${time} | ${action} | ${id} | ${moderator} | ${content} | ${reason} | ${inquire} |`; +} + +// --------------------------------------------------------------------------- +// Day-block assembly + byte-cap trimming (INV-3). +// --------------------------------------------------------------------------- + +/** A rendered table for one calendar day, with its day key for sorting/trim. */ +interface DayBlock { + readonly date: string; // "YYYY-MM-DD" + readonly markdown: string; // full "## date\n\n" block +} + +/** + * Group records by UTC day (newest day first) and render one markdown table + * per day. Records are not mutated; a new array of DayBlocks is produced. + */ +function buildDayBlocks(records: readonly ModRecord[], cfg: AppConfig): DayBlock[] { + const byDate = new Map(); + for (const rec of records) { + const key = dayKey(rec.createdAtSec); + const bucket = byDate.get(key); + if (bucket) { + bucket.push(rec); + } else { + byDate.set(key, [rec]); + } + } + + // Dates newest-first; within a day, rows newest-first (matches Python sort). + const sortedDates = [...byDate.keys()].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); + + return sortedDates.map((date) => { + const rows = [...byDate.get(date)!].sort((a, b) => b.createdAtSec - a.createdAtSec); + const lines = [`## ${date}`, TABLE_HEADER, TABLE_SEPARATOR]; + for (const rec of rows) { + lines.push(renderRow(rec, cfg)); + } + lines.push(''); // trailing blank line between day tables + return { date, markdown: lines.join('\n') }; + }); +} + +/** UTF-8 byte length of a string (Devvit runtime provides TextEncoder). */ +function utf8ByteLength(s: string): number { + return new TextEncoder().encode(s).length; +} + +/** + * Assemble header + day blocks + footer, enforcing the 512 KB cap (INV-3). + * + * Mirrors the Python trimming loop: day blocks are added newest-first while the + * running UTF-8 size stays at/under the trim target (90% of cap). The first day + * that would push past the target stops inclusion, and a "trimmed" notice is + * inserted. The header + footer bytes are pre-counted so the final document is + * guaranteed <= WIKI_BYTE_CAP. + */ +export function enforceByteCap( + header: string, + dayBlocks: readonly DayBlock[], + footerLines: readonly string[], +): string { + const footer = footerLines.join('\n'); + + // Pre-count header + footer so day inclusion respects the real budget. + let runningSize = utf8ByteLength(`${header}\n${footer}`); + + const includedParts: string[] = [header]; + let skippedDays = 0; + let lastIncludedDate: string | null = null; + + for (let i = 0; i < dayBlocks.length; i++) { + const block = dayBlocks[i]; + const testSize = runningSize + utf8ByteLength(block.markdown); + + if (testSize > WIKI_TRIM_TARGET) { + // Stop here; everything from i onward is trimmed (oldest-day-first). + skippedDays = dayBlocks.length - i; + break; + } + + includedParts.push(block.markdown); + lastIncludedDate = block.date; + runningSize = testSize; + } + + if (skippedDays > 0) { + const fromDate = lastIncludedDate ?? 'today'; + includedParts.push( + `\n**Note:** ${skippedDays} older day(s) trimmed due to wiki size limits.`, + ); + includedParts.push(`Only showing entries from ${fromDate} onwards.\n`); + } + + includedParts.push(...footerLines); + + let result = includedParts.join('\n'); + + // Defensive hard guard: even after day-trimming, a single oversized day plus + // header/footer could (pathologically) exceed the absolute cap. Truncate on a + // UTF-8 boundary so we never hand the wiki layer an over-cap document (INV-3). + if (utf8ByteLength(result) > WIKI_BYTE_CAP) { + result = truncateToBytes(result, WIKI_BYTE_CAP); + } + + return result; +} + +/** + * Truncate a string so its UTF-8 encoding is at most `maxBytes`, never + * splitting a multi-byte character. Used only as the defensive last-resort + * guard in enforceByteCap. + */ +function truncateToBytes(s: string, maxBytes: number): string { + const encoder = new TextEncoder(); + if (encoder.encode(s).length <= maxBytes) { + return s; + } + // Binary search the largest prefix that fits. + let lo = 0; + let hi = s.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (encoder.encode(s.slice(0, mid)).length <= maxBytes) { + lo = mid; + } else { + hi = mid - 1; + } + } + return s.slice(0, lo); +} + +// --------------------------------------------------------------------------- +// Top-level entry point. +// --------------------------------------------------------------------------- + +/** + * Build the full wiki markdown document from render-ready records (P-10/P-31). + * Ported from `build_wiki_content`. + * + * @param records Render-ready, already-anonymized records (INV-1 satisfied + * upstream). Order is irrelevant; grouped/sorted here. + * @param cfg Resolved app config (subreddit name, max entries, etc.). + * @param nowIso Caller-supplied timestamp string for the "Last Updated" + * header. Passing it in (rather than reading the clock here) + * keeps this function pure/deterministic for tests. + * @returns Capped markdown ready for the wiki layer. + */ +export function buildContent( + records: readonly ModRecord[], + cfg: AppConfig, + nowIso: string, +): string { + const header = `**Last Updated:** ${nowIso}\n\n---\n`; + + if (records.length === 0) { + return `${header}\nNo recent moderation actions found.`; + } + + // Cap to the newest `maxWikiEntries` records before rendering (P-10). + // Records are sorted newest-first so the cap keeps the most recent entries. + const ordered = [...records].sort((a, b) => b.createdAtSec - a.createdAtSec); + const limited = + cfg.maxWikiEntries > 0 && ordered.length > cfg.maxWikiEntries + ? ordered.slice(0, cfg.maxWikiEntries) + : ordered; + + const dayBlocks = buildDayBlocks(limited, cfg); + return enforceByteCap(header, dayBlocks, FOOTER_LINES); +} + +// --------------------------------------------------------------------------- +// Content hashing (INV-6). +// --------------------------------------------------------------------------- + +/** + * SHA-256 hex digest of the rendered markdown (INV-6). Async because Web + * Crypto's `subtle.digest` returns a Promise. The wiki layer compares this + * against the last-written hash to skip no-op writes. + * + * Ported from `get_content_hash` (Python used hashlib.sha256 hexdigest). + */ +export async function contentHash(markdown: string): Promise { + const data = new TextEncoder().encode(markdown); + // crypto.subtle is available globally in the Devvit serverless runtime. + const digest = await crypto.subtle.digest('SHA-256', data); + return [...new Uint8Array(digest)] + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/devvit/src/settings.ts b/devvit/src/settings.ts new file mode 100644 index 0000000..c72d68d --- /dev/null +++ b/devvit/src/settings.ts @@ -0,0 +1,387 @@ +/** + * settings.ts β€” Devvit settings schema + typed config resolver. + * + * Maps the 19 Python config options (CLI / env / JSON in the legacy + * `modlog_wiki_publisher.py`) onto Devvit install-scope (per-subreddit) and + * app-scope settings, then resolves them into a single frozen `AppConfig`. + * + * Binding invariants carried verbatim from the Python app: + * - INV-1 anonymize_moderators is ALWAYS true. It is NOT a user-facing + * setting here (the Python app refused to start when false). It is + * hardcoded below and surfaced read-only on AppConfig. + * - INV-7 default tracked actions = the 7 removal/approval/reason actions. + * - INV-8 default ignored moderators = [AutoModerator]. + * - INV-9 one install == one subreddit. The subreddit name comes from the + * install context, never from a user setting, so cross-subreddit + * mixing is structurally impossible. + * + * Settings API shape verified against reddit/devvit reference apps + * (devvit-sandbox/modlog-archive, devvit-docs app-configurations.md): + * - Devvit.addSettings([ ...fields ]) with field.type in + * 'string' | 'boolean' | 'number' | 'select' | 'paragraph' | 'group' + * - per-field `scope: SettingScope.Installation | SettingScope.App` + * - per-field `onValidate: async ({ value }) => string | void` + * (return a string to REJECT at save time, void/undefined to accept) + * - read at runtime via `context.settings.get(name)` / + * `settings.get(name)`. + * + * @module settings + */ + +import { Devvit, SettingScope } from '@devvit/public-api'; + +import { + ANONYMIZE_MODERATORS, + DEFAULT_FETCH_LIMIT, + DEFAULT_IGNORED_MODS, + DEFAULT_MAX_WIKI_ENTRIES, + DEFAULT_RETENTION_DAYS, + DEFAULT_WIKI_ACTIONS, + DEFAULT_WIKI_PAGE, + FETCH_LIMIT_MAX, + FETCH_LIMIT_MIN, + MAX_WIKI_ENTRIES_MAX, + MAX_WIKI_ENTRIES_MIN, + RETENTION_DAYS_MAX, + RETENTION_DAYS_MIN, + VALID_MODLOG_ACTIONS, + type AppConfig, + type ModActionType, +} from './types.js'; + +// --------------------------------------------------------------------------- +// Setting keys (single source of truth β€” used by both the schema and loader) +// --------------------------------------------------------------------------- + +/** + * Stable setting names. Changing a value here is a data migration (Devvit keys + * the stored value by name), so treat these as frozen identifiers. + */ +export const SETTING_KEYS = { + /** Wiki page slug to publish to. (Python: `wiki_page`, env WIKI_PAGE) */ + wikiPage: 'wikiPage', + /** multiSelect of tracked modlog action types. (Python: `wiki_actions`) */ + wikiActions: 'wikiActions', + /** Comma-separated extra mods to ignore. (Python: `ignored_moderators`) */ + ignoredModerators: 'ignoredModerators', + /** Retention window in days. (Python: `retention_days`, 1..365) */ + retentionDays: 'retentionDays', + /** Max entries rendered per wiki page. (Python: `max_wiki_entries_per_page`) */ + maxWikiEntries: 'maxWikiEntries', + /** Explicit modlog fetch limit per run. (Python: `batch_size`) */ + fetchLimit: 'fetchLimit', +} as const; + +// --------------------------------------------------------------------------- +// Mapping of the 19 legacy Python options +// --------------------------------------------------------------------------- +// +// Legacy option -> Devvit disposition +// -------------------------------------------------------------------------- +// 1 reddit.client_id -> DROP (Devvit owns OAuth; no app credential) +// 2 reddit.client_secret -> DROP (same) +// 3 reddit.username -> DROP (runs as the app account) +// 4 reddit.password -> DROP (no password grant on Devvit) +// 5 source_subreddit -> DROP as a setting; from install context (INV-9) +// 6 wiki_page -> Installation setting `wikiPage` +// 7 retention_days -> Installation setting `retentionDays` (clamped) +// 8 batch_size -> Installation setting `fetchLimit` (clamped) +// 9 update_interval -> DROP (Scheduler cron in devvit.json, not a setting) +// 10 max_wiki_entries_per_page -> Installation setting `maxWikiEntries` (clamped) +// 11 wiki_display_days -> DROP (collapsed into retentionDays; legacy +// constraint was display_days <= retention_days, +// so a single window is sufficient) +// 12 max_continuous_errors -> DROP (no daemon loop; serverless per-invoke) +// 13 rate_limit_buffer -> DROP (platform-managed rate limiting) +// 14 max_batch_retries -> DROP (Scheduler provides retry semantics) +// 15 archive_threshold_days -> DROP (no SQLite archive table; Redis TTL/ZSET) +// 16 anonymize_moderators -> HARDCODED true (INV-1); NOT a setting +// 17 ignored_moderators -> Installation setting `ignoredModerators` +// 18 wiki_actions -> Installation setting `wikiActions` (multiSelect) +// 19 database_path / display_format -> DROP (Redis storage; fixed render format) +// +// Net: 6 of 19 surface as Devvit settings; 1 is hardcoded; 12 are obsolete on +// the Devvit platform. All preserved options keep their Python defaults/ranges. + +// --------------------------------------------------------------------------- +// Settings schema +// --------------------------------------------------------------------------- + +/** + * Build the multiSelect option list for tracked action types from the canonical + * VALID_MODLOG_ACTIONS list so the form and validation never drift. + */ +const WIKI_ACTION_OPTIONS = VALID_MODLOG_ACTIONS.map((action) => ({ + label: action, + value: action, +})); + +/** + * Register the app's configuration form. Moderators edit these on the per-install + * Settings page; all are `SettingScope.Installation` so each subreddit is + * configured independently (reinforces INV-9). There is intentionally NO + * anonymize-moderators toggle (INV-1) and NO Reddit-credential fields (Devvit + * owns auth). + * + * Call once at module load from `main.ts`. + */ +export function registerSettings(): void { + Devvit.addSettings([ + { + type: 'string', + name: SETTING_KEYS.wikiPage, + label: 'Wiki page name to publish the mod log to (e.g. "modlog")', + scope: SettingScope.Installation, + defaultValue: DEFAULT_WIKI_PAGE, + onValidate: ({ value }) => validateWikiPage(value), + }, + { + type: 'select', + name: SETTING_KEYS.wikiActions, + label: 'Mod-log action types to publish', + scope: SettingScope.Installation, + multiSelect: true, + options: WIKI_ACTION_OPTIONS, + // Devvit `select` defaults are an array of option values. + defaultValue: [...DEFAULT_WIKI_ACTIONS], + onValidate: ({ value }) => validateWikiActions(value), + }, + { + type: 'string', + name: SETTING_KEYS.ignoredModerators, + label: + 'Additional moderators to exclude (comma-separated usernames). ' + + 'AutoModerator is always excluded.', + scope: SettingScope.Installation, + // Stored as a CSV string; AutoModerator is merged in by the loader. + defaultValue: '', + }, + { + type: 'number', + name: SETTING_KEYS.retentionDays, + label: `Days of history to keep (${RETENTION_DAYS_MIN}-${RETENTION_DAYS_MAX})`, + scope: SettingScope.Installation, + defaultValue: DEFAULT_RETENTION_DAYS, + onValidate: ({ value }) => + validateNumberRange(value, RETENTION_DAYS_MIN, RETENTION_DAYS_MAX, 'Retention days'), + }, + { + type: 'number', + name: SETTING_KEYS.maxWikiEntries, + label: `Maximum entries to render on the wiki page (${MAX_WIKI_ENTRIES_MIN}-${MAX_WIKI_ENTRIES_MAX})`, + scope: SettingScope.Installation, + defaultValue: DEFAULT_MAX_WIKI_ENTRIES, + onValidate: ({ value }) => + validateNumberRange(value, MAX_WIKI_ENTRIES_MIN, MAX_WIKI_ENTRIES_MAX, 'Max wiki entries'), + }, + { + type: 'number', + name: SETTING_KEYS.fetchLimit, + label: `Mod-log entries to fetch per run (${FETCH_LIMIT_MIN}-${FETCH_LIMIT_MAX})`, + scope: SettingScope.Installation, + defaultValue: DEFAULT_FETCH_LIMIT, + onValidate: ({ value }) => + validateNumberRange(value, FETCH_LIMIT_MIN, FETCH_LIMIT_MAX, 'Fetch limit'), + }, + ]); +} + +// --------------------------------------------------------------------------- +// Validation handlers (return a string to REJECT, void to accept) +// --------------------------------------------------------------------------- + +/** Reddit wiki slugs: lowercase letters, digits, and `/_-` separators. */ +const WIKI_PAGE_RE = /^[a-z0-9][a-z0-9/_-]*$/; + +/** + * Reject empty/malformed wiki page slugs. Returns an error string on failure, + * void on success (Devvit `onValidate` contract). + */ +export function validateWikiPage(value: string | undefined): string | void { + const slug = (value ?? '').trim(); + if (slug.length === 0) { + return 'Wiki page name is required.'; + } + if (slug.length > 256) { + return 'Wiki page name is too long (max 256 characters).'; + } + if (!WIKI_PAGE_RE.test(slug)) { + return 'Use lowercase letters, numbers, and / _ - only (e.g. "modlog" or "logs/modlog").'; + } +} + +/** + * Reject unknown / empty action selections. The multiSelect already constrains + * choices to VALID_MODLOG_ACTIONS, but we defend against an empty selection + * (which would publish nothing) and any out-of-set value. + */ +export function validateWikiActions(value: string[] | undefined): string | void { + const selected = value ?? []; + if (selected.length === 0) { + return 'Select at least one action type to publish.'; + } + const unknown = selected.filter( + (action) => !VALID_MODLOG_ACTIONS.includes(action as ModActionType), + ); + if (unknown.length > 0) { + return `Unknown action type(s): ${unknown.join(', ')}.`; + } +} + +/** + * Inclusive numeric range validator shared by retention/maxEntries/fetchLimit. + * Rejects non-finite, non-integer, and out-of-range values. + */ +export function validateNumberRange( + value: number | undefined, + min: number, + max: number, + label: string, +): string | void { + if (value === undefined || value === null || !Number.isFinite(value)) { + return `${label} is required and must be a number.`; + } + if (!Number.isInteger(value)) { + return `${label} must be a whole number.`; + } + if (value < min || value > max) { + return `${label} must be between ${min} and ${max}.`; + } +} + +// --------------------------------------------------------------------------- +// Config resolution +// --------------------------------------------------------------------------- + +/** + * Minimal structural shape of the Devvit settings reader. Both + * `context.settings` and the destructured `settings` handler arg satisfy this, + * so the loader is decoupled from how the caller obtained the handle. + */ +export interface SettingsReader { + get(name: string): Promise; +} + +/** + * Clamp a value into [min, max]. Validation already rejects out-of-range input + * at save time, but app-scope defaults or legacy stored values could still fall + * outside the band β€” clamp defensively and log when we do (parity with the + * Python `validate_config_value` warn-and-clamp behavior). + */ +function clamp(value: number, min: number, max: number, label: string): number { + if (value < min) { + console.warn(`[settings] ${label}=${value} below min ${min}; clamping to ${min}`); + return min; + } + if (value > max) { + console.warn(`[settings] ${label}=${value} above max ${max}; clamping to ${max}`); + return max; + } + return value; +} + +/** + * Coerce a possibly-undefined number setting to a finite integer, falling back + * to `fallback` when absent or invalid. + */ +function asInt(value: number | undefined, fallback: number): number { + if (value === undefined || value === null || !Number.isFinite(value)) { + return fallback; + } + return Math.trunc(value); +} + +/** + * Parse the comma-separated `ignoredModerators` CSV into a normalized list and + * ALWAYS union in the default ignored mods (INV-8: AutoModerator is + * non-removable). Usernames are lowercased for case-insensitive comparison at + * the filter site, deduped, and stripped of an optional `u/` prefix. + */ +function parseIgnoredModerators(csv: string | undefined): string[] { + const fromUser = (csv ?? '') + .split(',') + .map((name) => name.trim().replace(/^\/?u\//i, '')) + .filter((name) => name.length > 0); + + const merged = [...DEFAULT_IGNORED_MODS, ...fromUser].map((name) => name.toLowerCase()); + return Array.from(new Set(merged)); +} + +/** + * Validate the stored action selection against the canonical list, dropping any + * unknown values (P-22). Falls back to the INV-7 defaults if nothing valid + * remains, so the publisher never silently produces an empty page from a bad + * stored value. + */ +function parseWikiActions(value: string[] | undefined): ModActionType[] { + const selected = value ?? []; + const valid = selected.filter((action): action is ModActionType => + VALID_MODLOG_ACTIONS.includes(action as ModActionType), + ); + const dropped = selected.length - valid.length; + if (dropped > 0) { + console.warn(`[settings] dropped ${dropped} unknown wikiActions value(s)`); + } + return valid.length > 0 ? valid : [...DEFAULT_WIKI_ACTIONS]; +} + +/** + * Resolve all settings into a single validated, clamped, frozen `AppConfig`. + * + * @param settings A Devvit settings reader (`context.settings` or the + * destructured handler `settings` arg). + * @param subredditName The install's subreddit, from context (INV-9) β€” never + * a user setting. + * @returns A deeply-frozen `AppConfig`. `anonymizeModerators` is always true + * (INV-1) and is not derived from any setting. + */ +export async function loadConfig( + settings: SettingsReader, + subredditName: string, +): Promise { + // Read every setting up front; unrelated reads have no dependencies. + const [wikiPageRaw, wikiActionsRaw, ignoredRaw, retentionRaw, maxEntriesRaw, fetchLimitRaw] = + await Promise.all([ + settings.get(SETTING_KEYS.wikiPage), + settings.get(SETTING_KEYS.wikiActions), + settings.get(SETTING_KEYS.ignoredModerators), + settings.get(SETTING_KEYS.retentionDays), + settings.get(SETTING_KEYS.maxWikiEntries), + settings.get(SETTING_KEYS.fetchLimit), + ]); + + const wikiPage = (wikiPageRaw ?? '').trim() || DEFAULT_WIKI_PAGE; + + const config: AppConfig = { + subredditName, + wikiPage, + wikiActions: parseWikiActions(wikiActionsRaw), + ignoredModerators: parseIgnoredModerators(ignoredRaw), + retentionDays: clamp( + asInt(retentionRaw, DEFAULT_RETENTION_DAYS), + RETENTION_DAYS_MIN, + RETENTION_DAYS_MAX, + 'retentionDays', + ), + maxWikiEntries: clamp( + asInt(maxEntriesRaw, DEFAULT_MAX_WIKI_ENTRIES), + MAX_WIKI_ENTRIES_MIN, + MAX_WIKI_ENTRIES_MAX, + 'maxWikiEntries', + ), + fetchLimit: clamp( + asInt(fetchLimitRaw, DEFAULT_FETCH_LIMIT), + FETCH_LIMIT_MIN, + FETCH_LIMIT_MAX, + 'fetchLimit', + ), + // INV-1: anonymization is mandatory and not user-configurable. + anonymizeModerators: ANONYMIZE_MODERATORS, + }; + + // Immutability (coding-style rule): hand callers a frozen snapshot. The + // nested arrays are frozen too so no consumer can mutate shared config state. + Object.freeze(config.wikiActions); + Object.freeze(config.ignoredModerators); + return Object.freeze(config); +} diff --git a/devvit/src/storage.ts b/devvit/src/storage.ts new file mode 100644 index 0000000..f592bcd --- /dev/null +++ b/devvit/src/storage.ts @@ -0,0 +1,406 @@ +/** + * storage.ts β€” Redis data-access layer (Repository pattern). + * + * This is the ONLY module that talks to Redis. Every other module depends on + * the exported async functions here, never on raw Redis commands (patterns.md + * Repository pattern). It encapsulates four concerns: + * + * 1. Dedup of processed ModAction ids (INV-5) + * 2. The render-ready action-record collection (rebuild source) + * 3. Retention via a sorted-set keyed by created_at secs (INV-9 / retention) + * 4. The wiki content-hash cache (INV-6) + * + * Per-subreddit isolation (INV-9) is structural: Devvit name-spaces every app + * INSTALLATION's Redis data by subreddit, so there is NO `subreddit` column and + * cross-subreddit mixing is impossible. Key prefixes below are therefore scoped + * only by `wikiPage`, allowing multiple wiki pages per install without collision. + * + * No KEYS / global SCAN is used anywhere (Devvit forbids it) β€” every collection + * is rooted at an explicit, known key. + * + * Devvit Redis API notes (verified against reddit/devvit + * packages/redis/src/RedisClient.ts): + * - `set(key, value, { nx, expiration })` β€” `expiration` is a Date, and the + * return value is the stored string, NOT a NX-success boolean. So we do NOT + * use `set` for atomic dedup-newness detection. + * - `hSetNX(key, field, value)` returns 1 when the field was newly created and + * 0 when it already existed β€” this IS race-free and is our dedup primitive. + * - `zAdd(key, ...members)` takes spread `ZMember[]` (not an array). + * - `zRange(key, start, stop, { by:'score' })` returns `{member,score}[]` and + * internally caps at 1000 members per call (we loop to drain). + * - `zRemRangeByScore(key, min, max)` prunes by score window. + * - `hGetAll` returns `Record`; `hDel(key, fields[])`. + */ + +// The prompt mandates importing from '@devvit/public-api', which re-exports the +// Redis types. `RedisClient` is the structural type of the `redis` handle that +// callers obtain from their Devvit context / `@devvit/redis`. +import type { RedisClient } from '@devvit/public-api'; + +// --------------------------------------------------------------------------- +// Record type β€” the persisted, render-ready shape of one moderation action. +// --------------------------------------------------------------------------- + +/** P/C/U/A display-prefix kind derived from the target fullname. */ +export type DisplayKind = 'P' | 'C' | 'U' | 'A'; + +/** + * One moderation-log action, stored in render-ready form. Enough is persisted + * to rebuild the entire wiki page from Redis alone (no re-fetch required). + * + * INVARIANTS baked into this shape: + * - `moderator` is ALREADY anonymized at ingest (INV-1) β€” a real moderator + * name must NEVER reach Redis. Only `HumanModerator`, `AutoModerator`, or + * `Reddit` are valid here. + * - `permalink` is present ONLY for posts/comments (INV-2). For user/subreddit + * targets it is `undefined` so the render layer cannot emit a profile link. + * - `reason` is stored RAW; email-censor + pipe-escape (INV-4) are applied at + * render time, keeping storage idempotent and the censor regex evolvable + * without a data migration. + */ +export interface ModActionRecord { + /** ModAction.id β€” the dedup key (INV-5). Also the hash field + zset member. */ + id: string; + /** Epoch SECONDS of the action's creation; the sorted-set score (retention). */ + createdAtSec: number; + /** The tracked action type (e.g. 'removelink'); already filtered to INV-7. */ + actionType: string; + /** ALREADY-anonymized moderator label (INV-1) β€” never a real name. */ + moderator: string; + /** Target fullname (t3_/t1_/t2_/t5_...), if any. */ + targetId?: string; + /** P (post) | C (comment) | U (user) | A (any/other). */ + displayKind: DisplayKind; + /** Short, prefixed display id (e.g. 'P:abc123'), if a target exists. */ + displayId?: string; + /** Post/comment permalink ONLY (INV-2); undefined for profiles/subreddits. */ + permalink?: string; + /** Target author handle, for the modmail removal-inquiry prefill. */ + targetAuthor?: string; + /** RAW reason text; sanitized at render (INV-4). */ + reason?: string; +} + +// --------------------------------------------------------------------------- +// Key layout. All keys are scoped by wiki page; the install is already scoped +// by subreddit (INV-9), so no subreddit segment is needed. +// --------------------------------------------------------------------------- + +/** Hash of seen action ids β€” field=actionId, value='1'. Powers atomic dedup. */ +const seenKey = (page: string): string => `seen:${page}`; +/** Hash of full records β€” field=actionId, value=JSON(ModActionRecord). */ +const actionsKey = (page: string): string => `actions:${page}`; +/** Sorted set: member=actionId, score=createdAtSec. Drives retention pruning. */ +const timeIndexKey = (page: string): string => `actions_by_time:${page}`; +/** Hash of wiki content hashes β€” field=page, value=sha256hex. */ +const WIKI_HASH_KEY = 'wiki_hash'; + +/** zRange returns at most this many members per call; drain loops honor it. */ +const ZRANGE_PAGE_LIMIT = 1000; + +// --------------------------------------------------------------------------- +// 1. Dedup (INV-5) +// --------------------------------------------------------------------------- + +/** + * Atomically test-and-mark an action id as processed. + * + * Returns `true` if this id had NOT been seen before (caller SHOULD process and + * persist it), or `false` if it was already processed (caller skips). This is + * race-free: `hSetNX` only sets β€” and returns truthy β€” when the field is new, + * so two concurrent invocations (cron + onModAction trigger) can never both + * believe they "won" the same id. + * + * Naming: exported as `isProcessed` per the module contract. Note the inverted + * sense β€” it returns whether the action is NEW (not-yet-processed). It also + * has the side effect of MARKING the id, by design, so the dedup decision and + * the claim are a single atomic step. + * + * @param redis Devvit redis handle (subreddit-scoped by the platform). + * @param page Wiki page these actions belong to. + * @param actionId The ModAction.id to test-and-claim. + * @returns `true` when newly claimed (process it); `false` when duplicate. + */ +export async function isProcessed( + redis: RedisClient, + page: string, + actionId: string, +): Promise { + // hSetNX => 1 (truthy) when newly created, 0 when the field already existed. + const created = await redis.hSetNX(seenKey(page), actionId, '1'); + return !created; +} + +// --------------------------------------------------------------------------- +// 2. Record collection (rebuild source) +// --------------------------------------------------------------------------- + +/** + * Persist one render-ready record. Writes the full record into the `actions` + * hash AND indexes it in the time-ordered sorted set so retention pruning and + * "newest N" reads both work. Re-recording the same id is idempotent (hash + * field + zset member are overwritten in place), which lets force-rebuild + * re-ingest without creating duplicates. + * + * NOTE: the two writes are not wrapped in a transaction. With the cron job as + * the normal single writer the worst-case interleave (an entry in the hash but + * not yet the zset, or vice versa) is self-healing on the next run, and a + * partially-indexed record still renders. If the onModAction trigger and cron + * are later allowed to write concurrently, wrap these in `watch/multi/exec`. + * + * @param redis Devvit redis handle. + * @param page Wiki page scope. + * @param record The fully-extracted, ALREADY-anonymized record (INV-1). + */ +export async function recordAction( + redis: RedisClient, + page: string, + record: ModActionRecord, +): Promise { + await redis.hSet(actionsKey(page), { [record.id]: JSON.stringify(record) }); + await redis.zAdd(timeIndexKey(page), { + member: record.id, + score: record.createdAtSec, + }); +} + +/** + * Return the most-recent records, newest first, capped to `maxEntries`. + * + * Reads ids newest-first from the time index (reverse score order), then + * batch-resolves their full records from the `actions` hash. Records that are + * missing or fail to parse are skipped defensively (never throws on bad data). + * + * @param redis Devvit redis handle. + * @param page Wiki page scope. + * @param maxEntries Upper bound on returned records (e.g. AppConfig.maxWikiEntries). + * @returns Up to `maxEntries` records, sorted createdAtSec descending. + */ +export async function getRecentActions( + redis: RedisClient, + page: string, + maxEntries: number, +): Promise { + if (maxEntries <= 0) return []; + + // Pull the newest ids from the sorted set. `reverse: true` gives high scores + // (most recent) first; we only need the newest `maxEntries`. + // Full score window (-inf..+inf) expressed as Β±Infinity bounds. + const indexed = await redis.zRange(timeIndexKey(page), -Infinity, +Infinity, { + by: 'score', + reverse: true, + limit: { offset: 0, count: maxEntries }, + }); + if (indexed.length === 0) return []; + + // Resolve full records. hGetAll is a single round-trip; we then look up by id + // preserving the time-index ordering (which is authoritative for "newest"). + const all = await redis.hGetAll(actionsKey(page)); + if (!all) return []; + + const records: ModActionRecord[] = []; + for (const { member } of indexed) { + const raw = all[member]; + if (!raw) continue; // record evicted/missing β€” skip rather than fail. + try { + records.push(JSON.parse(raw) as ModActionRecord); + } catch { + // Corrupt JSON for one record must not break the whole rebuild. + continue; + } + } + return records; +} + +// --------------------------------------------------------------------------- +// 3. Retention (INV-9 / time-based pruning) +// --------------------------------------------------------------------------- + +/** + * Delete every record older than `retentionDays` relative to `nowSec`. + * + * Works off the sorted-set time index: it pages out members whose score is at + * or below the cutoff (honoring the 1000-member-per-zRange limit by looping), + * removes them from the `actions` hash, the `seen` dedup hash, and finally + * trims the time index by score. Returns the total number of records removed. + * + * The `seen` dedup field is also pruned so an old, since-deleted action can be + * re-recorded if it somehow reappears; this bounds the dedup hash to roughly + * the retention window rather than growing unbounded. + * + * @param redis Devvit redis handle. + * @param page Wiki page scope. + * @param retentionDays Days to keep (e.g. AppConfig.retentionDays, clamped 1..365). + * @param nowSec Current epoch seconds (injected for testability). + * @returns Count of records removed. + */ +export async function cleanupOld( + redis: RedisClient, + page: string, + retentionDays: number, + nowSec: number, +): Promise { + const cutoffSec = nowSec - retentionDays * 86_400; + if (cutoffSec <= 0) return 0; + + let totalRemoved = 0; + + // Drain expired members in pages of up to ZRANGE_PAGE_LIMIT. We re-query the + // bottom of the index each iteration because zRemRangeByScore shifts it. + for (;;) { + const expired = await redis.zRange(timeIndexKey(page), -Infinity, cutoffSec, { + by: 'score', + limit: { offset: 0, count: ZRANGE_PAGE_LIMIT }, + }); + if (expired.length === 0) break; + + const ids = expired.map((m) => m.member); + // Remove the full records and their dedup markers. + await redis.hDel(actionsKey(page), ids); + await redis.hDel(seenKey(page), ids); + totalRemoved += ids.length; + + if (expired.length < ZRANGE_PAGE_LIMIT) break; // last (partial) page handled. + } + + // Finally trim the time index itself in one shot (idempotent if already gone). + if (totalRemoved > 0) { + await redis.zRemRangeByScore(timeIndexKey(page), -Infinity, cutoffSec); + } + + return totalRemoved; +} + +// --------------------------------------------------------------------------- +// 4. Wiki content-hash cache (INV-6) +// --------------------------------------------------------------------------- + +/** + * Get the last-written content hash for a wiki page, or `undefined` if the page + * has never been published (or its hash is not yet cached). + * + * @param redis Devvit redis handle. + * @param page Wiki page name. + */ +export async function getWikiHash( + redis: RedisClient, + page: string, +): Promise { + const all = await redis.hGetAll(WIKI_HASH_KEY); + if (!all) return undefined; + return all[page] ?? undefined; +} + +/** + * Store the content hash that was just written to a wiki page, so the next + * publish can skip an unchanged write (INV-6). + * + * @param redis Devvit redis handle. + * @param page Wiki page name. + * @param hash SHA-256 hex of the content that was published. + */ +export async function setWikiHash( + redis: RedisClient, + page: string, + hash: string, +): Promise { + await redis.hSet(WIKI_HASH_KEY, { [page]: hash }); +} + +// --------------------------------------------------------------------------- +// 5. Spec-name compatibility layer + status accessors +// --------------------------------------------------------------------------- +// +// The architecture spec (and the sibling modlog/wiki/menu modules) reference +// these function names; the implementations above were authored under slightly +// different names. The thin adapters below bind the spec names to the +// implementations so the whole project links without duplicating logic. +// +// Drift map (spec name -> implementation): +// markSeen -> inverse of isProcessed (markSeen returns TRUE when NEW) +// putRecord -> recordAction +// getAllRecords -> getRecentActions (newest-first, default cap) +// getStatus / recordRunStarted / recordPublished -> status hash (added below) + +/** Default cap for getAllRecords when no explicit limit is supplied. */ +const DEFAULT_RECORD_FETCH_CAP = 2000; + +/** Status hash: field=metric, value=epoch-ms string. */ +const STATUS_KEY = 'status'; +const STATUS_FIELD_LAST_RUN = 'lastRunAtMs'; +const STATUS_FIELD_LAST_PUBLISHED = 'lastPublishedAtMs'; + +/** + * The status accessors are not page-scoped in the menu contract, so they read + * the default wiki page for the entry count. Multi-page installs still publish + * correctly; the status entry count just reflects the primary page. + */ +const DEFAULT_STATUS_PAGE = 'modlog'; + +/** + * Atomic test-and-mark for dedup (INV-5), spec spelling. + * + * Returns `true` when the action id was NOT previously seen (caller should + * process it), `false` when it was already processed. This is the inverse sense + * of `isProcessed` (which returns `true` for duplicates), so we negate. + * + * `retentionDays` is accepted for signature compatibility with the spec; the + * underlying dedup hash is pruned by `cleanupOld` rather than per-key TTL. + */ +export async function markSeen( + redis: RedisClient, + page: string, + actionId: string, + _retentionDays?: number, +): Promise { + const duplicate = await isProcessed(redis, page, actionId); + return !duplicate; +} + +/** Persist one render-ready record (spec spelling for `recordAction`). */ +export async function putRecord( + redis: RedisClient, + page: string, + record: ModActionRecord, +): Promise { + return recordAction(redis, page, record); +} + +/** + * Return all retained records newest-first (spec spelling). The wiki layer caps + * to maxWikiEntries during render, so a generous default cap here is safe. + */ +export async function getAllRecords( + redis: RedisClient, + page: string, + maxEntries: number = DEFAULT_RECORD_FETCH_CAP, +): Promise { + return getRecentActions(redis, page, maxEntries); +} + +/** Read the lightweight status snapshot used by the "Show status" menu item. */ +export async function getStatus( + redis: RedisClient, +): Promise<{ lastRunAtMs?: number; lastPublishedAtMs?: number; entryCount: number }> { + const status = await redis.hGetAll(STATUS_KEY); + const lastRun = status?.[STATUS_FIELD_LAST_RUN]; + const lastPub = status?.[STATUS_FIELD_LAST_PUBLISHED]; + // entryCount is best-effort: count of fields in the actions hash. + const actions = await redis.hGetAll(actionsKey(DEFAULT_STATUS_PAGE)); + const entryCount = actions ? Object.keys(actions).length : 0; + return { + lastRunAtMs: lastRun ? Number(lastRun) : undefined, + lastPublishedAtMs: lastPub ? Number(lastPub) : undefined, + entryCount, + }; +} + +/** Stamp the last-run-started time (epoch ms). */ +export async function recordRunStarted(redis: RedisClient, epochMs: number): Promise { + await redis.hSet(STATUS_KEY, { [STATUS_FIELD_LAST_RUN]: String(epochMs) }); +} + +/** Stamp the last-published time (epoch ms). */ +export async function recordPublished(redis: RedisClient, epochMs: number): Promise { + await redis.hSet(STATUS_KEY, { [STATUS_FIELD_LAST_PUBLISHED]: String(epochMs) }); +} diff --git a/devvit/src/types.ts b/devvit/src/types.ts new file mode 100644 index 0000000..b5aff1c --- /dev/null +++ b/devvit/src/types.ts @@ -0,0 +1,193 @@ +/** + * types.ts β€” Shared contracts and frozen constants (single source of truth). + * + * Every other module imports its cross-cutting types and constants from here so + * the record/config shapes are declared exactly once (coding-style: no + * duplication). This module has NO runtime behavior and NO `@devvit/*` imports, + * which keeps the pure render layer free of platform dependencies. + * + * The invariants referenced below (INV-1..INV-9) are the binding rules carried + * verbatim from the legacy Python publisher (`modlog_wiki_publisher.py`): + * + * INV-1 Anonymize moderators ALWAYS (no toggle). + * INV-2 Never link user profiles (only post/comment permalinks). + * INV-3 512 KB wiki cap. + * INV-4 Email censor + pipe-escape on free text. + * INV-5 Dedup by ModAction.id. + * INV-6 Wiki hash-skip on unchanged content. + * INV-7 Default tracked action types. + * INV-8 Default ignored moderators ([AutoModerator]). + * INV-9 One install == one subreddit (Redis is install-scoped). + */ + +// --------------------------------------------------------------------------- +// Action-type model +// --------------------------------------------------------------------------- + +/** + * A moderation-log action type. + * + * Typed as `string` rather than a closed union so it stays assignment-compatible + * with Devvit's own `ModActionType` union (which `modlog.ts` reads off + * `ModAction.type`) WITHOUT pulling a `@devvit/*` import into the pure layers. + * The canonical tracked set is constrained at the edges by `VALID_MODLOG_ACTIONS` + * + the settings validators, not by the type system. + */ +export type ModActionType = string; + +/** P (post) | C (comment) | U (user) | A (any/other) display-prefix kind. */ +export type DisplayKind = 'P' | 'C' | 'U' | 'A'; + +// --------------------------------------------------------------------------- +// Wiki byte cap (INV-3) +// --------------------------------------------------------------------------- + +/** Hard wiki content cap in UTF-8 bytes (Reddit limit). */ +export const WIKI_BYTE_CAP = 524_288; + +/** Trim target: day-blocks are dropped until content fits in <= 90% of the cap. */ +export const WIKI_TRIM_TARGET = Math.floor(WIKI_BYTE_CAP * 0.9); + +// --------------------------------------------------------------------------- +// Anonymization (INV-1) +// --------------------------------------------------------------------------- + +/** + * Anonymization is mandatory and not user-configurable (the Python app refused + * to start when it was false). Exposed read-only on AppConfig for clarity. + */ +export const ANONYMIZE_MODERATORS = true as const; + +/** Single shared label every human moderator collapses to (INV-1). */ +export const ANON_LABEL = 'HumanModerator'; + +/** + * Actors kept literal (NOT anonymized): platform automation. Everything else is + * a human moderator and collapses to ANON_LABEL. + */ +export const LITERAL_MODS: ReadonlySet = new Set(['AutoModerator', 'Reddit']); + +// --------------------------------------------------------------------------- +// Tracked actions (INV-7) + valid set +// --------------------------------------------------------------------------- + +/** + * The full set of moderation-log action types the app understands. The settings + * multiSelect and validators are derived from this list so the form and the + * runtime filter never drift. Every value here exists in Devvit's ModActionType + * union (verified against reddit/devvit ModAction.ts). + */ +export const VALID_MODLOG_ACTIONS: readonly ModActionType[] = Object.freeze([ + 'removelink', + 'removecomment', + 'spamlink', + 'spamcomment', + 'addremovalreason', + 'approvelink', + 'approvecomment', +]); + +/** Default tracked action types (INV-7) β€” the 7 removal/spam/approval/reason actions. */ +export const DEFAULT_WIKI_ACTIONS: readonly ModActionType[] = Object.freeze([ + 'removelink', + 'removecomment', + 'spamlink', + 'spamcomment', + 'addremovalreason', + 'approvelink', + 'approvecomment', +]); + +// --------------------------------------------------------------------------- +// Ignored moderators (INV-8) +// --------------------------------------------------------------------------- + +/** Always-ignored moderators (INV-8). AutoModerator is non-removable. */ +export const DEFAULT_IGNORED_MODS: readonly string[] = Object.freeze(['AutoModerator']); + +// --------------------------------------------------------------------------- +// Numeric settings: defaults + clamp bands +// --------------------------------------------------------------------------- + +/** Default wiki page slug to publish to (Python default: "modlog"). */ +export const DEFAULT_WIKI_PAGE = 'modlog'; + +/** Retention window in days (Python default 90; band 1..365). */ +export const DEFAULT_RETENTION_DAYS = 90; +export const RETENTION_DAYS_MIN = 1; +export const RETENTION_DAYS_MAX = 365; + +/** Max entries rendered per wiki page (band 100..2000). */ +export const DEFAULT_MAX_WIKI_ENTRIES = 1000; +export const MAX_WIKI_ENTRIES_MIN = 100; +export const MAX_WIKI_ENTRIES_MAX = 2000; + +/** Mod-log entries fetched per run (band 50..1000). */ +export const DEFAULT_FETCH_LIMIT = 500; +export const FETCH_LIMIT_MIN = 50; +export const FETCH_LIMIT_MAX = 1000; + +/** Storage schema version marker (forward-migration hook). */ +export const SCHEMA_VERSION = 1; + +// --------------------------------------------------------------------------- +// Record + config shapes +// --------------------------------------------------------------------------- + +/** + * One moderation-log action in persisted, render-ready form. Enough is stored to + * rebuild the entire wiki page from Redis alone (no re-fetch required). + * + * INVARIANTS baked into this shape: + * - `moderator` is ALREADY anonymized at ingest (INV-1) β€” a real moderator + * name must NEVER reach Redis or the render layer. + * - `permalink` is present ONLY for posts/comments (INV-2); user/subreddit + * targets carry `undefined`, so render can never emit a profile link. + * - `reason` is stored RAW; censor + pipe-escape (INV-4) are applied at + * render time so the censor regex can evolve without a data migration. + */ +export interface ModRecord { + /** ModAction.id β€” the dedup key (INV-5); also the hash field + zset member. */ + id: string; + /** Epoch SECONDS of the action's creation; the sorted-set score (retention). */ + createdAtSec: number; + /** Tracked action type (e.g. 'removelink'); already filtered to INV-7. */ + actionType: ModActionType; + /** ALREADY-anonymized moderator label (INV-1) β€” never a real name. */ + moderator: string; + /** Target fullname (t3_/t1_/t2_/t5_...), if any. */ + targetId?: string; + /** P (post) | C (comment) | U (user) | A (any/other). */ + displayKind: DisplayKind; + /** Short, prefixed display id (e.g. 'Pabc123'), if a target exists. */ + displayId?: string; + /** Post/comment permalink ONLY (INV-2); undefined for profiles/subreddits. */ + permalink?: string; + /** Target author handle, for the modmail removal-inquiry prefill. */ + targetAuthor?: string; + /** RAW reason text; sanitized at render (INV-4). */ + reason?: string; +} + +/** + * Resolved, validated application configuration for one install (INV-9). + * Produced by `settings.loadConfig`; consumed (read-only) everywhere else. + */ +export interface AppConfig { + /** Subreddit name from the install context (INV-9) β€” never a user setting. */ + subredditName: string; + /** Wiki page slug to publish to. */ + wikiPage: string; + /** Tracked action types (INV-7), validated against VALID_MODLOG_ACTIONS. */ + wikiActions: ModActionType[]; + /** Ignored moderators (INV-8 default unioned in), lowercased + deduped. */ + ignoredModerators: string[]; + /** Retention window in days, clamped to [RETENTION_DAYS_MIN, MAX]. */ + retentionDays: number; + /** Max entries rendered, clamped to [MAX_WIKI_ENTRIES_MIN, MAX]. */ + maxWikiEntries: number; + /** Mod-log fetch limit per run, clamped to [FETCH_LIMIT_MIN, MAX]. */ + fetchLimit: number; + /** Always true (INV-1); surfaced read-only, never derived from a setting. */ + anonymizeModerators: boolean; +} diff --git a/devvit/src/wiki.ts b/devvit/src/wiki.ts new file mode 100644 index 0000000..071077e --- /dev/null +++ b/devvit/src/wiki.ts @@ -0,0 +1,211 @@ +/** + * wiki.ts β€” Wiki publish orchestration. + * + * Responsibility (per architecture Β§1.5): + * The create-or-update + hash-skip publish flow that writes the rendered + * moderation-log markdown to a subreddit wiki page. + * + * Invariants owned here: + * - INV-3 512 KB wiki cap β€” content arriving here is ALREADY capped by + * render.enforceByteCap; we defensively re-check the byte length and + * refuse to write over-cap content (fail closed rather than let + * Reddit reject a >524288-byte body). + * - INV-6 Wiki hash-skip β€” never write the wiki when the SHA-256 of the + * rendered content equals the last written hash. Bypassable via + * `opts.bypassHash` for the menu "force wiki" action. + * + * This is the single publish code path shared by the scheduler cron, the + * trigger-coalesced refresh, and the menu force-write handlers. + * + * Dependency direction (strictly downward): wiki -> { storage, render } and + * the Reddit client. No cycles; render is pure, storage is the only Redis + * module. + * + * NOTE ON IMPORTS: per the build instruction this module imports from + * '@devvit/public-api' (the classic single-package entrypoint). The + * architecture spec references the newer split packages + * ('@devvit/reddit' / '@devvit/redis'); if this project is built on Devvit Web + * the imports below should be swapped accordingly β€” see the TODOs. + */ + +// TODO(devvit-imports): On Devvit Web this becomes +// import type { RedditAPIClient } from '@devvit/reddit'; +// import type { RedisClient } from '@devvit/redis'; +// Using '@devvit/public-api' here per the build instruction for this file. +import type { RedditAPIClient, RedisClient } from '@devvit/public-api'; + +import type { AppConfig } from './types.js'; +import { WIKI_BYTE_CAP } from './types.js'; +import { getAllRecords, getWikiHash, setWikiHash } from './storage.js'; +import { buildContent, contentHash } from './render.js'; + +/** Reason string attached to wiki revisions so the audit trail is legible. */ +const WIKI_REVISION_REASON = 'RedditModLog: moderation log update'; + +/** Outcome of a publish attempt β€” surfaced to logs and menu toasts. */ +export interface PublishResult { + /** True if a wiki write (create or update) actually happened. */ + wrote: boolean; + /** + * Why we did / did not write: + * 'created' β€” page did not exist, was created + * 'updated' β€” page existed and content changed + * 'unchanged' β€” hash matched (INV-6) OR existing content already equal + * 'over-cap' β€” content exceeded the 512 KB cap; refused to write + */ + reason: 'created' | 'updated' | 'unchanged' | 'over-cap'; +} + +/** + * UTF-8 byte length of a string. The wiki cap (INV-3) is defined in BYTES, not + * code units, so we must measure the encoded length β€” multi-byte characters + * (emoji, non-Latin reasons) count for more than one byte. + */ +function utf8ByteLength(value: string): number { + // TextEncoder is available in the Devvit serverless runtime (Web Crypto / + // standard globals). Avoids pulling in Node's Buffer. + return new TextEncoder().encode(value).length; +} + +/** + * Publish pre-rendered markdown to the wiki page with hash-skip + cap guard. + * + * Flow (architecture Β§1.5): + * 1. Defensive INV-3 byte-cap check β€” refuse over-cap content. + * 2. Compute SHA-256 of content (INV-6). + * 3. Unless bypassHash: compare to stored hash; skip write if equal. + * 4. Read the page; create if absent, update if present-and-different, + * skip if present-and-equal (defensive double-check of INV-6). + * 5. Persist the new hash so the next run can short-circuit. + * + * Idempotent: calling twice with identical content writes at most once. + * + * @param reddit Reddit API client (provides get/create/updateWikiPage). + * @param redis Redis handle (hash cache only β€” via storage layer). + * @param cfg Resolved app config (subreddit + wiki page name). + * @param content Fully rendered, already-capped markdown (from render.buildContent). + * @param opts.bypassHash Skip the INV-6 hash short-circuit (menu force-write). + */ +export async function publish( + reddit: RedditAPIClient, + redis: RedisClient, + cfg: AppConfig, + content: string, + opts?: { bypassHash?: boolean }, +): Promise { + const bypassHash = opts?.bypassHash ?? false; + + // --- Step 1: INV-3 defensive byte-cap guard -------------------------------- + // render.enforceByteCap should already keep us under the cap; if something + // upstream produced over-cap content we fail closed rather than send a body + // Reddit will reject. This is a guard, not the primary trim mechanism. + const byteLength = utf8ByteLength(content); + if (byteLength > WIKI_BYTE_CAP) { + console.error( + `[wiki] refusing to publish: content is ${byteLength} bytes, exceeds cap ${WIKI_BYTE_CAP} ` + + `(page="${cfg.wikiPage}", sub="${cfg.subredditName}"). render.enforceByteCap should have trimmed this.`, + ); + return { wrote: false, reason: 'over-cap' }; + } + + // --- Step 2: hash of the content we intend to write (INV-6) ---------------- + const hash = await contentHash(content); + + // --- Step 3: hash-skip short-circuit (INV-6) ------------------------------- + if (!bypassHash) { + const prevHash = await getWikiHash(redis, cfg.wikiPage); + if (prevHash !== undefined && prevHash === hash) { + console.info(`[wiki] skip write: content hash unchanged (page="${cfg.wikiPage}").`); + return { wrote: false, reason: 'unchanged' }; + } + } + + // --- Step 4: read-or-create, then update if changed ------------------------ + // getWikiPage throws when the page does not exist (research delta #6), so the + // "does it exist?" probe is a try/catch around the read, not a return code. + let existingContent: string | undefined; + try { + const page = await reddit.getWikiPage(cfg.subredditName, cfg.wikiPage); + existingContent = page.content; + } catch (err) { + // Treat any read failure as "page absent" and attempt creation below. + // If creation also fails for a non-absence reason, that error propagates. + existingContent = undefined; + console.info( + `[wiki] getWikiPage("${cfg.subredditName}", "${cfg.wikiPage}") failed/absent; will create. ` + + `(${err instanceof Error ? err.message : String(err)})`, + ); + } + + if (existingContent === undefined) { + // Page does not exist β€” create it. + // TODO(devvit-api): confirm createWikiPage options shape + // { subredditName, page, content, reason } β€” verified against + // CreateWikiPageOptions in devvit-docs (RedditAPIClient.createWikiPage). + await reddit.createWikiPage({ + subredditName: cfg.subredditName, + page: cfg.wikiPage, + content, + reason: WIKI_REVISION_REASON, + }); + await setWikiHash(redis, cfg.wikiPage, hash); + console.info(`[wiki] created page "${cfg.wikiPage}" (${byteLength} bytes).`); + return { wrote: true, reason: 'created' }; + } + + if (existingContent === content) { + // Defensive INV-6 double-check: page already byte-for-byte equal. This can + // happen if the stored hash was lost/reset but the wiki itself is current. + // Re-persist the hash so the next run short-circuits via step 3. + await setWikiHash(redis, cfg.wikiPage, hash); + console.info(`[wiki] skip write: existing page content already equal (page="${cfg.wikiPage}").`); + return { wrote: false, reason: 'unchanged' }; + } + + // Page exists and differs β€” update it. + // TODO(devvit-api): confirm updateWikiPage options shape + // { subredditName, page, content, reason } β€” verified against + // UpdateWikiPageOptions in devvit-docs (RedditAPIClient.updateWikiPage). + await reddit.updateWikiPage({ + subredditName: cfg.subredditName, + page: cfg.wikiPage, + content, + reason: WIKI_REVISION_REASON, + }); + await setWikiHash(redis, cfg.wikiPage, hash); + console.info(`[wiki] updated page "${cfg.wikiPage}" (${byteLength} bytes).`); + return { wrote: true, reason: 'updated' }; +} + +/** + * Convenience publish path: read all stored records, render them, and publish. + * + * This is the single entrypoint invoked by: + * - the scheduler cron job, + * - the trigger-coalesced refresh (dirty-flag consumer), + * - the menu "run now" / "force rebuild" / "force wiki" handlers. + * + * Keeping the render+publish composition here means callers never assemble + * content themselves, so INV-3/INV-4/INV-6 are enforced uniformly. + * + * @param reddit Reddit API client. + * @param redis Redis handle. + * @param cfg Resolved app config. + * @param opts.bypassHash Forwarded to publish() (menu force-write). + */ +export async function publishFromStore( + reddit: RedditAPIClient, + redis: RedisClient, + cfg: AppConfig, + opts?: { bypassHash?: boolean }, +): Promise { + // Newest-first records, already capped to maxWikiEntries by the storage layer. + const records = await getAllRecords(redis, cfg.wikiPage); + + // render.buildContent is pure and owns the table layout, censor/escape + // (INV-4), link gating (INV-2), and the byte-cap trim (INV-3). + const nowIso = new Date().toISOString(); + const content = buildContent(records, cfg, nowIso); + + return publish(reddit, redis, cfg, content, opts); +} diff --git a/devvit/tsconfig.json b/devvit/tsconfig.json new file mode 100644 index 0000000..74f25f7 --- /dev/null +++ b/devvit/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@devvit/public-api/devvit.tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "noEmit": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} From d6504ce93c30936efa6695b64879593bd97ac494 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:06:52 -0500 Subject: [PATCH 2/3] test(devvit): add offline mock-Reddit harness + fix 2 bugs it caught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A self-contained vitest harness (in-memory Redis + mock Reddit client + fixtures) runs the real compiled pipeline offline β€” no Devvit auth needed β€” covering ingest/filter, anonymize (INV-1), no-profile-links (INV-2), PII strip (INV-4), retention, and hash-skip (INV-6). It surfaced two real bugs, now fixed: - modlog.ts: ignored-moderator match was case-sensitive, so a lowercased ignore list (or mixed-case mod name) silently leaked ignored mods (e.g. AutoModerator) into the public wiki. Now case-insensitive (INV-8). - render.ts: contentHash hashed the volatile 'Last Updated' timestamp, so the INV-6 skip never triggered and the wiki would be rewritten every cron run. Hash now excludes that line (mirrors the Python prod hotfix). --- devvit/src/modlog.ts | 9 +-- devvit/src/render.ts | 7 ++- devvit/test/fixtures.ts | 63 +++++++++++++++++++++ devvit/test/mocks.ts | 104 +++++++++++++++++++++++++++++++++++ devvit/test/pipeline.test.ts | 88 +++++++++++++++++++++++++++++ 5 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 devvit/test/fixtures.ts create mode 100644 devvit/test/mocks.ts create mode 100644 devvit/test/pipeline.test.ts diff --git a/devvit/src/modlog.ts b/devvit/src/modlog.ts index 27f1486..41ee8b4 100644 --- a/devvit/src/modlog.ts +++ b/devvit/src/modlog.ts @@ -141,10 +141,11 @@ export function extractRecord(action: ModAction, cfg: AppConfig): ModRecord | nu } // INV-8 β€” drop actions performed by ignored moderators (e.g. AutoModerator). - // Compare on the RAW name before anonymization so the ignore list stays - // meaningful. - const rawModerator = (action.moderatorName ?? '').trim(); - if (rawModerator.length > 0 && cfg.ignoredModerators.includes(rawModerator)) { + // Case-insensitive: Reddit usernames match case-insensitively, and the ignore + // list (default + per-install) may carry mixed case. Comparing raw case would + // silently leak ignored mods into the public wiki. + const rawModerator = (action.moderatorName ?? '').trim().toLowerCase(); + if (rawModerator.length > 0 && cfg.ignoredModerators.some((m) => m.toLowerCase() === rawModerator)) { return null; } diff --git a/devvit/src/render.ts b/devvit/src/render.ts index 36a3342..c7e90ce 100644 --- a/devvit/src/render.ts +++ b/devvit/src/render.ts @@ -414,7 +414,12 @@ export function buildContent( * Ported from `get_content_hash` (Python used hashlib.sha256 hexdigest). */ export async function contentHash(markdown: string): Promise { - const data = new TextEncoder().encode(markdown); + // Exclude the volatile "**Last Updated:** " header line so an + // unchanged modlog hashes stably (INV-6). Hashing the timestamped content + // would differ every run, defeating the skip and rewriting the wiki each + // cycle. Mirrors the Python get_content_hash fix. + const hashable = markdown.replace(/^\*\*Last Updated:\*\* .*\r?\n/, ''); + const data = new TextEncoder().encode(hashable); // crypto.subtle is available globally in the Devvit serverless runtime. const digest = await crypto.subtle.digest('SHA-256', data); return [...new Uint8Array(digest)] diff --git a/devvit/test/fixtures.ts b/devvit/test/fixtures.ts new file mode 100644 index 0000000..4b9eef9 --- /dev/null +++ b/devvit/test/fixtures.ts @@ -0,0 +1,63 @@ +/** + * Synthetic ModAction fixtures + a test AppConfig. Shapes match the fields + * modlog.extractRecord reads: id, type, moderatorName, createdAt (Date), + * target?: { id, permalink, author }, description/details (reason source). + */ +import type { AppConfig } from '../src/types.js'; + +const NOW = 1_700_000_000; // fixed epoch seconds (no Date.now β€” deterministic) + +function action(o: { + id: string; + type: string; + mod: string; + ageSec?: number; + target?: { id: string; permalink?: string; author?: string }; + description?: string; + details?: string; +}) { + return { + id: o.id, + type: o.type, + moderatorName: o.mod, + createdAt: new Date((NOW - (o.ageSec ?? 0)) * 1000), + target: o.target, + description: o.description, + details: o.details, + }; +} + +export const FIXTURE_NOW_SEC = NOW; + +export const ACTIONS = [ + // tracked removal w/ reason + post target + author + action({ id: 'a1', type: 'removelink', mod: 'HumanMod1', ageSec: 10, description: 'Rule 1: off-topic', + target: { id: 't3_aaa', permalink: '/r/test/comments/aaa/title/', author: 'alice' } }), + // tracked comment removal whose reason has a PIPE char (must be escaped) + email (must be censored) + action({ id: 'a2', type: 'removecomment', mod: 'HumanMod2', ageSec: 20, details: 'spam | contact me at evil@example.com', + target: { id: 't1_bbb', permalink: '/r/test/comments/aaa/title/bbb/', author: 'bob' } }), + // addremovalreason with an email in the reason (PII strip) + action({ id: 'a3', type: 'addremovalreason', mod: 'HumanMod1', ageSec: 30, description: 'Appeal: mail admin@sub.example.org', + target: { id: 't3_aaa', permalink: '/r/test/comments/aaa/title/', author: 'alice' } }), + // approval + action({ id: 'a4', type: 'approvelink', mod: 'HumanMod2', ageSec: 40, + target: { id: 't3_ddd', permalink: '/r/test/comments/ddd/title/', author: 'carol' } }), + // IGNORED moderator (AutoModerator is in default ignored list) -> filtered out + action({ id: 'a5', type: 'spamlink', mod: 'AutoModerator', ageSec: 50, target: { id: 't3_eee' } }), + // UNTRACKED action type (banuser not in wiki_actions) -> filtered out + action({ id: 'a6', type: 'banuser', mod: 'HumanMod1', ageSec: 60 }), + // OLD action (beyond retention) for cleanup test + action({ id: 'a7', type: 'removelink', mod: 'HumanMod1', ageSec: 200 * 86_400, + target: { id: 't3_fff', permalink: '/r/test/comments/fff/title/', author: 'dave' } }), +]; + +export const CFG: AppConfig = { + subredditName: 'test', + wikiPage: 'modlog', + wikiActions: ['removelink', 'removecomment', 'spamlink', 'spamcomment', 'addremovalreason', 'approvelink', 'approvecomment'], + ignoredModerators: ['automoderator'], + retentionDays: 90, + maxWikiEntries: 1000, + fetchLimit: 500, + anonymizeModerators: true, +}; diff --git a/devvit/test/mocks.ts b/devvit/test/mocks.ts new file mode 100644 index 0000000..27a0f53 --- /dev/null +++ b/devvit/test/mocks.ts @@ -0,0 +1,104 @@ +/** + * In-memory fakes for the Devvit primitives our pipeline touches, so the real + * compiled modules (dist/*.js β€” type-only @devvit imports are erased at build) + * can run fully offline. This is the "self-test reddit": no auth, no platform. + */ + +type ZMember = { member: string; score: number }; + +/** Implements the exact RedisClient subset storage.ts uses. */ +export class InMemoryRedis { + private hashes = new Map>(); + private zsets = new Map>(); + + private h(key: string): Map { + let m = this.hashes.get(key); + if (!m) { m = new Map(); this.hashes.set(key, m); } + return m; + } + private z(key: string): Map { + let m = this.zsets.get(key); + if (!m) { m = new Map(); this.zsets.set(key, m); } + return m; + } + + async hSet(key: string, obj: Record): Promise { + const m = this.h(key); + for (const [k, v] of Object.entries(obj)) m.set(k, v); + return Object.keys(obj).length; + } + async hSetNX(key: string, field: string, value: string): Promise { + const m = this.h(key); + if (m.has(field)) return 0; + m.set(field, value); + return 1; + } + async hGet(key: string, field: string): Promise { + return this.hashes.get(key)?.get(field) ?? undefined; + } + async hGetAll(key: string): Promise> { + const m = this.hashes.get(key); + return m ? Object.fromEntries(m) : {}; + } + async hDel(key: string, fields: string[]): Promise { + const m = this.h(key); + let n = 0; + for (const f of fields) if (m.delete(f)) n++; + return n; + } + async zAdd(key: string, ...members: ZMember[]): Promise { + const m = this.z(key); + for (const { member, score } of members) m.set(member, score); + return members.length; + } + async zRange( + key: string, + min: number, + max: number, + opts: { by: string; reverse?: boolean; limit?: { offset: number; count: number } }, + ): Promise { + const m = this.zsets.get(key); + if (!m) return []; + let arr: ZMember[] = [...m.entries()] + .map(([member, score]) => ({ member, score })) + .filter((e) => e.score >= min && e.score <= max); + arr.sort((a, b) => (opts.reverse ? b.score - a.score : a.score - b.score)); + if (opts.limit) arr = arr.slice(opts.limit.offset, opts.limit.offset + opts.limit.count); + return arr; + } + async zRemRangeByScore(key: string, min: number, max: number): Promise { + const m = this.zsets.get(key); + if (!m) return 0; + let n = 0; + for (const [member, score] of [...m]) if (score >= min && score <= max) { m.delete(member); n++; } + return n; + } +} + +/** Mock RedditAPIClient covering getModerationLog + the wiki read/create/update path. */ +export function makeMockReddit(actions: any[]) { + const wiki = new Map(); // `${sub}/${page}` -> content + const writes: Array<{ op: string; page: string; content: string }> = []; + const reddit = { + getModerationLog(o: { subredditName: string; limit?: number; pageSize?: number }) { + const slice = actions.slice(0, o.limit ?? actions.length); + return { all: async () => slice }; + }, + async getWikiPage(subredditName: string, page: string) { + const key = `${subredditName}/${page}`; + if (!wiki.has(key)) throw new Error(`WIKI_PAGE_NOT_FOUND: ${key}`); + return { content: wiki.get(key)! }; + }, + async createWikiPage(o: { subredditName: string; page: string; content: string }) { + wiki.set(`${o.subredditName}/${o.page}`, o.content); + writes.push({ op: 'create', page: o.page, content: o.content }); + return { content: o.content }; + }, + async updateWikiPage(o: { subredditName: string; page: string; content: string }) { + wiki.set(`${o.subredditName}/${o.page}`, o.content); + writes.push({ op: 'update', page: o.page, content: o.content }); + return { content: o.content }; + }, + }; + return { reddit: reddit as any, wiki, writes }; +} diff --git a/devvit/test/pipeline.test.ts b/devvit/test/pipeline.test.ts new file mode 100644 index 0000000..d3756cf --- /dev/null +++ b/devvit/test/pipeline.test.ts @@ -0,0 +1,88 @@ +/** + * Offline "self-test reddit" β€” runs the REAL compiled pipeline (dist/*.js, with + * type-only @devvit imports erased) against in-memory Redis + a mock Reddit + * client. Validates logic + the security invariants (anonymize / PII strip / + * no-profile-links / dedup / retention / size-cap / hash-skip) with no auth. + * + * Requires a build first (npm run build); the `test` script chains tsc. + */ +import { describe, it, expect } from 'vitest'; +import { InMemoryRedis, makeMockReddit } from './mocks'; +import { ACTIONS, CFG, FIXTURE_NOW_SEC } from './fixtures'; + +import { ingest } from '../dist/modlog.js'; +import { getRecentActions, cleanupOld, getAllRecords } from '../dist/storage.js'; +import { buildContent, censorEmail, escapePipes } from '../dist/render.js'; +import { publishFromStore } from '../dist/wiki.js'; + +describe('ingest + filtering', () => { + it('persists only tracked, non-ignored actions and dedupes', async () => { + const redis = new InMemoryRedis(); + const { reddit } = makeMockReddit(ACTIONS); + const r1 = await ingest(reddit, redis, CFG); + // a1,a2,a3,a4,a7 tracked+kept = 5 ; a5 (AutoModerator ignored), a6 (banuser untracked) dropped + expect(r1.added).toBe(5); + // second pass: everything already seen -> 0 new (INV-5 idempotency, no daemon) + const r2 = await ingest(reddit, redis, CFG); + expect(r2.added).toBe(0); + }); +}); + +describe('anonymization (INV-1) + no profile links (INV-2)', () => { + it('never emits a real moderator name and never links a user profile', async () => { + const redis = new InMemoryRedis(); + const { reddit } = makeMockReddit(ACTIONS); + await ingest(reddit, redis, CFG); + const recs = await getAllRecords(redis, CFG.wikiPage); + for (const rec of recs) { + expect(rec.moderator).not.toMatch(/HumanMod\d/); // real names (HumanMod1/2) never stored; anon label "HumanModerator" is fine + } + const md = buildContent(recs, CFG); + expect(md).not.toMatch(/HumanMod1|HumanMod2/); + expect(md).not.toMatch(/\/u\/|\/user\//); // no user-profile links anywhere + }); +}); + +describe('PII strip (INV-4): email censor + pipe escape', () => { + it('censorEmail replaces addresses', () => { + expect(censorEmail('reach evil@example.com now')).not.toContain('evil@example.com'); + }); + it('escapePipes neutralizes table-breaking pipes', () => { + expect(escapePipes('a | b')).not.toContain('|'); + }); + it('rendered wiki content contains no raw emails', async () => { + const redis = new InMemoryRedis(); + const { reddit } = makeMockReddit(ACTIONS); + await ingest(reddit, redis, CFG); + const md = buildContent(await getAllRecords(redis, CFG.wikiPage), CFG); + expect(md).not.toMatch(/[\w.+-]+@[\w-]+\.[\w.-]+/); // no email survives + }); +}); + +describe('retention cleanup', () => { + it('removes actions older than retentionDays', async () => { + const redis = new InMemoryRedis(); + const { reddit } = makeMockReddit(ACTIONS); + await ingest(reddit, redis, CFG); + const before = (await getAllRecords(redis, CFG.wikiPage)).length; + const removed = await cleanupOld(redis, CFG.wikiPage, CFG.retentionDays, FIXTURE_NOW_SEC); + expect(removed).toBeGreaterThanOrEqual(1); // a7 (200 days old) pruned + const after = (await getAllRecords(redis, CFG.wikiPage)).length; + expect(after).toBe(before - removed); + }); +}); + +describe('publish: create then hash-skip (INV-6)', () => { + it('writes once, then skips when content is unchanged', async () => { + const redis = new InMemoryRedis(); + const { reddit, writes } = makeMockReddit(ACTIONS); + await ingest(reddit, redis, CFG); + const p1 = await publishFromStore(reddit, redis, CFG); + expect(p1.wrote).toBe(true); + expect(writes.length).toBe(1); + const p2 = await publishFromStore(reddit, redis, CFG); + expect(p2.wrote).toBe(false); + expect(p2.reason).toBe('unchanged'); + expect(writes.length).toBe(1); // no second write + }); +}); From cbd8eb19b6fc1ffcd5ce04ac00a4ad025a116c04 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:15:29 -0500 Subject: [PATCH 3/3] ci: strip trailing whitespace + configure CodeRabbit for the Devvit app - fix pre-commit trailing-whitespace failure in a generated research doc - .coderabbit.yaml: add review instructions for devvit/src + devvit/test (enforce the migration invariants, validate Devvit API usage) and exclude generated/vendored paths (devvit/dist, node_modules, package-lock) --- .coderabbit.yaml | 22 +++++++++++++++++++ .../docs/02-research-api-shapes.md | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 18c89a2..6198a4b 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -6,11 +6,33 @@ reviews: poem: false review_status: true collapse_walkthrough: false + path_filters: + - "!devvit/dist/**" + - "!devvit/node_modules/**" + - "!**/package-lock.json" path_instructions: - path: "*.py" instructions: | Review for security issues β€” validate all user inputs. Check for proper error handling and logging. + - path: "devvit/src/**/*.ts" + instructions: | + Devvit (Reddit Developer Platform) app. Enforce the migration invariants: + moderator names must always be anonymized (INV-1, never a real name); + only post/comment permalinks may be linked, never user profiles (INV-2); + removal-reason text must be email-censored and pipe-escaped (INV-4); + wiki content must stay under the 512KB cap (INV-3); the content hash used + for skip-if-unchanged must exclude volatile data like the Last Updated + timestamp (INV-6); ingest must be idempotent/deduped since there is no + daemon (INV-5). Verify Devvit API usage (reddit.getModerationLog, + get/updateWikiPage, context.redis, scheduler) and flag any unvalidated + // TODO(devvit-api) call. Prefer type-safe, injected clients over globals. + - path: "devvit/test/**/*.ts" + instructions: | + Offline mock-Reddit test harness. Check that the security invariants + (anonymize, no profile links, PII strip, dedup, retention, hash-skip) + are actually asserted, and that mocks faithfully match the real Devvit + API shapes they stand in for. - path: ".github/workflows/**" instructions: | Check for command injection via untrusted GitHub context variables. diff --git a/devvit-migration/docs/02-research-api-shapes.md b/devvit-migration/docs/02-research-api-shapes.md index 37b694e..43e159f 100644 --- a/devvit-migration/docs/02-research-api-shapes.md +++ b/devvit-migration/docs/02-research-api-shapes.md @@ -55,7 +55,7 @@ export interface ModAction { id: string; // "ModAction_1b1af634-..." β€” the modlog entry id type: ModActionType; moderatorName: string; - moderatorId: T2; // "t2_..." + moderatorId: T2; // "t2_..." createdAt: Date; // native Date (Python had to parse epoch) subredditName: string; subredditId: T5; // "t5_..."