chore: auto-fix lint & formatting [skip ci]

This commit is contained in:
github-actions[bot] 2026-08-11 16:06:06 +00:00
parent c73b642504
commit 89b43b8b28
12 changed files with 160 additions and 97 deletions

View file

@ -65,7 +65,7 @@ npx wrangler dev # 启动本地开发服务器
| `TELEGRAM_RICH_HEADER_HOST` | 可选;覆盖内置 `GET /api/richheader` 的 Telegram 头像卡片地址 | | `TELEGRAM_RICH_HEADER_HOST` | 可选;覆盖内置 `GET /api/richheader` 的 Telegram 头像卡片地址 |
| `BASE_URL` | 公网地址(用于 OAuth 回调与 Telegram webhook 同步) | | `BASE_URL` | 公网地址(用于 OAuth 回调与 Telegram webhook 同步) |
| `ADMIN_USER_IDS` | 允许访问 `/admin` 的 GitHub 用户 ID或登录名逗号分隔 | | `ADMIN_USER_IDS` | 允许访问 `/admin` 的 GitHub 用户 ID或登录名逗号分隔 |
| `ALLOW_SELF_SIGNUP` | 设为 `1` 时,无权限的 GitHub 用户首次登录自动获得个人分组(默认关闭) | | `ALLOW_SELF_SIGNUP` | 设为 `1` 时,无权限的 GitHub 用户首次登录自动获得个人分组(默认关闭) |
| `AUDIT_RETENTION_DAYS` | 定时清理时审计日志的保留天数(默认 90 | | `AUDIT_RETENTION_DAYS` | 定时清理时审计日志的保留天数(默认 90 |
| `DOCS_URL` | 可选;落地页使用的文档站点 URL | | `DOCS_URL` | 可选;落地页使用的文档站点 URL |
| `GITHUB_REPO_URL` | 可选;落地页使用的 GitHub 仓库 URL | | `GITHUB_REPO_URL` | 可选;落地页使用的 GitHub 仓库 URL |

View file

@ -73,11 +73,7 @@
<section v-if="!groupRoutesLoading && !groupRoutes.length" class="empty"> <section v-if="!groupRoutesLoading && !groupRoutes.length" class="empty">
<p>{{ t("routes.emptyGroup") }}</p> <p>{{ t("routes.emptyGroup") }}</p>
<button <button v-if="canEditRoutes(selectedGroup.id)" class="btn btn-accent" @click="openNew">
v-if="canEditRoutes(selectedGroup.id)"
class="btn btn-accent"
@click="openNew"
>
{{ t("routes.createFirst") }} {{ t("routes.createFirst") }}
</button> </button>
</section> </section>
@ -134,7 +130,9 @@
<div class="card-title"> <div class="card-title">
<span class="route-name">{{ g.name || t("route.untitled") }}</span> <span class="route-name">{{ g.name || t("route.untitled") }}</span>
<span class="route-id">{{ g.id }}</span> <span class="route-id">{{ g.id }}</span>
<span v-if="roleOf(g.id)" class="badge lang">{{ t("role.badge", { role: t("roles." + roleOf(g.id)) }) }}</span> <span v-if="roleOf(g.id)" class="badge lang">{{
t("role.badge", { role: t("roles." + roleOf(g.id)) })
}}</span>
</div> </div>
<div v-if="canEditGroup(g.id)" class="card-actions" @click.stop> <div v-if="canEditGroup(g.id)" class="card-actions" @click.stop>
<button <button
@ -150,7 +148,9 @@
<div class="target"> <div class="target">
<span <span
><b>{{ t("groups.members") }}</b ><b>{{ t("groups.members") }}</b
><code>{{ (g.members ?? []).length || (g.adminIds || []).length || "—" }}</code></span ><code>{{
(g.members ?? []).length || (g.adminIds || []).length || "—"
}}</code></span
> >
<span <span
><b>{{ t("groups.owners") }}</b ><b>{{ t("groups.owners") }}</b
@ -249,7 +249,12 @@ if (view.value === null) {
} }
const { logs, loading: logsLoading, load: loadLogs } = useSendLogs(); const { logs, loading: logsLoading, load: loadLogs } = useSendLogs();
const { entries: auditEntries, loading: auditLoading, error: auditError, load: loadAudit } = useAuditApi(); const {
entries: auditEntries,
loading: auditLoading,
error: auditError,
load: loadAudit,
} = useAuditApi();
const { const {
groups, groups,
isSuper, isSuper,

View file

@ -110,7 +110,12 @@ import type { Group } from "~/types";
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps<{ open: boolean; group: Group | null; saving: boolean; superAdmin?: boolean }>(); const props = defineProps<{
open: boolean;
group: Group | null;
saving: boolean;
superAdmin?: boolean;
}>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "close"): void; (e: "close"): void;
(e: "save", group: Group): void; (e: "save", group: Group): void;

View file

@ -39,7 +39,11 @@
> >
</button> </button>
<button class="icon-btn" :title="t('routeEditor.editTitle')" @click="$emit('edit', route)"> <button
class="icon-btn"
:title="t('routeEditor.editTitle')"
@click="$emit('edit', route)"
>
</button> </button>
<button <button
@ -88,7 +92,12 @@ import { fmtMatch } from "~/types";
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps<{ route: Route; atFirst?: boolean; atLast?: boolean; readonly?: boolean }>(); const props = defineProps<{
route: Route;
atFirst?: boolean;
atLast?: boolean;
readonly?: boolean;
}>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "toggle", route: Route): void; (e: "toggle", route: Route): void;
(e: "edit", route: Route): void; (e: "edit", route: Route): void;

View file

@ -70,5 +70,17 @@ export function useGroupsApi() {
groups.value = next; groups.value = next;
} }
return { groups, isSuper, roles, roleOf, canEditGroup, canEditRoutes, loading, needLogin, error, load, save }; return {
groups,
isSuper,
roles,
roleOf,
canEditGroup,
canEditRoutes,
loading,
needLogin,
error,
load,
save,
};
} }

