ework-web 0.10.60 → 0.10.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.60",
3
+ "version": "0.10.62",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
package/src/giteaApi.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // bg audit) plus a few cheap stubs (/version, /user, /repos/:o/:r) that
13
13
  // Gitea clients commonly hit during connection bootstrap.
14
14
 
15
- import { getDB } from "./db";
15
+ import { getDB, getConfigAll } from "./db";
16
16
  import {
17
17
  StoreError,
18
18
  getProject,
@@ -32,6 +32,8 @@ import {
32
32
  listReactionsFor,
33
33
  canWriteProject,
34
34
  canReadProject,
35
+ ensureUser,
36
+ getUserByLogin,
35
37
  type UserRow,
36
38
  } from "./store";
37
39
  import {
@@ -95,6 +97,12 @@ function asContent(v: unknown): string | undefined {
95
97
  return trimmed;
96
98
  }
97
99
 
100
+ async function canSudo(u: UserRow): Promise<boolean> {
101
+ const cfg = await getConfigAll();
102
+ const allow = (cfg["sudoLogins"] ?? "").split(",").map((s: string) => s.trim()).filter(Boolean);
103
+ return allow.includes(u.login);
104
+ }
105
+
98
106
  export async function handleGiteaApi(
99
107
  req: Request,
100
108
  url: URL,
@@ -103,8 +111,30 @@ export async function handleGiteaApi(
103
111
  const path = url.pathname;
104
112
  if (!path.startsWith("/api/v1/")) return null;
105
113
  const origin = new URL(req.url).origin;
106
- const user = ctx.user;
107
- if (!user) return giteaError(401, "requires authentication");
114
+ const caller = ctx.user;
115
+ if (!caller) return giteaError(401, "requires authentication");
116
+
117
+ // ─── Sudo: Gitea convention for impersonation ───
118
+ // Allows bridge/integration processes to post on behalf of real users.
119
+ // Permission gated by config KV `sudoLogins` whitelist (not is_admin — bridges
120
+ // should not be admins). Sudo-Kind header controls kind for first-creation
121
+ // only; existing users' kind is never changed via sudo.
122
+ const sudoLoginRaw = req.headers.get("Sudo") ?? url.searchParams.get("sudo");
123
+ const sudoLogin = sudoLoginRaw?.trim() || null;
124
+ let user: UserRow = caller;
125
+ if (sudoLogin && sudoLogin !== caller.login) {
126
+ if (!(await canSudo(caller))) return giteaError(403, "sudo requires permission");
127
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(sudoLogin)) return giteaError(400, "invalid sudo login");
128
+ const sudoKindRaw = req.headers.get("Sudo-Kind")?.trim();
129
+ const existing = await getUserByLogin(sudoLogin);
130
+ if (existing) {
131
+ user = existing;
132
+ } else {
133
+ const kind = sudoKindRaw === "bot" || sudoKindRaw === "system" ? sudoKindRaw : "human";
134
+ user = await ensureUser(sudoLogin, kind);
135
+ }
136
+ if (!user.is_active) return giteaError(403, "sudo target inactive");
137
+ }
108
138
 
109
139
  if (ROUTES.version.test(path) && req.method === "GET") {
110
140
  return { status: 200, body: { version: "1.22.0" } };
package/src/index.ts CHANGED
@@ -1311,6 +1311,22 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1311
1311
  return json({ ok: true });
1312
1312
  }
1313
1313
 
1314
+ if (url.pathname === "/api/sudo-policy" && req.method === "GET") {
1315
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1316
+ const cfg = await getConfigAll();
1317
+ const list = (k: string, d: string) => (cfg[k] ?? d).split(",").map((s) => s.trim()).filter(Boolean);
1318
+ return json({ sudoLogins: list("sudoLogins", "") });
1319
+ }
1320
+
1321
+ if (url.pathname === "/api/sudo-policy" && req.method === "POST") {
1322
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1323
+ const payload = await req.json().catch(() => ({} as Record<string, unknown>));
1324
+ const join = (v: unknown) => Array.isArray(v) ? v.filter((s) => typeof s === "string").join(",") : (typeof v === "string" ? v : "");
1325
+ const sl = join((payload as { sudoLogins?: unknown }).sudoLogins);
1326
+ if (sl) await setConfig("sudoLogins", sl); else await deleteConfig("sudoLogins");
1327
+ return json({ ok: true });
1328
+ }
1329
+
1314
1330
  if (url.pathname === "/api/router/daemons" && req.method === "GET") {
1315
1331
  if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1316
1332
  try {
package/src/webhooks.ts CHANGED
@@ -696,11 +696,15 @@ export async function emitCommentEvent(
696
696
  log.info(`webhook: issue dispatch disabled — skipping comment_created (author=${comment.author}) for ${scopeKey}#${issue.number}`);
697
697
  return;
698
698
  }
699
- // Global/project-level are "default no auto-dispatch" strategies — comments can still explicitly wake AI.
700
- if (cfg["dispatchEnabled"] === "false" || cfg[`dispatchOff:${scopeKey}`] === "1") {
701
- log.info(`webhook: dispatch disabled but waking via comment_created (author=${comment.author}) for ${scopeKey}#${issue.number}`);
702
- }
703
699
  const authorUser = await getUserByLogin(comment.author);
700
+ const dispatchOff = cfg["dispatchEnabled"] === "false" || cfg[`dispatchOff:${scopeKey}`] === "1";
701
+ if (dispatchOff && authorUser && authorUser.kind !== "human") {
702
+ log.info(`webhook: dispatch disabled — non-human comment blocked (author=${comment.author} kind=${authorUser.kind}) for ${scopeKey}#${issue.number}`);
703
+ return;
704
+ }
705
+ if (dispatchOff) {
706
+ log.info(`webhook: dispatch disabled but human comment waking AI (author=${comment.author}) for ${scopeKey}#${issue.number}`);
707
+ }
704
708
  const appCfg = await loadConfig();
705
709
  const cfgList = (k: string, d: string) =>
706
710
  (cfg[k] ?? d).split(",").map((s) => s.trim()).filter(Boolean);