diff --git a/public/registry/index.json b/public/registry/index.json
index c5d8162..0c99952 100644
--- a/public/registry/index.json
+++ b/public/registry/index.json
@@ -1,24 +1,24 @@
{
"version": 1,
- "updatedAt": "2026-08-14T16:53:54.431Z",
+ "updatedAt": "2026-08-14T18:32:52.260Z",
"plugins": {
"count": 27,
- "updatedAt": "2026-08-14T16:53:54.431Z"
+ "updatedAt": "2026-08-14T18:32:52.260Z"
},
"themes": {
"count": 54,
- "updatedAt": "2026-08-14T16:53:54.431Z"
+ "updatedAt": "2026-08-14T18:32:52.260Z"
},
"widgets": {
- "count": 45,
- "updatedAt": "2026-08-14T16:53:54.431Z"
+ "count": 47,
+ "updatedAt": "2026-08-14T18:32:52.260Z"
},
"skills": {
"count": 32,
- "updatedAt": "2026-08-14T16:53:54.431Z"
+ "updatedAt": "2026-08-14T18:32:52.260Z"
},
"queries": {
"count": 24,
- "updatedAt": "2026-08-14T16:53:54.431Z"
+ "updatedAt": "2026-08-14T18:32:52.260Z"
}
}
diff --git a/public/registry/plugins.json b/public/registry/plugins.json
index 1463dfe..e7ab01c 100644
--- a/public/registry/plugins.json
+++ b/public/registry/plugins.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updatedAt": "2026-08-14T16:53:54.431Z",
+ "updatedAt": "2026-08-14T18:32:52.260Z",
"plugins": [
{
"id": "ai-actions",
@@ -22,7 +22,7 @@
"apiUrl": "https://store.notedeck.io/registry/plugins/ai-actions/api.json",
"sha512": "5c9a60976f798c3adbee963451daab12bbdfff538c877206b525c8590d6694129d6eb43991730b71d9f4221e2545822d2d6dcb5983d514af0bde3159bc96082f",
"createdAt": "2026-08-13T03:19:12+09:00",
- "updatedAt": "2026-08-15T01:45:34+09:00",
+ "updatedAt": "2026-08-15T01:54:09+09:00",
"authorUrl": "https://github.com/notedeck-dev",
"license": "MIT",
"permissions": [
diff --git a/public/registry/queries.json b/public/registry/queries.json
index d46a5b4..4c07b6d 100644
--- a/public/registry/queries.json
+++ b/public/registry/queries.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updatedAt": "2026-08-14T16:53:54.431Z",
+ "updatedAt": "2026-08-14T18:32:52.260Z",
"queries": [
{
"id": "no-cw",
diff --git a/public/registry/skills.json b/public/registry/skills.json
index 7971955..61206a5 100644
--- a/public/registry/skills.json
+++ b/public/registry/skills.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updatedAt": "2026-08-14T16:53:54.431Z",
+ "updatedAt": "2026-08-14T18:32:52.260Z",
"skills": [
{
"id": "ai-cost-pulse",
diff --git a/public/registry/themes.json b/public/registry/themes.json
index efb9fb6..eb908ac 100644
--- a/public/registry/themes.json
+++ b/public/registry/themes.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updatedAt": "2026-08-14T16:53:54.431Z",
+ "updatedAt": "2026-08-14T18:32:52.260Z",
"themes": [
{
"id": "adwaita-dark",
diff --git a/public/registry/widgets.json b/public/registry/widgets.json
index a2a94cf..75fc47e 100644
--- a/public/registry/widgets.json
+++ b/public/registry/widgets.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updatedAt": "2026-08-14T16:53:54.431Z",
+ "updatedAt": "2026-08-14T18:32:52.260Z",
"widgets": [
{
"id": "atcoder",
@@ -340,6 +340,35 @@
"license": "MIT",
"iconUrl": "https://store.notedeck.io/registry/widgets/misskey-online/icon.svg"
},
+ {
+ "id": "nodeinfo",
+ "name": "nodeinfo 調査",
+ "version": "1.0.0",
+ "author": "@hitalin",
+ "description": "任意の Fediverse サーバーの nodeinfo を引いて、ソフトウェア・バージョン・規模・登録受付状態を表示。Misskey / Mastodon / Pleroma など ActivityPub 実装に共通で通り、認証不要",
+ "icon": "ti-scan",
+ "autoRun": true,
+ "category": "stats",
+ "capabilities": [
+ "notedeck-api"
+ ],
+ "tags": [
+ "fediverse",
+ "nodeinfo",
+ "server",
+ "stats",
+ "misskey",
+ "mastodon"
+ ],
+ "sourceUrl": "https://store.notedeck.io/registry/widgets/nodeinfo/widget.is",
+ "apiUrl": "https://store.notedeck.io/registry/widgets/nodeinfo/api.json",
+ "sha512": "107ca7daa16937e18fab8abf7838ad83885fbb19046b2b0150260fb68799e1230ea93d89b41e64fa0dab815cd1eaca0e92099765df9cedbfd35830a6470738f3",
+ "createdAt": "2026-08-15T03:32:33+09:00",
+ "updatedAt": "2026-08-15T03:32:33+09:00",
+ "authorUrl": "https://github.com/hitalin",
+ "license": "MIT",
+ "iconUrl": "https://store.notedeck.io/registry/widgets/nodeinfo/icon.svg"
+ },
{
"id": "okj-prices",
"name": "OKJ 円建て価格ボード",
@@ -369,6 +398,35 @@
"license": "MIT",
"iconUrl": "https://store.notedeck.io/registry/widgets/okj-prices/icon.svg"
},
+ {
+ "id": "osv-vulns",
+ "name": "OSV 脆弱性",
+ "version": "1.0.0",
+ "author": "@hitalin",
+ "description": "OSV.dev にパッケージ名を投げて、そのパッケージの既知脆弱性を重大度・修正バージョン付きで新しい順に表示。既定は npm:misskey-js。npm / PyPI / crates.io など 8 エコシステム対応、API キー不要",
+ "icon": "ti-package-search",
+ "autoRun": true,
+ "category": "display",
+ "capabilities": [
+ "notedeck-api"
+ ],
+ "tags": [
+ "security",
+ "vulnerability",
+ "osv",
+ "cve",
+ "supply-chain",
+ "misskey"
+ ],
+ "sourceUrl": "https://store.notedeck.io/registry/widgets/osv-vulns/widget.is",
+ "apiUrl": "https://store.notedeck.io/registry/widgets/osv-vulns/api.json",
+ "sha512": "3144f60a44effc996ad98616e5af4360cb18fc9acf2288ff6c81ad7ea54d3fdbbb9da1c0bffb1bd67d64c4452697d4996a1a30d2852364afb80f7e7d4c00a39b",
+ "createdAt": "2026-08-14T18:32:54.089Z",
+ "updatedAt": "2026-08-14T18:32:54.089Z",
+ "authorUrl": "https://github.com/hitalin",
+ "license": "MIT",
+ "iconUrl": "https://store.notedeck.io/registry/widgets/osv-vulns/icon.svg"
+ },
{
"id": "rss-reader",
"name": "RSSリーダー",
diff --git a/public/registry/widgets/nodeinfo/api.json b/public/registry/widgets/nodeinfo/api.json
new file mode 100644
index 0000000..ff52344
--- /dev/null
+++ b/public/registry/widgets/nodeinfo/api.json
@@ -0,0 +1 @@
+{"type":"widget","data":"/// @ 1.2.1\n/// nodeinfo ウィジェット\n/// 任意の Fediverse サーバーの nodeinfo を引いて、ソフトウェア・規模・登録受付状態を表示。\n/// 認証不要・レスポンス数 KB で、Misskey に限らず Mastodon / Pleroma / Akkoma などにも通る。\n///\n/// 実装メモ:\n/// - nodeinfo は 2 段構え。/.well-known/nodeinfo で本体 URL を discovery してから取得する。\n/// 本体パスは実装ごとに違う (Pleroma は /nodeinfo/2.1.json) ので、discovery は省略できない\n/// - 2.1 を優先し、無ければ 2.0。Mastodon は 2.0 のみを出す\n/// - discovery が返す href は**相手サーバーが自由に決められる値**なので、\n/// 「調査対象ホスト自身の https URL」以外は捨てる (別ドメインへ取りに行かせないため)\n/// - nodeName も相手の自由入力。MFM / リンク / HTML 記法は safeLabel で全角に潰す。\n/// nodeDescription は HTML 混じりで無害化のコストが高いので最初から表示しない\n/// - 内容はほぼ静的なので自動更新は 6 時間おき\n\nlet WELL_KNOWN_REL_20 = \"http://nodeinfo.diaspora.software/ns/schema/2.0\"\nlet WELL_KNOWN_REL_21 = \"http://nodeinfo.diaspora.software/ns/schema/2.1\"\nlet HOST_KEY = \"nodeinfo:host\"\nlet DEFAULT_HOST = \"misskey.io\"\nlet HOST_CHARS = \"abcdefghijklmnopqrstuvwxyz0123456789.-\"\n// nodeinfo は大きくても数 KB。相手が返す量を無制限にパースしない\nlet MAX_BODY = 200000\n\n// カラーパレット (ink-new-servers と同系統)\nlet COL_OPEN = \"86b300\" // 登録開放\nlet COL_DOWN = \"f74e52\" // エラー\nlet COL_SUB = \"6b7480\" // 補助・招待制\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// レスポンス本文を JSON オブジェクトとして読む。\n/// Json:parse は失敗時に throw せず error 値を返すので、obj 以外はすべて null に潰す\n@parseObj(body) {\n if (Core:type(body) != \"str\") { return null }\n if (body.len > MAX_BODY) { return null }\n let v = Json:parse(body)\n if (Core:type(v) == \"obj\") v else null\n}\n\n/// 数値を日本語の桁表記に (731141 -> 73.1万, 172951329 -> 1.7億)\n@fmtNum(n) {\n if (Core:type(n) != \"num\") { \"?\" }\n elif (n >= 100000000) { `{Math:round(n / 10000000) / 10}億` }\n elif (n >= 10000) { `{Math:round(n / 1000) / 10}万` }\n else { `{Math:round(n)}` }\n}\n\n/// 相手サーバー由来の文字列を表示用に無害化する。\n/// MFM ($[...])・リンク ([...])・装飾 (*)・HTML (<>) を全角に置換して発火させない\n@safeLabel(s) {\n if (Core:type(s) != \"str\") { \"?\" }\n else {\n s.replace(\"$\", \"$\").replace(\"[\", \"[\").replace(\"]\", \"]\").replace(\"*\", \"*\").replace(\"<\", \"<\").replace(\">\", \">\")\n }\n}\n\n@trunc(s, n) {\n if (s.len > n) `{s.slice(0, n)}…` else s\n}\n\n/// ホスト名として妥当か。ドットを含む小文字英数 + ハイフンのみ許す\n/// (ポート付き・IDN・localhost は弾く)\n@validHost(h) {\n if (!h.incl(\".\")) { return false }\n if (h.starts_with(\".\") || h.starts_with(\"-\")) { return false }\n if (h.ends_with(\".\") || h.ends_with(\"-\")) { return false }\n var ok = true\n each (let c, h.to_char_arr()) {\n if (!HOST_CHARS.incl(c)) { ok = false }\n }\n ok\n}\n\n/// 入力欄の値からホスト名を取り出す。URL 貼り付け・@user@host・大文字を許容。\n/// 妥当でなければ null\n@normalizeHost(s) {\n if (Core:type(s) != \"str\") { return null }\n var h = s.trim().lower()\n if (h.starts_with(\"https://\")) { h = h.slice(8, h.len) }\n elif (h.starts_with(\"http://\")) { h = h.slice(7, h.len) }\n // @user@host 形式は最後の @ より後ろを取る\n let parts = h.split(\"@\")\n h = parts[parts.len - 1]\n let slash = h.index_of(\"/\")\n if (slash >= 0) { h = h.slice(0, slash) }\n if (h == \"\") { return null }\n if (!validHost(h)) { return null }\n h\n}\n\n/// href が調査対象ホスト自身の https URL か\n@ownHttpsUrl(href, host) {\n if (Core:type(href) != \"str\") { return false }\n href.lower().starts_with(`https://{host}/`)\n}\n\n/// discovery の links から本体 URL を選ぶ (2.1 優先、自ホストの https のみ)\n@pickNodeinfoUrl(links, host) {\n if (Core:type(links) != \"arr\") { return null }\n var url20 = null\n var url21 = null\n each (let l, links) {\n let href = get(l, \"href\", null)\n if (ownHttpsUrl(href, host)) {\n let rel = get(l, \"rel\", \"\")\n if (rel == WELL_KNOWN_REL_21) { url21 = href }\n elif (rel == WELL_KNOWN_REL_20) { url20 = href }\n }\n }\n if (Core:type(url21) == \"str\") url21 else url20\n}\n\n/// ソフトウェア名ごとの色 (Misskey 系は緑、Mastodon は紫、Pleroma 系は橙)\n@softwareColor(name) {\n if (name == \"misskey\" || name == \"sharkey\" || name == \"cherrypick\") { COL_OPEN }\n elif (name == \"firefish\" || name == \"iceshrimp\") { COL_OPEN }\n elif (name == \"mastodon\" || name == \"hometown\") { \"6364ff\" }\n elif (name == \"pleroma\" || name == \"akkoma\") { \"fba457\" }\n else { COL_SUB }\n}\n\n@softwareName(ni) {\n let name = get(get(ni, \"software\", null), \"name\", null)\n if (Core:type(name) != \"str\") \"\" else name.lower()\n}\n\n/// \"misskey v2025.4.1\" 形式のバッジ本文\n@softwareLabel(ni) {\n let sw = get(ni, \"software\", null)\n let name = get(sw, \"name\", null)\n if (Core:type(name) != \"str\") { return \"不明\" }\n let ver = get(sw, \"version\", null)\n if (Core:type(ver) != \"str\") { safeLabel(trunc(name, 24)) }\n else { `{safeLabel(trunc(name, 24))} v{safeLabel(trunc(ver, 32))}` }\n}\n\n/// 登録受付状態 (色付き)。emailRequiredForSignup は Misskey 系のみ持つ\n@regLabel(ni) {\n if (get(ni, \"openRegistrations\", false) != true) {\n `$[fg.color={COL_SUB} 招待制]`\n } elif (get(get(ni, \"metadata\", null), \"emailRequiredForSignup\", false) == true) {\n `$[fg.color={COL_OPEN} 登録開放] $[fg.color={COL_SUB} ✉要メール]`\n } else {\n `$[fg.color={COL_OPEN} 登録開放]`\n }\n}\n\n/// 表示行 (MFM 文字列) を組み立てる。常に 5 行\n@infoRows(ni, host) {\n let name = trunc(safeLabel(dflt(get(get(ni, \"metadata\", null), \"nodeName\", null), host)), 28)\n let users = get(get(get(ni, \"usage\", null), \"users\", null), \"total\", null)\n let posts = get(get(ni, \"usage\", null), \"localPosts\", null)\n let active = get(get(get(ni, \"usage\", null), \"users\", null), \"activeMonth\", null)\n var stats = `👥 **{fmtNum(users)}** 📝 **{fmtNum(posts)}**`\n if (Core:type(active) == \"num\") {\n stats = `{stats} 🔥 **{fmtNum(active)}**`\n }\n let protocols = get(ni, \"protocols\", null)\n var footer = `nodeinfo {dflt(get(ni, \"version\", null), \"?\")}`\n if (Core:type(protocols) == \"arr\" && protocols.len > 0) {\n footer = `{footer} ・ {safeLabel(trunc(protocols.join(\", \"), 40))}`\n }\n [\n `
🌐 [**{name}**](https://{host})`\n `$[bg.color={softwareColor(softwareName(ni))} $[fg.color=ffffff {softwareLabel(ni)} ]]`\n `{stats}`\n `{regLabel(ni)}`\n `$[fg.color={COL_SUB} {footer}]`\n ]\n}\n\n// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----\n\n// 保存値も信用せず normalizeHost に通す (壊れた値が URL 組み立てに乗らないように)\nvar host = dflt(normalizeHost(Mk:load(HOST_KEY)), DEFAULT_HOST)\nvar input = host\nvar firstRun = true\nvar errorMsg = null\n\n/// nodeinfo を取得する。失敗時は null を返し errorMsg に理由を入れる\n@fetchNodeinfo(h) {\n errorMsg = null\n let disc = Nd:http(`https://{h}/.well-known/nodeinfo`, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (disc.status != 200) {\n errorMsg = `nodeinfo が見つかりません (HTTP {disc.status})`\n return null\n }\n let url = pickNodeinfoUrl(get(parseObj(disc.body), \"links\", null), h)\n if (Core:type(url) != \"str\") {\n errorMsg = \"対応する nodeinfo (2.0 / 2.1) のリンクがありません\"\n return null\n }\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (res.status != 200) {\n errorMsg = `nodeinfo を取得できません (HTTP {res.status})`\n return null\n }\n let ni = parseObj(res.body)\n if (Core:type(ni) != \"obj\") {\n errorMsg = \"nodeinfo の応答を読めませんでした\"\n }\n ni\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 調べるサーバー\"\n opened: false\n children: [\n Ui:C:textInput({\n default: input\n label: \"ホスト\"\n text: \"misskey.io / https://mastodon.social など\"\n onInput: @(v) { input = v }\n })\n Ui:C:button({\n text: \"🔍 調べる\"\n rounded: true\n primary: true\n onClick: @() {\n let h = normalizeHost(input)\n if (Core:type(h) != \"str\") {\n Mk:dialog(\"エラー\", \"ホスト名が不正です。misskey.io のような形式で入力してください\", \"error\")\n } else {\n // 保存は取得成功後 (render 内)。名前が引けないホストで Nd:http が\n // 落ちた場合に、その値が次回起動時にも読み込まれて詰むのを避ける\n host = h\n input = h\n firstRun = true\n render()\n }\n }\n })\n ]\n })\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: `{host} を調べています… 🌐` })]\n })\n ])\n }\n firstRun = false\n\n let ni = fetchNodeinfo(host)\n if (Core:type(ni) != \"obj\") {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_SUB} {safeLabel(host)}]` })\n Ui:C:mfm({ text: `$[fg.color={COL_DOWN} ⚠️ {dflt(errorMsg, \"取得に失敗しました\")}]` })\n Ui:C:button({ text: \"🔄 再試行\", rounded: true, onClick: @() { render() } })\n configFolder()\n ]\n })\n ])\n return null\n }\n\n Mk:save(HOST_KEY, host)\n\n var body = []\n each (let r, infoRows(ni, host)) {\n body.push(Ui:C:mfm({ text: r }))\n }\n body.push(Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { render() } }))\n body.push(configFolder())\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\n// nodeinfo の内容はほぼ静的なので 6 時間おきで十分\nAsync:interval(21600000, render, false)\n"}
diff --git a/public/registry/widgets/nodeinfo/icon.svg b/public/registry/widgets/nodeinfo/icon.svg
new file mode 100644
index 0000000..191a7bb
--- /dev/null
+++ b/public/registry/widgets/nodeinfo/icon.svg
@@ -0,0 +1,19 @@
+
diff --git a/public/registry/widgets/nodeinfo/meta.json b/public/registry/widgets/nodeinfo/meta.json
new file mode 100644
index 0000000..a3819a2
--- /dev/null
+++ b/public/registry/widgets/nodeinfo/meta.json
@@ -0,0 +1,14 @@
+{
+ "id": "nodeinfo",
+ "name": "nodeinfo 調査",
+ "version": "1.0.0",
+ "author": "@hitalin",
+ "authorUrl": "https://github.com/hitalin",
+ "description": "任意の Fediverse サーバーの nodeinfo を引いて、ソフトウェア・バージョン・規模・登録受付状態を表示。Misskey / Mastodon / Pleroma など ActivityPub 実装に共通で通り、認証不要",
+ "icon": "ti-scan",
+ "autoRun": true,
+ "category": "stats",
+ "capabilities": ["notedeck-api"],
+ "license": "MIT",
+ "tags": ["fediverse", "nodeinfo", "server", "stats", "misskey", "mastodon"]
+}
diff --git a/public/registry/widgets/nodeinfo/widget.is b/public/registry/widgets/nodeinfo/widget.is
new file mode 100644
index 0000000..1af4379
--- /dev/null
+++ b/public/registry/widgets/nodeinfo/widget.is
@@ -0,0 +1,298 @@
+/// @ 1.2.1
+/// nodeinfo ウィジェット
+/// 任意の Fediverse サーバーの nodeinfo を引いて、ソフトウェア・規模・登録受付状態を表示。
+/// 認証不要・レスポンス数 KB で、Misskey に限らず Mastodon / Pleroma / Akkoma などにも通る。
+///
+/// 実装メモ:
+/// - nodeinfo は 2 段構え。/.well-known/nodeinfo で本体 URL を discovery してから取得する。
+/// 本体パスは実装ごとに違う (Pleroma は /nodeinfo/2.1.json) ので、discovery は省略できない
+/// - 2.1 を優先し、無ければ 2.0。Mastodon は 2.0 のみを出す
+/// - discovery が返す href は**相手サーバーが自由に決められる値**なので、
+/// 「調査対象ホスト自身の https URL」以外は捨てる (別ドメインへ取りに行かせないため)
+/// - nodeName も相手の自由入力。MFM / リンク / HTML 記法は safeLabel で全角に潰す。
+/// nodeDescription は HTML 混じりで無害化のコストが高いので最初から表示しない
+/// - 内容はほぼ静的なので自動更新は 6 時間おき
+
+let WELL_KNOWN_REL_20 = "http://nodeinfo.diaspora.software/ns/schema/2.0"
+let WELL_KNOWN_REL_21 = "http://nodeinfo.diaspora.software/ns/schema/2.1"
+let HOST_KEY = "nodeinfo:host"
+let DEFAULT_HOST = "misskey.io"
+let HOST_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789.-"
+// nodeinfo は大きくても数 KB。相手が返す量を無制限にパースしない
+let MAX_BODY = 200000
+
+// カラーパレット (ink-new-servers と同系統)
+let COL_OPEN = "86b300" // 登録開放
+let COL_DOWN = "f74e52" // エラー
+let COL_SUB = "6b7480" // 補助・招待制
+
+// ─────────────────────────────
+// ヘルパー
+// ─────────────────────────────
+@dflt(v, d) { if (Core:type(v) == "null") d else v }
+
+@get(o, k, d) {
+ if (Core:type(o) != "obj") { d }
+ elif (Obj:has(o, k)) { dflt(o[k], d) }
+ else { d }
+}
+
+/// レスポンス本文を JSON オブジェクトとして読む。
+/// Json:parse は失敗時に throw せず error 値を返すので、obj 以外はすべて null に潰す
+@parseObj(body) {
+ if (Core:type(body) != "str") { return null }
+ if (body.len > MAX_BODY) { return null }
+ let v = Json:parse(body)
+ if (Core:type(v) == "obj") v else null
+}
+
+/// 数値を日本語の桁表記に (731141 -> 73.1万, 172951329 -> 1.7億)
+@fmtNum(n) {
+ if (Core:type(n) != "num") { "?" }
+ elif (n >= 100000000) { `{Math:round(n / 10000000) / 10}億` }
+ elif (n >= 10000) { `{Math:round(n / 1000) / 10}万` }
+ else { `{Math:round(n)}` }
+}
+
+/// 相手サーバー由来の文字列を表示用に無害化する。
+/// MFM ($[...])・リンク ([...])・装飾 (*)・HTML (<>) を全角に置換して発火させない
+@safeLabel(s) {
+ if (Core:type(s) != "str") { "?" }
+ else {
+ s.replace("$", "$").replace("[", "[").replace("]", "]").replace("*", "*").replace("<", "<").replace(">", ">")
+ }
+}
+
+@trunc(s, n) {
+ if (s.len > n) `{s.slice(0, n)}…` else s
+}
+
+/// ホスト名として妥当か。ドットを含む小文字英数 + ハイフンのみ許す
+/// (ポート付き・IDN・localhost は弾く)
+@validHost(h) {
+ if (!h.incl(".")) { return false }
+ if (h.starts_with(".") || h.starts_with("-")) { return false }
+ if (h.ends_with(".") || h.ends_with("-")) { return false }
+ var ok = true
+ each (let c, h.to_char_arr()) {
+ if (!HOST_CHARS.incl(c)) { ok = false }
+ }
+ ok
+}
+
+/// 入力欄の値からホスト名を取り出す。URL 貼り付け・@user@host・大文字を許容。
+/// 妥当でなければ null
+@normalizeHost(s) {
+ if (Core:type(s) != "str") { return null }
+ var h = s.trim().lower()
+ if (h.starts_with("https://")) { h = h.slice(8, h.len) }
+ elif (h.starts_with("http://")) { h = h.slice(7, h.len) }
+ // @user@host 形式は最後の @ より後ろを取る
+ let parts = h.split("@")
+ h = parts[parts.len - 1]
+ let slash = h.index_of("/")
+ if (slash >= 0) { h = h.slice(0, slash) }
+ if (h == "") { return null }
+ if (!validHost(h)) { return null }
+ h
+}
+
+/// href が調査対象ホスト自身の https URL か
+@ownHttpsUrl(href, host) {
+ if (Core:type(href) != "str") { return false }
+ href.lower().starts_with(`https://{host}/`)
+}
+
+/// discovery の links から本体 URL を選ぶ (2.1 優先、自ホストの https のみ)
+@pickNodeinfoUrl(links, host) {
+ if (Core:type(links) != "arr") { return null }
+ var url20 = null
+ var url21 = null
+ each (let l, links) {
+ let href = get(l, "href", null)
+ if (ownHttpsUrl(href, host)) {
+ let rel = get(l, "rel", "")
+ if (rel == WELL_KNOWN_REL_21) { url21 = href }
+ elif (rel == WELL_KNOWN_REL_20) { url20 = href }
+ }
+ }
+ if (Core:type(url21) == "str") url21 else url20
+}
+
+/// ソフトウェア名ごとの色 (Misskey 系は緑、Mastodon は紫、Pleroma 系は橙)
+@softwareColor(name) {
+ if (name == "misskey" || name == "sharkey" || name == "cherrypick") { COL_OPEN }
+ elif (name == "firefish" || name == "iceshrimp") { COL_OPEN }
+ elif (name == "mastodon" || name == "hometown") { "6364ff" }
+ elif (name == "pleroma" || name == "akkoma") { "fba457" }
+ else { COL_SUB }
+}
+
+@softwareName(ni) {
+ let name = get(get(ni, "software", null), "name", null)
+ if (Core:type(name) != "str") "" else name.lower()
+}
+
+/// "misskey v2025.4.1" 形式のバッジ本文
+@softwareLabel(ni) {
+ let sw = get(ni, "software", null)
+ let name = get(sw, "name", null)
+ if (Core:type(name) != "str") { return "不明" }
+ let ver = get(sw, "version", null)
+ if (Core:type(ver) != "str") { safeLabel(trunc(name, 24)) }
+ else { `{safeLabel(trunc(name, 24))} v{safeLabel(trunc(ver, 32))}` }
+}
+
+/// 登録受付状態 (色付き)。emailRequiredForSignup は Misskey 系のみ持つ
+@regLabel(ni) {
+ if (get(ni, "openRegistrations", false) != true) {
+ `$[fg.color={COL_SUB} 招待制]`
+ } elif (get(get(ni, "metadata", null), "emailRequiredForSignup", false) == true) {
+ `$[fg.color={COL_OPEN} 登録開放] $[fg.color={COL_SUB} ✉要メール]`
+ } else {
+ `$[fg.color={COL_OPEN} 登録開放]`
+ }
+}
+
+/// 表示行 (MFM 文字列) を組み立てる。常に 5 行
+@infoRows(ni, host) {
+ let name = trunc(safeLabel(dflt(get(get(ni, "metadata", null), "nodeName", null), host)), 28)
+ let users = get(get(get(ni, "usage", null), "users", null), "total", null)
+ let posts = get(get(ni, "usage", null), "localPosts", null)
+ let active = get(get(get(ni, "usage", null), "users", null), "activeMonth", null)
+ var stats = `👥 **{fmtNum(users)}** 📝 **{fmtNum(posts)}**`
+ if (Core:type(active) == "num") {
+ stats = `{stats} 🔥 **{fmtNum(active)}**`
+ }
+ let protocols = get(ni, "protocols", null)
+ var footer = `nodeinfo {dflt(get(ni, "version", null), "?")}`
+ if (Core:type(protocols) == "arr" && protocols.len > 0) {
+ footer = `{footer} ・ {safeLabel(trunc(protocols.join(", "), 40))}`
+ }
+ [
+ `🌐 [**{name}**](https://{host})`
+ `$[bg.color={softwareColor(softwareName(ni))} $[fg.color=ffffff {softwareLabel(ni)} ]]`
+ `{stats}`
+ `{regLabel(ni)}`
+ `$[fg.color={COL_SUB} {footer}]`
+ ]
+}
+
+// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----
+
+// 保存値も信用せず normalizeHost に通す (壊れた値が URL 組み立てに乗らないように)
+var host = dflt(normalizeHost(Mk:load(HOST_KEY)), DEFAULT_HOST)
+var input = host
+var firstRun = true
+var errorMsg = null
+
+/// nodeinfo を取得する。失敗時は null を返し errorMsg に理由を入れる
+@fetchNodeinfo(h) {
+ errorMsg = null
+ let disc = Nd:http(`https://{h}/.well-known/nodeinfo`, {
+ method: "GET"
+ headers: { "Accept": "application/json" }
+ })
+ if (disc.status != 200) {
+ errorMsg = `nodeinfo が見つかりません (HTTP {disc.status})`
+ return null
+ }
+ let url = pickNodeinfoUrl(get(parseObj(disc.body), "links", null), h)
+ if (Core:type(url) != "str") {
+ errorMsg = "対応する nodeinfo (2.0 / 2.1) のリンクがありません"
+ return null
+ }
+ let res = Nd:http(url, {
+ method: "GET"
+ headers: { "Accept": "application/json" }
+ })
+ if (res.status != 200) {
+ errorMsg = `nodeinfo を取得できません (HTTP {res.status})`
+ return null
+ }
+ let ni = parseObj(res.body)
+ if (Core:type(ni) != "obj") {
+ errorMsg = "nodeinfo の応答を読めませんでした"
+ }
+ ni
+}
+
+@configFolder() {
+ Ui:C:folder({
+ title: "⚙️ 調べるサーバー"
+ opened: false
+ children: [
+ Ui:C:textInput({
+ default: input
+ label: "ホスト"
+ text: "misskey.io / https://mastodon.social など"
+ onInput: @(v) { input = v }
+ })
+ Ui:C:button({
+ text: "🔍 調べる"
+ rounded: true
+ primary: true
+ onClick: @() {
+ let h = normalizeHost(input)
+ if (Core:type(h) != "str") {
+ Mk:dialog("エラー", "ホスト名が不正です。misskey.io のような形式で入力してください", "error")
+ } else {
+ // 保存は取得成功後 (render 内)。名前が引けないホストで Nd:http が
+ // 落ちた場合に、その値が次回起動時にも読み込まれて詰むのを避ける
+ host = h
+ input = h
+ firstRun = true
+ render()
+ }
+ }
+ })
+ ]
+ })
+}
+
+@render() {
+ if (firstRun) {
+ Ui:render([
+ Ui:C:container({
+ align: "center"
+ children: [Ui:C:text({ text: `{host} を調べています… 🌐` })]
+ })
+ ])
+ }
+ firstRun = false
+
+ let ni = fetchNodeinfo(host)
+ if (Core:type(ni) != "obj") {
+ Ui:render([
+ Ui:C:container({
+ align: "center"
+ children: [
+ Ui:C:mfm({ text: `$[fg.color={COL_SUB} {safeLabel(host)}]` })
+ Ui:C:mfm({ text: `$[fg.color={COL_DOWN} ⚠️ {dflt(errorMsg, "取得に失敗しました")}]` })
+ Ui:C:button({ text: "🔄 再試行", rounded: true, onClick: @() { render() } })
+ configFolder()
+ ]
+ })
+ ])
+ return null
+ }
+
+ Mk:save(HOST_KEY, host)
+
+ var body = []
+ each (let r, infoRows(ni, host)) {
+ body.push(Ui:C:mfm({ text: r }))
+ }
+ body.push(Ui:C:button({ text: "🔄 更新", rounded: true, onClick: @() { render() } }))
+ body.push(configFolder())
+ Ui:render([
+ Ui:C:container({
+ align: "center"
+ children: body
+ })
+ ])
+}
+
+render()
+// nodeinfo の内容はほぼ静的なので 6 時間おきで十分
+Async:interval(21600000, render, false)
diff --git a/public/registry/widgets/osv-vulns/api.json b/public/registry/widgets/osv-vulns/api.json
new file mode 100644
index 0000000..3a62846
--- /dev/null
+++ b/public/registry/widgets/osv-vulns/api.json
@@ -0,0 +1 @@
+{"type":"widget","data":"/// @ 1.2.1\n/// OSV 脆弱性ウィジェット\n/// OSV.dev (Google の Open Source Vulnerabilities) に**パッケージ名**を投げて、\n/// そのパッケージの既知脆弱性を新しい順に表示する。API キー不要。\n/// 既定は Misskey の公式 JS SDK (npm:misskey-js)。\n///\n/// cve ウィジェットが「NVD の直近 N 日の CVE を眺める」= CVE 番号起点なのに対し、\n/// こちらは「自分が使っているパッケージに何が出ているか」= パッケージ起点で引く。\n///\n/// 実装メモ:\n/// - POST https://api.osv.dev/v1/query に {\"package\":{\"name\",\"ecosystem\"}} を送る。\n/// 本文は Json:stringify で組む (パッケージ名を文字列連結すると本文を捏造できる)。\n/// 加えて validPkg で許可文字を制限し、リンク生成にも安全な値だけ通す\n/// - ecosystem の綴りは OSV 側で厳密 (\"PyPI\" / \"crates.io\" / \"RubyGems\")。select で固定する\n/// - 0 件のときの応答は {} (vulns キーごと無い)\n/// - 大きなパッケージほど応答が重い (npm:next で約 160KB)。表示は MAX_ITEMS 件だけ\n/// - OSV の返す順は発行日順ではないので published から数値キーを作って並べ替える\n\nlet API_URL = \"https://api.osv.dev/v1/query\"\nlet PKG_KEY = \"osv-vulns:pkg\"\nlet ECO_KEY = \"osv-vulns:eco\"\nlet DEFAULT_PKG = \"misskey-js\"\nlet DEFAULT_ECO = \"npm\"\nlet MAX_ITEMS = 5 // 表示する最大件数\nlet DESC_LEN = 58 // 概要の最大表示文字数\nlet MAX_NAME = 128 // パッケージ名の最大長\n// 応答は件数に比例して重い (npm:next で約 160KB、PyPI:Django は 320 件で約 870KB)。\n// 桁違いのものはパースせず捨てる\nlet MAX_BODY = 1200000\n// パッケージ名に許す文字。\" と \\ を除くことで POST 本文を捏造されないようにする\nlet PKG_CHARS = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_/@:+\"\n\n// cve ウィジェットと揃えたカラー\nlet COL_DIM = \"5b6470\"\nlet COL_FLAT = \"8a919e\"\nlet COL_DESC = \"b0b7c3\"\nlet COL_OK = \"16c784\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// レスポンス本文を JSON オブジェクトとして読む。\n/// Json:parse は失敗時に throw せず error 値を返すので、obj 以外はすべて null に潰す\n@parseObj(body) {\n if (Core:type(body) != \"str\") { return null }\n if (body.len > MAX_BODY) { return null }\n let v = Json:parse(body)\n if (Core:type(v) == \"obj\") v else null\n}\n\n/// OSV 由来の文字列を表示用に無害化する。\n/// MFM ($[...])・リンク ([...])・装飾 (*)・HTML (<>) を全角に置換して発火させない\n@safeLabel(s) {\n if (Core:type(s) != \"str\") { \"?\" }\n else {\n s.replace(\"$\", \"$\").replace(\"[\", \"[\").replace(\"]\", \"]\").replace(\"*\", \"*\").replace(\"<\", \"<\").replace(\">\", \">\")\n }\n}\n\n@trunc(s, n) {\n if (s.len > n) `{s.slice(0, n)}…` else s\n}\n\n/// パッケージ名として妥当か (許可文字のみ・長さ制限)\n@validPkg(s) {\n if (Core:type(s) != \"str\") { return false }\n if (s == \"\" || s.len > MAX_NAME) { return false }\n var ok = true\n each (let c, s.to_char_arr()) {\n if (!PKG_CHARS.incl(c)) { ok = false }\n }\n ok\n}\n\n/// 入力欄の値をパッケージ名に整える。妥当でなければ null\n@normalizePkg(s) {\n if (Core:type(s) != \"str\") { return null }\n let p = s.trim()\n if (!validPkg(p)) { return null }\n p\n}\n\n/// GHSA の重大度 (LOW / MODERATE / HIGH / CRITICAL)。無ければ \"不明\"\n@sevOf(v) {\n let s = get(get(v, \"database_specific\", null), \"severity\", null)\n if (Core:type(s) != \"str\" || s == \"\") \"不明\" else s.upper()\n}\n\n@sevColor(sev) {\n if (sev == \"CRITICAL\") { \"ea3943\" }\n elif (sev == \"HIGH\") { \"ff8c42\" }\n elif (sev == \"MODERATE\" || sev == \"MEDIUM\") { \"f5a623\" }\n elif (sev == \"LOW\") { COL_OK }\n else { COL_FLAT }\n}\n\n/// ISO 8601 から \"MM/DD\" を取り出す\n@monthDay(s) {\n if (Core:type(s) != \"str\" || s.len < 10) \"\" else `{s.slice(5, 7)}/{s.slice(8, 10)}`\n}\n\n/// published から並べ替え用の数値キー (20251215205527)。読めなければ 0。\n/// 同じ日に複数出ることがある (npm:next は 1 日に何件も) ので秒まで見る。\n/// 桁の切り出しに 1 文字ずつのループを使うと 300 件規模で数秒かかるので to_num に任せる\n@pubKey(v) {\n let s = get(v, \"published\", null)\n if (Core:type(s) != \"str\" || s.len < 19) { return 0 }\n let digits = `{s.slice(0, 4)}{s.slice(5, 7)}{s.slice(8, 10)}{s.slice(11, 13)}{s.slice(14, 16)}{s.slice(17, 19)}`\n dflt(digits.to_num(), 0)\n}\n\n/// affected[].ranges[].events[] から最初の fixed バージョンを拾う。無ければ \"\"\n@fixedVersion(v) {\n var found = \"\"\n each (let a, get(v, \"affected\", [])) {\n each (let r, get(a, \"ranges\", [])) {\n each (let e, get(r, \"events\", [])) {\n let f = get(e, \"fixed\", null)\n if (found == \"\" && Core:type(f) == \"str\") { found = f }\n }\n }\n }\n found\n}\n\n/// 脆弱性 1 件 = 2 行カード\n/// 行1: ● GHSA-ID (osv.dev へのリンク) 重大度\n/// 行2: MM/DD 概要 → 修正バージョン\n@vulnRows(v) {\n let id = safeLabel(trunc(dflt(get(v, \"id\", null), \"?\"), 40))\n let sev = sevOf(v)\n let col = sevColor(sev)\n let summary = trunc(safeLabel(dflt(get(v, \"summary\", null), \"(概要なし)\")), DESC_LEN)\n var l2 = ` $[fg.color={COL_DESC} {summary}]`\n let md = monthDay(get(v, \"published\", null))\n if (md != \"\") {\n l2 = ` $[fg.color={COL_DIM} {md}] $[fg.color={COL_DESC} {summary}]`\n }\n let fixed = fixedVersion(v)\n if (fixed != \"\") {\n l2 = `{l2} $[fg.color={COL_DIM} → {safeLabel(trunc(fixed, 24))} で修正]`\n }\n [\n `$[fg.color={col} ●] [{id}](https://osv.dev/vulnerability/{id}) $[fg.color={col} **{sev}**]`\n l2\n ]\n}\n\n/// vulns を新しい順に並べ、上位 max 件ぶんの表示行を返す。\n/// 並べ替えキーは先に 1 件 1 回だけ作る。比較関数の中で pubKey を呼ぶと\n/// 件数の多いパッケージ (PyPI:Django で 320 件) で桁違いに遅くなる\n@buildRows(vulns, max) {\n if (Core:type(vulns) != \"arr\") { return [] }\n var keyed = []\n each (let v, vulns) {\n keyed.push({ k: pubKey(v), v: v })\n }\n let sorted = keyed.sort(@(a, b) { b.k - a.k })\n var rows = []\n var shown = 0\n each (let e, sorted) {\n if (shown < max) {\n rows = rows.concat(vulnRows(e.v))\n shown += 1\n }\n }\n rows\n}\n\n// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----\n\n// OSV の ecosystem 名は綴りが厳密なので select で固定する\nlet ECOSYSTEMS = [\n { text: \"npm\", value: \"npm\" }\n { text: \"PyPI\", value: \"PyPI\" }\n { text: \"crates.io\", value: \"crates.io\" }\n { text: \"Go\", value: \"Go\" }\n { text: \"RubyGems\", value: \"RubyGems\" }\n { text: \"Packagist\", value: \"Packagist\" }\n { text: \"Maven\", value: \"Maven\" }\n { text: \"NuGet\", value: \"NuGet\" }\n]\n\n// 保存値も信用せず normalizePkg に通す\nvar pkg = dflt(normalizePkg(Mk:load(PKG_KEY)), DEFAULT_PKG)\nvar eco = dflt(Mk:load(ECO_KEY), DEFAULT_ECO)\nvar input = pkg\nvar firstRun = true\nvar errorMsg = null\n\n/// OSV に問い合わせる。失敗時は null を返し errorMsg に理由を入れる\n@fetchVulns(name, ecosystem) {\n errorMsg = null\n let res = Nd:http(API_URL, {\n method: \"POST\"\n headers: {\n \"Content-Type\": \"application/json\"\n \"Accept\": \"application/json\"\n }\n body: Json:stringify({\n package: {\n name: name\n ecosystem: ecosystem\n }\n })\n })\n if (res.status != 200) {\n errorMsg = `OSV に問い合わせできません (HTTP {res.status})`\n return null\n }\n let data = parseObj(res.body)\n if (Core:type(data) != \"obj\") {\n errorMsg = \"OSV の応答を読めませんでした\"\n return null\n }\n // 0 件のときは {} が返る (vulns キーごと無い)\n get(data, \"vulns\", [])\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 調べるパッケージ\"\n opened: false\n children: [\n Ui:C:select({\n items: ECOSYSTEMS\n default: eco\n onChange: @(v) {\n eco = v\n Mk:save(ECO_KEY, v)\n }\n })\n Ui:C:textInput({\n default: input\n label: \"パッケージ名\"\n text: \"misskey-js / @misskey-dev/note など\"\n onInput: @(v) { input = v }\n })\n Ui:C:button({\n text: \"🔍 調べる\"\n rounded: true\n primary: true\n onClick: @() {\n let p = normalizePkg(input)\n if (Core:type(p) != \"str\") {\n Mk:dialog(\"エラー\", \"パッケージ名に使えない文字が含まれています\", \"error\")\n } else {\n // 保存は取得成功後 (render 内)\n pkg = p\n input = p\n firstRun = true\n render()\n }\n }\n })\n ]\n })\n}\n\n@header() {\n Ui:C:mfm({ text: `🛡 **OSV 脆弱性** $[fg.color={COL_DIM} {eco} / {safeLabel(pkg)}]` })\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: `{pkg} を照会中… 🛡` })]\n })\n ])\n }\n firstRun = false\n\n let vulns = fetchVulns(pkg, eco)\n if (Core:type(vulns) != \"arr\") {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n header()\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ {dflt(errorMsg, \"取得に失敗しました\")}]` })\n Ui:C:button({ text: \"🔄 再試行\", rounded: true, onClick: @() { render() } })\n configFolder()\n ]\n })\n ])\n return null\n }\n Mk:save(PKG_KEY, pkg)\n\n var body = [header()]\n if (vulns.len == 0) {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_OK} ✅ 既知の脆弱性はありません]` }))\n } else {\n each (let r, buildRows(vulns, MAX_ITEMS)) {\n body.push(Ui:C:mfm({ text: r }))\n }\n if (vulns.len > MAX_ITEMS) {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_FLAT} 全 {vulns.len} 件のうち新しい {MAX_ITEMS} 件]` }))\n }\n }\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_FLAT} データ: osv.dev]` }))\n body.push(Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { render() } }))\n body.push(configFolder())\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\n// アドバイザリの発行は日次より粗いので 6 時間おきで十分\nAsync:interval(21600000, render, false)\n"}
diff --git a/public/registry/widgets/osv-vulns/icon.svg b/public/registry/widgets/osv-vulns/icon.svg
new file mode 100644
index 0000000..e9564c0
--- /dev/null
+++ b/public/registry/widgets/osv-vulns/icon.svg
@@ -0,0 +1,19 @@
+
diff --git a/public/registry/widgets/osv-vulns/meta.json b/public/registry/widgets/osv-vulns/meta.json
new file mode 100644
index 0000000..88c3dc4
--- /dev/null
+++ b/public/registry/widgets/osv-vulns/meta.json
@@ -0,0 +1,14 @@
+{
+ "id": "osv-vulns",
+ "name": "OSV 脆弱性",
+ "version": "1.0.0",
+ "author": "@hitalin",
+ "authorUrl": "https://github.com/hitalin",
+ "description": "OSV.dev にパッケージ名を投げて、そのパッケージの既知脆弱性を重大度・修正バージョン付きで新しい順に表示。既定は npm:misskey-js。npm / PyPI / crates.io など 8 エコシステム対応、API キー不要",
+ "icon": "ti-package-search",
+ "autoRun": true,
+ "category": "display",
+ "capabilities": ["notedeck-api"],
+ "license": "MIT",
+ "tags": ["security", "vulnerability", "osv", "cve", "supply-chain", "misskey"]
+}
diff --git a/public/registry/widgets/osv-vulns/widget.is b/public/registry/widgets/osv-vulns/widget.is
new file mode 100644
index 0000000..dc08007
--- /dev/null
+++ b/public/registry/widgets/osv-vulns/widget.is
@@ -0,0 +1,324 @@
+/// @ 1.2.1
+/// OSV 脆弱性ウィジェット
+/// OSV.dev (Google の Open Source Vulnerabilities) に**パッケージ名**を投げて、
+/// そのパッケージの既知脆弱性を新しい順に表示する。API キー不要。
+/// 既定は Misskey の公式 JS SDK (npm:misskey-js)。
+///
+/// cve ウィジェットが「NVD の直近 N 日の CVE を眺める」= CVE 番号起点なのに対し、
+/// こちらは「自分が使っているパッケージに何が出ているか」= パッケージ起点で引く。
+///
+/// 実装メモ:
+/// - POST https://api.osv.dev/v1/query に {"package":{"name","ecosystem"}} を送る。
+/// 本文は Json:stringify で組む (パッケージ名を文字列連結すると本文を捏造できる)。
+/// 加えて validPkg で許可文字を制限し、リンク生成にも安全な値だけ通す
+/// - ecosystem の綴りは OSV 側で厳密 ("PyPI" / "crates.io" / "RubyGems")。select で固定する
+/// - 0 件のときの応答は {} (vulns キーごと無い)
+/// - 大きなパッケージほど応答が重い (npm:next で約 160KB)。表示は MAX_ITEMS 件だけ
+/// - OSV の返す順は発行日順ではないので published から数値キーを作って並べ替える
+
+let API_URL = "https://api.osv.dev/v1/query"
+let PKG_KEY = "osv-vulns:pkg"
+let ECO_KEY = "osv-vulns:eco"
+let DEFAULT_PKG = "misskey-js"
+let DEFAULT_ECO = "npm"
+let MAX_ITEMS = 5 // 表示する最大件数
+let DESC_LEN = 58 // 概要の最大表示文字数
+let MAX_NAME = 128 // パッケージ名の最大長
+// 応答は件数に比例して重い (npm:next で約 160KB、PyPI:Django は 320 件で約 870KB)。
+// 桁違いのものはパースせず捨てる
+let MAX_BODY = 1200000
+// パッケージ名に許す文字。" と \ を除くことで POST 本文を捏造されないようにする
+let PKG_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_/@:+"
+
+// cve ウィジェットと揃えたカラー
+let COL_DIM = "5b6470"
+let COL_FLAT = "8a919e"
+let COL_DESC = "b0b7c3"
+let COL_OK = "16c784"
+
+// ─────────────────────────────
+// ヘルパー
+// ─────────────────────────────
+@dflt(v, d) { if (Core:type(v) == "null") d else v }
+
+@get(o, k, d) {
+ if (Core:type(o) != "obj") { d }
+ elif (Obj:has(o, k)) { dflt(o[k], d) }
+ else { d }
+}
+
+/// レスポンス本文を JSON オブジェクトとして読む。
+/// Json:parse は失敗時に throw せず error 値を返すので、obj 以外はすべて null に潰す
+@parseObj(body) {
+ if (Core:type(body) != "str") { return null }
+ if (body.len > MAX_BODY) { return null }
+ let v = Json:parse(body)
+ if (Core:type(v) == "obj") v else null
+}
+
+/// OSV 由来の文字列を表示用に無害化する。
+/// MFM ($[...])・リンク ([...])・装飾 (*)・HTML (<>) を全角に置換して発火させない
+@safeLabel(s) {
+ if (Core:type(s) != "str") { "?" }
+ else {
+ s.replace("$", "$").replace("[", "[").replace("]", "]").replace("*", "*").replace("<", "<").replace(">", ">")
+ }
+}
+
+@trunc(s, n) {
+ if (s.len > n) `{s.slice(0, n)}…` else s
+}
+
+/// パッケージ名として妥当か (許可文字のみ・長さ制限)
+@validPkg(s) {
+ if (Core:type(s) != "str") { return false }
+ if (s == "" || s.len > MAX_NAME) { return false }
+ var ok = true
+ each (let c, s.to_char_arr()) {
+ if (!PKG_CHARS.incl(c)) { ok = false }
+ }
+ ok
+}
+
+/// 入力欄の値をパッケージ名に整える。妥当でなければ null
+@normalizePkg(s) {
+ if (Core:type(s) != "str") { return null }
+ let p = s.trim()
+ if (!validPkg(p)) { return null }
+ p
+}
+
+/// GHSA の重大度 (LOW / MODERATE / HIGH / CRITICAL)。無ければ "不明"
+@sevOf(v) {
+ let s = get(get(v, "database_specific", null), "severity", null)
+ if (Core:type(s) != "str" || s == "") "不明" else s.upper()
+}
+
+@sevColor(sev) {
+ if (sev == "CRITICAL") { "ea3943" }
+ elif (sev == "HIGH") { "ff8c42" }
+ elif (sev == "MODERATE" || sev == "MEDIUM") { "f5a623" }
+ elif (sev == "LOW") { COL_OK }
+ else { COL_FLAT }
+}
+
+/// ISO 8601 から "MM/DD" を取り出す
+@monthDay(s) {
+ if (Core:type(s) != "str" || s.len < 10) "" else `{s.slice(5, 7)}/{s.slice(8, 10)}`
+}
+
+/// published から並べ替え用の数値キー (20251215205527)。読めなければ 0。
+/// 同じ日に複数出ることがある (npm:next は 1 日に何件も) ので秒まで見る。
+/// 桁の切り出しに 1 文字ずつのループを使うと 300 件規模で数秒かかるので to_num に任せる
+@pubKey(v) {
+ let s = get(v, "published", null)
+ if (Core:type(s) != "str" || s.len < 19) { return 0 }
+ let digits = `{s.slice(0, 4)}{s.slice(5, 7)}{s.slice(8, 10)}{s.slice(11, 13)}{s.slice(14, 16)}{s.slice(17, 19)}`
+ dflt(digits.to_num(), 0)
+}
+
+/// affected[].ranges[].events[] から最初の fixed バージョンを拾う。無ければ ""
+@fixedVersion(v) {
+ var found = ""
+ each (let a, get(v, "affected", [])) {
+ each (let r, get(a, "ranges", [])) {
+ each (let e, get(r, "events", [])) {
+ let f = get(e, "fixed", null)
+ if (found == "" && Core:type(f) == "str") { found = f }
+ }
+ }
+ }
+ found
+}
+
+/// 脆弱性 1 件 = 2 行カード
+/// 行1: ● GHSA-ID (osv.dev へのリンク) 重大度
+/// 行2: MM/DD 概要 → 修正バージョン
+@vulnRows(v) {
+ let id = safeLabel(trunc(dflt(get(v, "id", null), "?"), 40))
+ let sev = sevOf(v)
+ let col = sevColor(sev)
+ let summary = trunc(safeLabel(dflt(get(v, "summary", null), "(概要なし)")), DESC_LEN)
+ var l2 = ` $[fg.color={COL_DESC} {summary}]`
+ let md = monthDay(get(v, "published", null))
+ if (md != "") {
+ l2 = ` $[fg.color={COL_DIM} {md}] $[fg.color={COL_DESC} {summary}]`
+ }
+ let fixed = fixedVersion(v)
+ if (fixed != "") {
+ l2 = `{l2} $[fg.color={COL_DIM} → {safeLabel(trunc(fixed, 24))} で修正]`
+ }
+ [
+ `$[fg.color={col} ●] [{id}](https://osv.dev/vulnerability/{id}) $[fg.color={col} **{sev}**]`
+ l2
+ ]
+}
+
+/// vulns を新しい順に並べ、上位 max 件ぶんの表示行を返す。
+/// 並べ替えキーは先に 1 件 1 回だけ作る。比較関数の中で pubKey を呼ぶと
+/// 件数の多いパッケージ (PyPI:Django で 320 件) で桁違いに遅くなる
+@buildRows(vulns, max) {
+ if (Core:type(vulns) != "arr") { return [] }
+ var keyed = []
+ each (let v, vulns) {
+ keyed.push({ k: pubKey(v), v: v })
+ }
+ let sorted = keyed.sort(@(a, b) { b.k - a.k })
+ var rows = []
+ var shown = 0
+ each (let e, sorted) {
+ if (shown < max) {
+ rows = rows.concat(vulnRows(e.v))
+ shown += 1
+ }
+ }
+ rows
+}
+
+// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----
+
+// OSV の ecosystem 名は綴りが厳密なので select で固定する
+let ECOSYSTEMS = [
+ { text: "npm", value: "npm" }
+ { text: "PyPI", value: "PyPI" }
+ { text: "crates.io", value: "crates.io" }
+ { text: "Go", value: "Go" }
+ { text: "RubyGems", value: "RubyGems" }
+ { text: "Packagist", value: "Packagist" }
+ { text: "Maven", value: "Maven" }
+ { text: "NuGet", value: "NuGet" }
+]
+
+// 保存値も信用せず normalizePkg に通す
+var pkg = dflt(normalizePkg(Mk:load(PKG_KEY)), DEFAULT_PKG)
+var eco = dflt(Mk:load(ECO_KEY), DEFAULT_ECO)
+var input = pkg
+var firstRun = true
+var errorMsg = null
+
+/// OSV に問い合わせる。失敗時は null を返し errorMsg に理由を入れる
+@fetchVulns(name, ecosystem) {
+ errorMsg = null
+ let res = Nd:http(API_URL, {
+ method: "POST"
+ headers: {
+ "Content-Type": "application/json"
+ "Accept": "application/json"
+ }
+ body: Json:stringify({
+ package: {
+ name: name
+ ecosystem: ecosystem
+ }
+ })
+ })
+ if (res.status != 200) {
+ errorMsg = `OSV に問い合わせできません (HTTP {res.status})`
+ return null
+ }
+ let data = parseObj(res.body)
+ if (Core:type(data) != "obj") {
+ errorMsg = "OSV の応答を読めませんでした"
+ return null
+ }
+ // 0 件のときは {} が返る (vulns キーごと無い)
+ get(data, "vulns", [])
+}
+
+@configFolder() {
+ Ui:C:folder({
+ title: "⚙️ 調べるパッケージ"
+ opened: false
+ children: [
+ Ui:C:select({
+ items: ECOSYSTEMS
+ default: eco
+ onChange: @(v) {
+ eco = v
+ Mk:save(ECO_KEY, v)
+ }
+ })
+ Ui:C:textInput({
+ default: input
+ label: "パッケージ名"
+ text: "misskey-js / @misskey-dev/note など"
+ onInput: @(v) { input = v }
+ })
+ Ui:C:button({
+ text: "🔍 調べる"
+ rounded: true
+ primary: true
+ onClick: @() {
+ let p = normalizePkg(input)
+ if (Core:type(p) != "str") {
+ Mk:dialog("エラー", "パッケージ名に使えない文字が含まれています", "error")
+ } else {
+ // 保存は取得成功後 (render 内)
+ pkg = p
+ input = p
+ firstRun = true
+ render()
+ }
+ }
+ })
+ ]
+ })
+}
+
+@header() {
+ Ui:C:mfm({ text: `🛡 **OSV 脆弱性** $[fg.color={COL_DIM} {eco} / {safeLabel(pkg)}]` })
+}
+
+@render() {
+ if (firstRun) {
+ Ui:render([
+ Ui:C:container({
+ align: "center"
+ children: [Ui:C:text({ text: `{pkg} を照会中… 🛡` })]
+ })
+ ])
+ }
+ firstRun = false
+
+ let vulns = fetchVulns(pkg, eco)
+ if (Core:type(vulns) != "arr") {
+ Ui:render([
+ Ui:C:container({
+ align: "center"
+ children: [
+ header()
+ Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ {dflt(errorMsg, "取得に失敗しました")}]` })
+ Ui:C:button({ text: "🔄 再試行", rounded: true, onClick: @() { render() } })
+ configFolder()
+ ]
+ })
+ ])
+ return null
+ }
+ Mk:save(PKG_KEY, pkg)
+
+ var body = [header()]
+ if (vulns.len == 0) {
+ body.push(Ui:C:mfm({ text: `$[fg.color={COL_OK} ✅ 既知の脆弱性はありません]` }))
+ } else {
+ each (let r, buildRows(vulns, MAX_ITEMS)) {
+ body.push(Ui:C:mfm({ text: r }))
+ }
+ if (vulns.len > MAX_ITEMS) {
+ body.push(Ui:C:mfm({ text: `$[fg.color={COL_FLAT} 全 {vulns.len} 件のうち新しい {MAX_ITEMS} 件]` }))
+ }
+ }
+ body.push(Ui:C:mfm({ text: `$[fg.color={COL_FLAT} データ: osv.dev]` }))
+ body.push(Ui:C:button({ text: "🔄 更新", rounded: true, onClick: @() { render() } }))
+ body.push(configFolder())
+ Ui:render([
+ Ui:C:container({
+ align: "center"
+ children: body
+ })
+ ])
+}
+
+render()
+// アドバイザリの発行は日次より粗いので 6 時間おきで十分
+Async:interval(21600000, render, false)
diff --git a/scripts/sync-icons.js b/scripts/sync-icons.js
index 337e398..60710ff 100644
--- a/scripts/sync-icons.js
+++ b/scripts/sync-icons.js
@@ -47,7 +47,9 @@ const ICON_MAP = {
'widgets/mewk': { pack: 'lucide', name: 'cat' },
'widgets/minecraft': { pack: 'lucide', name: 'box' },
'widgets/misskey-online': { pack: 'simple', name: 'misskey' },
+ 'widgets/nodeinfo': { pack: 'lucide', name: 'scan-search' },
'widgets/okj-prices': { pack: 'lucide', name: 'chart-candlestick' },
+ 'widgets/osv-vulns': { pack: 'lucide', name: 'package-search' },
'widgets/onedraw-timer': { pack: 'lucide', name: 'timer' },
// 本家 misskey-tools のミス廃アラートは fa-tower-broadcast
'widgets/misuhai-score': { pack: 'lucide', name: 'radio-tower' },