ework-web 0.10.25 → 0.10.27

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.25",
3
+ "version": "0.10.27",
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",
@@ -0,0 +1,81 @@
1
+ import { provisionUser, type UserRow } from "./store";
2
+
3
+ export interface AuthHookResult {
4
+ /** Must match /^[A-Za-z0-9_-]{1,64}$/ — enforced for cookie-safety. */
5
+ login: string;
6
+ /** If true, user is promoted to site-admin (one-way: never demotes). */
7
+ isAdmin?: boolean;
8
+ /** User kind for auto-provisioning. Defaults to "human". */
9
+ kind?: "human" | "bot" | "system";
10
+ }
11
+
12
+ export interface AuthProvider {
13
+ /**
14
+ * Return user identity or null. Do NOT throw on auth failure —
15
+ * return null. Exceptions are caught and logged by the caller.
16
+ */
17
+ authenticate(req: Request): Promise<AuthHookResult | null>;
18
+ }
19
+
20
+ const LOGIN_RE = /^[A-Za-z0-9_-]{1,64}$/;
21
+ const hookCache = new Map<string, AuthProvider | null>();
22
+
23
+ async function loadHook(hookPath: string): Promise<AuthProvider | null> {
24
+ const cached = hookCache.get(hookPath);
25
+ if (cached !== undefined) return cached;
26
+
27
+ try {
28
+ const path = await import("node:path");
29
+ const { pathToFileURL } = await import("node:url");
30
+ const abs = path.isAbsolute(hookPath) ? hookPath : path.resolve(process.cwd(), hookPath);
31
+ const mod = await import(pathToFileURL(abs).href);
32
+ const provider: AuthProvider = mod.default ?? mod;
33
+ if (!provider || typeof provider.authenticate !== "function") {
34
+ console.warn(`[auth-hook] ${hookPath}: must export an object with authenticate()`);
35
+ hookCache.set(hookPath, null);
36
+ return null;
37
+ }
38
+ hookCache.set(hookPath, provider);
39
+ return provider;
40
+ } catch (e) {
41
+ console.warn(`[auth-hook] failed to load ${hookPath}:`, e);
42
+ hookCache.set(hookPath, null);
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export async function runAuthHook(hookPath: string, req: Request): Promise<UserRow | null> {
48
+ if (!hookPath) return null;
49
+
50
+ const provider = await loadHook(hookPath);
51
+ if (!provider) return null;
52
+
53
+ let result: AuthHookResult | null;
54
+ try {
55
+ result = await provider.authenticate(req);
56
+ } catch (e) {
57
+ console.warn(`[auth-hook] ${hookPath}: authenticate() threw:`, e);
58
+ return null;
59
+ }
60
+
61
+ if (!result?.login) return null;
62
+
63
+ // v2 cookies split on "." — LOGIN_RE prevents dots/special chars that
64
+ // would break the split or enable cookie injection.
65
+ if (!LOGIN_RE.test(result.login)) {
66
+ console.warn(`[auth-hook] ${hookPath}: invalid login format (must match ${LOGIN_RE})`);
67
+ return null;
68
+ }
69
+
70
+ const user = await provisionUser(result.login, {
71
+ kind: result.kind ?? "human",
72
+ isAdmin: result.isAdmin ?? false,
73
+ });
74
+
75
+ if (!user.is_active) return null;
76
+ return user;
77
+ }
78
+
79
+ export function clearAuthHookCache(): void {
80
+ hookCache.clear();
81
+ }
package/src/auth.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Config } from "./config";
2
2
  import { getUserByLogin, ensureUser, verifyPat, type UserRow } from "./store";
3
+ import { runAuthHook } from "./auth-hook";
3
4
 
4
5
  // Per-user token-cookie auth. Cookie value is HMAC-signed and carries login +
5
6
  // issued-at, so the server is stateless (no session table). Two cookie formats
@@ -135,6 +136,18 @@ export async function checkAuth(req: Request, cfg: Config, ip?: string | null):
135
136
  }
136
137
  }
137
138
 