View file

@ -58,25 +58,25 @@ WebHooker ships with a built-in config console at `/admin` for managing routes i
The console is served as an SPA at `/admin`; its tabs are deep-linkable via the URL path (`/admin/groups`, `/admin/logs`, `/admin/audit`). URLs outside `/admin` that do not match an endpoint below return a plain `404` instead of the console. The console is served as an SPA at `/admin`; its tabs are deep-linkable via the URL path (`/admin/groups`, `/admin/logs`, `/admin/audit`). URLs outside `/admin` that do not match an endpoint below return a plain `404` instead of the console.
| Endpoint | Description | | Endpoint | Description |
| ----------------------------------------- | -------------------------------------------- | | ------------------------------------ | -------------------------------------------- |
| `GET /admin` | Config console UI | | `GET /admin` | Config console UI |
| `GET /admin/login` | Start GitHub OAuth sign-in | | `GET /admin/login` | Start GitHub OAuth sign-in |
| `GET /admin/logout` | Destroy session | | `GET /admin/logout` | Destroy session |
| `GET /admin/invite?token=…` | Accept a group invite (browser page) | | `GET /admin/invite?token=…` | Accept a group invite (browser page) |
| `GET /admin/api/me` | Current session, scope, groups, and roles | | `GET /admin/api/me` | Current session, scope, groups, and roles |
| `GET /admin/api/routes` | List routes (scoped to access) | | `GET /admin/api/routes` | List routes (scoped to access) |
| `PUT /admin/api/routes` | Replace routes (owner/admin per group) | | `PUT /admin/api/routes` | Replace routes (owner/admin per group) |
| `GET /admin/api/groups` | List groups + the signed-in user's role each | | `GET /admin/api/groups` | List groups + the signed-in user's role each |
| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) | | `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) |
| `GET /admin/api/groups/:id/routes` | List a group's routes | | `GET /admin/api/groups/:id/routes` | List a group's routes |
| `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) | | `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) |
| `GET /admin/api/logs` | Send logs (scoped to accessible routes) | | `GET /admin/api/logs` | Send logs (scoped to accessible routes) |
| `GET /admin/api/logs/:id` | Single send-log entry (scoped) | | `GET /admin/api/logs/:id` | Single send-log entry (scoped) |
| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) | | `POST /admin/api/groups/:id/invites` | Create an invite link (owner) |
| `GET /admin/api/groups/:id/invites` | List pending invites (owner) | | `GET /admin/api/groups/:id/invites` | List pending invites (owner) |
| `DELETE /admin/api/invites/:token` | Revoke an invite (owner) | | `DELETE /admin/api/invites/:token` | Revoke an invite (owner) |
| `GET /admin/api/audit` | Audit log (scoped to accessible groups) | | `GET /admin/api/audit` | Audit log (scoped to accessible groups) |
The console lets you add, edit, delete, and toggle routes. Saved routes are written to KV `config:routes` immediately and the config cache is invalidated so the webhook pipeline picks them up on the next run. The console lets you add, edit, delete, and toggle routes. Saved routes are written to KV `config:routes` immediately and the config cache is invalidated so the webhook pipeline picks them up on the next run.

View file

@ -26,7 +26,7 @@ WebHooker 需要多个密钥才能运行。本地开发时存储在 `.dev.vars`
| --------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------- | | --------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------- |
| `BASE_URL` | OAuth 回调的公开 URL | `http://localhost:8787` | | `BASE_URL` | OAuth 回调的公开 URL | `http://localhost:8787` |
| `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID或登录名逗号分隔 | 未设置时 WebUI 关闭 | | `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID或登录名逗号分隔 | 未设置时 WebUI 关闭 |
| `ALLOW_SELF_SIGNUP` | 开启(`1`/`true`)后,没有任何分组权限的 GitHub 用户首次登录会自动获得个人分组而非 403 | 关闭 | | `ALLOW_SELF_SIGNUP` | 开启(`1`/`true`)后,没有任何分组权限的 GitHub 用户首次登录会自动获得个人分组而非 403 | 关闭 |
| `AUDIT_RETENTION_DAYS` | 定时清理时审计日志的保留天数 | `90` | | `AUDIT_RETENTION_DAYS` | 定时清理时审计日志的保留天数 | `90` |
| `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取),交互功能必需 | 未设置时交互返回 401 | | `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取),交互功能必需 | 未设置时交互返回 401 |
| `DISCORD_APPLICATION_ID` | Discord 应用 ID省略时自动获取 | 自动获取 | | `DISCORD_APPLICATION_ID` | Discord 应用 ID省略时自动获取 | 自动获取 |
@ -58,25 +58,25 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
控制台以 SPA 形式挂在 `/admin`,各标签页可通过 URL 路径直达(`/admin/groups``/admin/logs``/admin/audit`)。`/admin` 之外且未匹配下方端点的 URL 直接返回 `404`,不会再被吞进控制台。 控制台以 SPA 形式挂在 `/admin`,各标签页可通过 URL 路径直达(`/admin/groups``/admin/logs``/admin/audit`)。`/admin` 之外且未匹配下方端点的 URL 直接返回 `404`,不会再被吞进控制台。
| 端点 | 说明 | | 端点 | 说明 |
| ------------------------------------- | ------------------------------------ | | ------------------------------------ | -------------------------------------- |
| `GET /admin` | 配置控制台页面 | | `GET /admin` | 配置控制台页面 |
| `GET /admin/login` | 开始 GitHub OAuth 登录 | | `GET /admin/login` | 开始 GitHub OAuth 登录 |
| `GET /admin/logout` | 销毁会话 | | `GET /admin/logout` | 销毁会话 |
| `GET /admin/invite?token=…` | 接受分组邀请(浏览器页面) | | `GET /admin/invite?token=…` | 接受分组邀请(浏览器页面) |
| `GET /admin/api/me` | 当前会话、权限范围、分组和角色 | | `GET /admin/api/me` | 当前会话、权限范围、分组和角色 |
| `GET /admin/api/routes` | 列出路由(按权限过滤) | | `GET /admin/api/routes` | 列出路由(按权限过滤) |
| `PUT /admin/api/routes` | 替换路由(按分组 owner/admin 权限) | | `PUT /admin/api/routes` | 替换路由(按分组 owner/admin 权限) |
| `GET /admin/api/groups` | 列出分组 + 当前用户在各组的角色 | | `GET /admin/api/groups` | 列出分组 + 当前用户在各组的角色 |
| `PUT /admin/api/groups` | 替换分组超管全量owner 仅自己的组) | | `PUT /admin/api/groups` | 替换分组超管全量owner 仅自己的组) |
| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 | | `GET /admin/api/groups/:id/routes` | 列出某分组的路由 |
| `PUT /admin/api/groups/:id/routes` | 替换某分组的路由owner/admin | | `PUT /admin/api/groups/:id/routes` | 替换某分组的路由owner/admin |
| `GET /admin/api/logs` | 发送日志(按可访问路由过滤) | | `GET /admin/api/logs` | 发送日志(按可访问路由过滤) |
| `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) | | `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) |
| `POST /admin/api/groups/:id/invites` | 创建邀请链接owner | | `POST /admin/api/groups/:id/invites` | 创建邀请链接owner |
| `GET /admin/api/groups/:id/invites` | 列出待接受邀请owner | | `GET /admin/api/groups/:id/invites` | 列出待接受邀请owner |
| `DELETE /admin/api/invites/:token` | 撤销邀请owner | | `DELETE /admin/api/invites/:token` | 撤销邀请owner |
| `GET /admin/api/audit` | 审计日志(按可访问分组过滤) | | `GET /admin/api/audit` | 审计日志(按可访问分组过滤) |
控制台支持新增、编辑、删除和开关路由。保存后立即写入 KV `config:routes` 并使配置缓存失效,下一次 webhook 处理即会生效。 控制台支持新增、编辑、删除和开关路由。保存后立即写入 KV `config:routes` 并使配置缓存失效,下一次 webhook 处理即会生效。
@ -186,25 +186,25 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
} }
``` ```
| 字段 | 类型 | 必需 | 说明 | | 字段 | 类型 | 必需 | 说明 |
| ----------- | -------- | ---- | ------------------------------------------------------------------ | | ----------- | -------- | ---- | ----------------------------------------------------------- |
| `id` | string | 是 | 小写 id`a-z0-9``-`),由每条路由的 `groupId` 引用 | | `id` | string | 是 | 小写 id`a-z0-9``-`),由每条路由的 `groupId` 引用 |
| `name` | string | 是 | 可读的分组名称 | | `name` | string | 是 | 可读的分组名称 |
| `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner``admin``viewer` | | `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner``admin``viewer` |
| `adminIds` | string[] | 否 | 已废弃的旧字段;存在时按 role 为 `owner` 的成员处理 | | `adminIds` | string[] | 否 | 已废弃的旧字段;存在时按 role 为 `owner` 的成员处理 |
| `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 | | `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 |
| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github``gitea`);为空表示全部 | | `providers` | string[] | 否 | 允许进入该分组的来源平台(`github``gitea`);为空表示全部 |
| `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji默认 `true` | | `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji默认 `true` |
### 角色 ### 角色
每个分组成员拥有三种角色之一。超级管理员(`ADMIN_USER_IDS`)始终绕过角色限制。 每个分组成员拥有三种角色之一。超级管理员(`ADMIN_USER_IDS`)始终绕过角色限制。
| 角色 | 查看路由/日志 | 编辑路由 | 管理成员与邀请 | 编辑分组设置 | | 角色 | 查看路由/日志 | 编辑路由 | 管理成员与邀请 | 编辑分组设置 |
| -------- | ------------- | -------- | -------------- | ------------ | | -------- | ------------- | -------- | -------------- | ------------------ |
| `owner` | ✓ | ✓ | ✓ | ✓(`owners` 除外) | | `owner` | ✓ | ✓ | ✓ | ✓(`owners` 除外) |
| `admin` | ✓ | ✓ | ✗ | ✗ | | `admin` | ✓ | ✓ | ✗ | ✗ |
| `viewer` | ✓(只读) | ✗ | ✗ | ✗ | | `viewer` | ✓(只读) | ✗ | ✗ | ✗ |
### 权限模型 ### 权限模型
@ -264,7 +264,7 @@ owner及超级管理员可在分组的「成员」面板创建一次性邀
| `token-reverse:{sha256}` | 用于按 Token 反查的用户 id | 0.9 × Token 有效期 | | `token-reverse:{sha256}` | 用于按 Token 反查的用户 id | 0.9 × Token 有效期 |
| `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 秒 | | `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 秒 |
| `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 天 | | `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 天 |
| `invite:group:{id}` | 每组的 Token 索引(保证邀请列表一致性) | 永久 | | `invite:group:{id}` | 每组的 Token 索引(保证邀请列表一致性) | 永久 |
| `delivery:{id}` | Webhook 投递 id去重标记 | 300 秒 | | `delivery:{id}` | Webhook 投递 id去重标记 | 300 秒 |
| `msg:{routeId}:{key}:{target}` | 原地更新用消息 id 追踪(如 `workflow_run` | 7 天 | | `msg:{routeId}:{key}:{target}` | 原地更新用消息 id 追踪(如 `workflow_run` | 7 天 |
| `cmd:guild:{id}` | 已注册命令的服务器 id去重标记 | 永久 | | `cmd:guild:{id}` | 已注册命令的服务器 id去重标记 | 永久 |

View file

@ -57,22 +57,42 @@ describe("invites", () => {
}); });
it("lists pending invites of a group only", async () => { it("lists pending invites of a group only", async () => {
await createInvite(kv, { groupId: "team", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" }); await createInvite(kv, {
await createInvite(kv, { groupId: "other", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" }); groupId: "team",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
await createInvite(kv, {
groupId: "other",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
const invites = await listInvites(kv, "team"); const invites = await listInvites(kv, "team");
expect(invites).toHaveLength(1); expect(invites).toHaveLength(1);
expect(invites[0]!.groupId).toBe("team"); expect(invites[0]!.groupId).toBe("team");
}); });
it("revokes an invite", async () => { it("revokes an invite", async () => {
const token = await createInvite(kv, { groupId: "team", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" }); const token = await createInvite(kv, {
groupId: "team",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
await revokeInvite(kv, token); await revokeInvite(kv, token);
expect(await getInvite(kv, token)).toBeNull(); expect(await getInvite(kv, token)).toBeNull();
}); });
it("accept adds the user as a member and consumes the token", async () => { it("accept adds the user as a member and consumes the token", async () => {
await saveGroups(kv, [group]); await saveGroups(kv, [group]);
const token = await createInvite(kv, { groupId: "team", role: "admin", expiresAt: Date.now() + 86400_000, createdBy: "boss" }); const token = await createInvite(kv, {
groupId: "team",
role: "admin",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
const result = await acceptInvite(kv, token, "777", "newbie"); const result = await acceptInvite(kv, token, "777", "newbie");
expect(result).toEqual({ ok: true, groupId: "team", role: "admin" }); expect(result).toEqual({ ok: true, groupId: "team", role: "admin" });
expect(await getInvite(kv, token)).toBeNull(); expect(await getInvite(kv, token)).toBeNull();
@ -84,9 +104,20 @@ describe("invites", () => {
it("upgrades an existing viewer to admin", async () => { it("upgrades an existing viewer to admin", async () => {
await saveGroups(kv, [ await saveGroups(kv, [
{ ...group, members: [{ login: "boss", role: "owner" }, { login: "newbie", role: "viewer" }] }, {
...group,
members: [
{ login: "boss", role: "owner" },
{ login: "newbie", role: "viewer" },
],
},
]); ]);
const token = await createInvite(kv, { groupId: "team", role: "admin", expiresAt: Date.now() + 86400_000, createdBy: "boss" }); const token = await createInvite(kv, {
groupId: "team",
role: "admin",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
await acceptInvite(kv, token, "777", "newbie"); await acceptInvite(kv, token, "777", "newbie");
const groups = await loadGroups(kv); const groups = await loadGroups(kv);
expect(groups[0]!.members).toContainEqual({ login: "newbie", role: "admin" }); expect(groups[0]!.members).toContainEqual({ login: "newbie", role: "admin" });
@ -94,14 +125,24 @@ describe("invites", () => {
it("rejects expired or unknown invites", async () => { it("rejects expired or unknown invites", async () => {
await saveGroups(kv, [group]); await saveGroups(kv, [group]);
const token = await createInvite(kv, { groupId: "team", role: "viewer", expiresAt: Date.now() - 1000, createdBy: "boss" }); const token = await createInvite(kv, {
groupId: "team",
role: "viewer",
expiresAt: Date.now() - 1000,
createdBy: "boss",
});
expect(await acceptInvite(kv, token, "1", "x")).toEqual({ ok: false, reason: "invalid" }); expect(await acceptInvite(kv, token, "1", "x")).toEqual({ ok: false, reason: "invalid" });
expect(await acceptInvite(kv, "deadbeef", "1", "x")).toEqual({ ok: false, reason: "invalid" }); expect(await acceptInvite(kv, "deadbeef", "1", "x")).toEqual({ ok: false, reason: "invalid" });
}); });
it("rejects invites for missing groups", async () => { it("rejects invites for missing groups", async () => {
await saveGroups(kv, []); await saveGroups(kv, []);
const token = await createInvite(kv, { groupId: "ghost", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" }); const token = await createInvite(kv, {
groupId: "ghost",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
expect(await acceptInvite(kv, token, "1", "x")).toEqual({ ok: false, reason: "group-missing" }); expect(await acceptInvite(kv, token, "1", "x")).toEqual({ ok: false, reason: "group-missing" });
}); });
}); });

View file

@ -15,7 +15,8 @@ export interface AuditEntry {
ip?: string; ip?: string;
} }
const COLUMNS = "id, ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip"; const COLUMNS =
"id, ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip";
interface AuditRow { interface AuditRow {
id: number; id: number;

View file

@ -148,14 +148,7 @@ export function createActionRoutes(): Hono<AuthEnv> {
repo: body.repo, repo: body.repo,
issue_number: body.issueNumber, issue_number: body.issueNumber,
content: body.reaction as content: body.reaction as
| "+1" "+1" | "-1" | "laugh" | "confused" | "heart" | "hooray" | "rocket" | "eyes",
| "-1"
| "laugh"
| "confused"
| "heart"
| "hooray"
| "rocket"
| "eyes",
}); });
} catch (err) { } catch (err) {
log.error({ err }, "Failed to create reaction"); log.error({ err }, "Failed to create reaction");

View file

@ -292,10 +292,7 @@ function ownerCount(members: GroupMember[]): number {
} }
/** Route params are always present for matched paths; keeps Hono's loose typing honest. */ /** Route params are always present for matched paths; keeps Hono's loose typing honest. */
function param( function param(c: { req: { param: (name: string) => string | undefined } }, name: string): string {
c: { req: { param: (name: string) => string | undefined } },
name: string,
): string {
return c.req.param(name) ?? ""; return c.req.param(name) ?? "";
} }
@ -329,7 +326,9 @@ export function createAdminRoutes(): Hono<AuthEnv> {
if (!token) return c.redirect("/admin"); if (!token) return c.redirect("/admin");
const auth = c.get("auth"); const auth = c.get("auth");
if (!auth) { if (!auth) {
return c.redirect(`/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`); return c.redirect(
`/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`,
);
} }
const result = await acceptInvite(c.env.KV, token, auth.session.userId, auth.session.login); const result = await acceptInvite(c.env.KV, token, auth.session.userId, auth.session.login);
if (result.ok) { if (result.ok) {
@ -411,10 +410,7 @@ export function createAdminRoutes(): Hono<AuthEnv> {
); );
const otherOwner = ownerCount(members) > 1; const otherOwner = ownerCount(members) > 1;
if (!stillMine && !otherOwner) { if (!stillMine && !otherOwner) {
return c.json( return c.json({ error: `group "${g.id}" would be left without an owner by you` }, 403);
{ error: `group "${g.id}" would be left without an owner by you` },
403,
);
} }
} }
nextAll = [ nextAll = [
@ -453,7 +449,8 @@ export function createAdminRoutes(): Hono<AuthEnv> {
if (prev.emoji !== g.emoji) fields.push("emoji"); if (prev.emoji !== g.emoji) fields.push("emoji");
if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers"); if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers");
if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners"); if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners");
if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members)) fields.push("members"); if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members))
fields.push("members");
if (fields.length > 0) { if (fields.length > 0) {
await recordAudit(c.env.DB, { await recordAudit(c.env.DB, {
ts: Date.now(), ts: Date.now(),

View file

@ -62,9 +62,7 @@ export function currentAuth(c: Context<AuthEnv>): AuthContext {
return c.get(AUTH_KEY); return c.get(AUTH_KEY);
} }
export type GroupAccess = export type GroupAccess = { ok: true; group: Group } | { ok: false; status: 403 | 404 };
| { ok: true; group: Group }
| { ok: false; status: 403 | 404 };
/** Resolves the group and checks the user can at least view it. */ /** Resolves the group and checks the user can at least view it. */
export function requireGroup(c: Context<AuthEnv>, groupId: string): GroupAccess { export function requireGroup(c: Context<AuthEnv>, groupId: string): GroupAccess {
@ -95,7 +93,9 @@ export function requireGroupRole(
export { canEditGroup, canEditRoutes, roleAt }; export { canEditGroup, canEditRoutes, roleAt };
/** Best-effort client IP for audit entries (Cloudflare header first). */ /** Best-effort client IP for audit entries (Cloudflare header first). */
export function clientIp(c: { req: { header: (n: string) => string | undefined } }): string | undefined { export function clientIp(c: {
req: { header: (n: string) => string | undefined };
}): string | undefined {
return c.req.header("cf-connecting-ip") ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim(); return c.req.header("cf-connecting-ip") ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim();
} }