139
+ // Internal auth hook (daemon / machine-to-machine — permanent credentials).
140
+ if (cfg.internalAuthHook) {
141
+ const user = await runAuthHook(cfg.internalAuthHook, req);
142
+ if (user) return { ok: true, user };
143
+ }
144
+
145
+ // User auth hook (human users — may expire, e.g. SSO/OAuth sessions).
146
+ if (cfg.userAuthHook) {
147
+ const user = await runAuthHook(cfg.userAuthHook, req);
148
+ if (user) return { ok: true, user };
149
+ }
150
+
138
151
  return { ok: false, user: null };
139
152
  }
140
153
 
package/src/config.ts CHANGED
@@ -79,6 +79,8 @@ export const configSchema = z.object({
79
79
  daemonWebhookUrl: z.string().default(""),
80
80
  daemonWebhookSecret: z.string().default(""),
81
81
  routerAdminToken: z.string().default(""),
82
+ internalAuthHook: z.string().default(""),
83
+ userAuthHook: z.string().default(""),
82
84
  // Default "provider/model" string passed to `opencode run --model <X>`.
83
85
  // Empty = let opencode pick per its own opencode.json + env. ework-daemon
84
86
  // pushes this (or the per-project override) on every spawn to defend
@@ -178,7 +180,9 @@ export async function loadConfig(): Promise<Config> {
178
180
  daemonBotLogin: process.env.WORK_DAEMON_BOT_LOGIN ?? "",
179
181
  daemonWebhookUrl: process.env.WORK_DAEMON_WEBHOOK_URL ?? "",
180
182
  daemonWebhookSecret: process.env.WORK_DAEMON_WEBHOOK_SECRET ?? "",
181
- routerAdminToken: process.env.WORK_ROUTER_ADMIN_TOKEN ?? "",
183
+ routerAdminToken: process.env.WORK_ROUTER_ADMIN_TOKEN ?? "",
184
+ internalAuthHook: process.env.WORK_INTERNAL_AUTH_HOOK ?? "",
185
+ userAuthHook: process.env.WORK_USER_AUTH_HOOK ?? "",
182
186
  defaultModel: db.defaultModel ?? process.env.WORK_DEFAULT_MODEL,
183
187
  autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
184
188
  webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
package/src/index.ts CHANGED
@@ -627,8 +627,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
627
627
  const desc = url.searchParams.get("asc") !== "1";
628
628
  const all = url.searchParams.get("all") === "1";
629
629
  const limit = Math.min(5000, Math.max(1, Number(url.searchParams.get("limit")) || 30));
630
+ const daemonEp = url.searchParams.get("daemon");
631
+ const client = daemonEp ? new RemoteOpencodeClient(daemonEp) : opencode;
630
632
  try {
631
- const { html: body } = await buildSessionView(opencode, sid, desc, cfg.collapseLines, limit, all);
633
+ const { html: body } = await buildSessionView(client, sid, desc, cfg.collapseLines, limit, all);
632
634
  return html(body);
633
635
  } catch (e) {
634
636
  const status = e instanceof OpencodeError ? e.status : 500;
package/src/store.ts CHANGED
@@ -138,6 +138,22 @@ export async function ensureUser(login: string, kind: UserKind = "human"): Promi
138
138
  };
139
139
  }
140
140
 
141
+ export async function provisionUser(
142
+ login: string,
143
+ opts: { kind?: UserKind; isAdmin?: boolean },
144
+ ): Promise<UserRow> {
145
+ const user = await ensureUser(login, opts.kind ?? "human");
146
+ if (opts.isAdmin && !user.is_admin) {
147
+ const db = getDB();
148
+ await db.run("UPDATE {{users}} SET is_admin = 1, updated_at = ? WHERE login = ?", [
149
+ now(),
150
+ login,
151
+ ]);
152
+ return { ...user, is_admin: 1 };
153
+ }
154
+ return user;
155
+ }
156
+
141
157
  export async function getProject(owner: string, name: string): Promise<ProjectRow | null> {
142
158
  return (await getDB().get<ProjectRow>("SELECT * FROM {{projects}} WHERE owner = ? AND name = ?", [owner, name])) ?? null;
143
159
  }