@hiroleague/taskmanager 0.0.1 → 0.0.2

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.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -52
  3. package/dist/assets/architecture-YZFGNWBL-C1MoQeSs.js +1 -0
  4. package/dist/assets/{architectureDiagram-Q4EWVU46-DSQ1_74_.js → architectureDiagram-Q4EWVU46-DUEfvDBu.js} +1 -1
  5. package/dist/assets/{blockDiagram-DXYQGD6D-DfOGNphI.js → blockDiagram-DXYQGD6D-DQzEOPT2.js} +1 -1
  6. package/dist/assets/{chunk-2KRD3SAO-9yt00aGC.js → chunk-2KRD3SAO-C2e-_49I.js} +1 -1
  7. package/dist/assets/{chunk-4TB4RGXK-DF8yJBFl.js → chunk-4TB4RGXK-AZq3s1Dh.js} +1 -1
  8. package/dist/assets/{chunk-67CJDMHE-5wFKo04G.js → chunk-67CJDMHE-B1-M78qu.js} +1 -1
  9. package/dist/assets/{chunk-7N4EOEYR-BRRGX_NC.js → chunk-7N4EOEYR-D7mYFpz-.js} +1 -1
  10. package/dist/assets/{chunk-AA7GKIK3-DUZv_pNI.js → chunk-AA7GKIK3-VWI9k39i.js} +1 -1
  11. package/dist/assets/{chunk-CIAEETIT-mA5aM_d7.js → chunk-CIAEETIT-hnu4zamm.js} +1 -1
  12. package/dist/assets/{chunk-FOC6F5B3-B-cqGCPC.js → chunk-FOC6F5B3-BJsh9nO9.js} +1 -1
  13. package/dist/assets/{chunk-K5T4RW27-BLRDzioh.js → chunk-K5T4RW27-BLIPdXaZ.js} +1 -1
  14. package/dist/assets/{chunk-KGLVRYIC-CTkQSeKy.js → chunk-KGLVRYIC-DvaW2TkT.js} +1 -1
  15. package/dist/assets/{chunk-LIHQZDEY-Cf34Nu3J.js → chunk-LIHQZDEY-CUsM0M11.js} +1 -1
  16. package/dist/assets/{chunk-ORNJ4GCN-D3uXgbay.js → chunk-ORNJ4GCN-CfluNV0_.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-syQho5jf.js → chunk-OYMX7WX6-CkWzw4JX.js} +1 -1
  18. package/dist/assets/{classDiagram-6PBFFD2Q-CotFZI8-.js → classDiagram-6PBFFD2Q-Dx_f-9b7.js} +1 -1
  19. package/dist/assets/{classDiagram-v2-HSJHXN6E-DAPzeDGn.js → classDiagram-v2-HSJHXN6E-CSfvZ-nt.js} +1 -1
  20. package/dist/assets/clone-CXokakwV.js +1 -0
  21. package/dist/assets/{dagre-rhyPjnsQ.js → dagre-Do0eD9eI.js} +1 -1
  22. package/dist/assets/{dagre-KV5264BT-BBqulDtd.js → dagre-KV5264BT-lveZDhBf.js} +1 -1
  23. package/dist/assets/{diagram-5BDNPKRD-Ky3EXXj0.js → diagram-5BDNPKRD-Dq5yM_uY.js} +1 -1
  24. package/dist/assets/{diagram-G4DWMVQ6-t7LbT0Uz.js → diagram-G4DWMVQ6-D-SYOmKm.js} +1 -1
  25. package/dist/assets/{diagram-MMDJMWI5-CdnLXEMx.js → diagram-MMDJMWI5-lU5t9BZA.js} +1 -1
  26. package/dist/assets/{diagram-TYMM5635-CnzTqJBM.js → diagram-TYMM5635-6tfUbY3R.js} +1 -1
  27. package/dist/assets/{erDiagram-SMLLAGMA-BN5eJerP.js → erDiagram-SMLLAGMA-dx09stuy.js} +1 -1
  28. package/dist/assets/{flatten-C5NL-f24.js → flatten-B2BZ0pzY.js} +1 -1
  29. package/dist/assets/{flowDiagram-DWJPFMVM-CbFskc8S.js → flowDiagram-DWJPFMVM-CJi2WISS.js} +1 -1
  30. package/dist/assets/gitGraph-7Q5UKJZL-BXTuQaDM.js +1 -0
  31. package/dist/assets/{gitGraphDiagram-UUTBAWPF-wpqI2kyI.js → gitGraphDiagram-UUTBAWPF-Bjj94M12.js} +1 -1
  32. package/dist/assets/{graphlib-COiJG5Qv.js → graphlib-BIlXYGdM.js} +1 -1
  33. package/dist/assets/{index-lyyIVcc_.js → index-CZZuue3D.js} +5 -5
  34. package/dist/assets/info-OMHHGYJF-BeeKt8-X.js +1 -0
  35. package/dist/assets/{infoDiagram-42DDH7IO-BbvTdpSV.js → infoDiagram-42DDH7IO-wq_opQKO.js} +1 -1
  36. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Epc23N_0.js → ishikawaDiagram-UXIWVN3A-Cnc1bwBo.js} +1 -1
  37. package/dist/assets/{kanban-definition-6JOO6SKY-C8dW_26n.js → kanban-definition-6JOO6SKY-CwHbIze0.js} +1 -1
  38. package/dist/assets/{mermaid-parser.core-6Tn8epr_.js → mermaid-parser.core-DrLhKJ48.js} +2 -2
  39. package/dist/assets/{mindmap-definition-QFDTVHPH-CvpNtrKT.js → mindmap-definition-QFDTVHPH-DswAJiEd.js} +1 -1
  40. package/dist/assets/packet-4T2RLAQJ-DQ-H9_jd.js +1 -0
  41. package/dist/assets/pie-ZZUOXDRM-BSj0Jsyj.js +1 -0
  42. package/dist/assets/{pieDiagram-DEJITSTG-eENymoXZ.js → pieDiagram-DEJITSTG-DgQTCddl.js} +1 -1
  43. package/dist/assets/radar-PYXPWWZC-B7-oRPFL.js +1 -0
  44. package/dist/assets/{reduce-BDOBPIXr.js → reduce-Uumu9GdR.js} +1 -1
  45. package/dist/assets/{requirementDiagram-MS252O5E-CmRO3hLp.js → requirementDiagram-MS252O5E-D1moa23Z.js} +1 -1
  46. package/dist/assets/{sequenceDiagram-FGHM5R23-B7qNcwNo.js → sequenceDiagram-FGHM5R23-Dvhj7HGn.js} +1 -1
  47. package/dist/assets/{stateDiagram-FHFEXIEX-CYfGMoR8.js → stateDiagram-FHFEXIEX-Dx5CjenB.js} +1 -1
  48. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-CO1W_n55.js → stateDiagram-v2-QKLJ7IA2-C_PkrTdc.js} +1 -1
  49. package/dist/assets/{timeline-definition-GMOUNBTQ-CQWqDPGG.js → timeline-definition-GMOUNBTQ-z-IncVmK.js} +1 -1
  50. package/dist/assets/treeView-SZITEDCU-CFXle9Az.js +1 -0
  51. package/dist/assets/treemap-W4RFUUIX-CAW3vWh8.js +1 -0
  52. package/dist/assets/{vennDiagram-DHZGUBPP-BjTbuhcb.js → vennDiagram-DHZGUBPP-CT1ehozU.js} +1 -1
  53. package/dist/assets/wardley-RL74JXVD-7q3ju4kc.js +1 -0
  54. package/dist/assets/{wardleyDiagram-NUSXRM2D-DNhPIFCg.js → wardleyDiagram-NUSXRM2D-D-kouujI.js} +1 -1
  55. package/dist/assets/{xychartDiagram-5P7HB3ND-BDblAZ11.js → xychartDiagram-5P7HB3ND-D1lnM0pL.js} +1 -1
  56. package/dist/index.html +1 -1
  57. package/package.json +101 -92
  58. package/scripts/postinstall-message.mjs +160 -0
  59. package/scripts/stubs/node-domexception/index.cjs +18 -0
  60. package/scripts/stubs/node-domexception/package.json +7 -0
  61. package/skills/hiro-task-manager-cli/SKILL.md +97 -0
  62. package/skills/hiro-task-manager-cli/reference/boards.md +143 -0
  63. package/skills/hiro-task-manager-cli/reference/cli-access-policy.md +72 -0
  64. package/skills/hiro-task-manager-cli/reference/errors.md +85 -0
  65. package/skills/hiro-task-manager-cli/reference/lists.md +106 -0
  66. package/skills/hiro-task-manager-cli/reference/releases.md +87 -0
  67. package/skills/hiro-task-manager-cli/reference/search.md +38 -0
  68. package/skills/hiro-task-manager-cli/reference/statuses.md +25 -0
  69. package/skills/hiro-task-manager-cli/reference/tasks.md +144 -0
  70. package/skills/hiro-task-manager-cli/reference/trash.md +50 -0
  71. package/src/cli/bootstrap/launcher.test.ts +66 -0
  72. package/src/cli/bootstrap/launcher.ts +375 -35
  73. package/src/cli/bootstrap/program.ts +4 -0
  74. package/src/cli/bootstrap/runtime.test.ts +15 -0
  75. package/src/cli/bootstrap/runtime.ts +27 -1
  76. package/src/cli/commands/query.ts +56 -56
  77. package/src/cli/commands/server.ts +27 -19
  78. package/src/cli/handlers/boards.test.ts +669 -669
  79. package/src/cli/handlers/cli-wiring.test.ts +1 -1
  80. package/src/cli/handlers/search.test.ts +374 -374
  81. package/src/cli/handlers/search.ts +17 -17
  82. package/src/cli/handlers/server.test.ts +55 -13
  83. package/src/cli/handlers/server.ts +16 -3
  84. package/src/cli/lib/api-client.test.ts +35 -2
  85. package/src/cli/lib/api-client.ts +43 -10
  86. package/src/cli/lib/cli-http-errors.test.ts +85 -85
  87. package/src/cli/lib/command-helpers.ts +161 -154
  88. package/src/cli/lib/config.ts +4 -0
  89. package/src/cli/lib/launcherUi.ts +166 -0
  90. package/src/cli/lib/process.test.ts +24 -5
  91. package/src/cli/lib/process.ts +86 -55
  92. package/src/cli/ports/process.ts +8 -2
  93. package/src/cli/subprocess.real-stack.test.ts +611 -598
  94. package/src/cli/subprocess.smoke.test.ts +954 -969
  95. package/src/cli/types/config.ts +2 -6
  96. package/src/client/components/auth/AuthScreen.tsx +3 -3
  97. package/src/client/components/board/BoardStatsChips.tsx +233 -233
  98. package/src/client/components/board/BoardStatsContext.tsx +41 -41
  99. package/src/client/components/board/boardHeaderButtonStyles.ts +38 -38
  100. package/src/client/components/board/shortcuts/useBoardShortcutKeydown.ts +49 -49
  101. package/src/client/components/board/useBoardCanvasPanScroll.ts +108 -108
  102. package/src/client/components/board/useBoardTaskContainerDroppableReact.ts +33 -33
  103. package/src/client/components/board/useBoardTaskSortableReact.ts +26 -26
  104. package/src/client/components/multi-select.tsx +1206 -1206
  105. package/src/client/components/routing/BoardPage.tsx +20 -20
  106. package/src/client/components/routing/NavigationRegistrar.tsx +13 -13
  107. package/src/client/components/settings/SettingsPage.tsx +1 -1
  108. package/src/client/components/task/TaskCard.tsx +643 -643
  109. package/src/client/components/ui/badge.tsx +49 -49
  110. package/src/client/components/ui/button.tsx +65 -65
  111. package/src/client/components/ui/command.tsx +193 -193
  112. package/src/client/components/ui/dialog.tsx +163 -163
  113. package/src/client/components/ui/input-group.tsx +155 -155
  114. package/src/client/components/ui/input.tsx +19 -19
  115. package/src/client/components/ui/popover.tsx +87 -87
  116. package/src/client/components/ui/separator.tsx +28 -28
  117. package/src/client/components/ui/textarea.tsx +18 -18
  118. package/src/client/index.css +248 -248
  119. package/src/client/lib/appNavigate.ts +16 -16
  120. package/src/client/lib/taskCardDate.ts +111 -111
  121. package/src/client/lib/utils.ts +6 -6
  122. package/src/server/auth.ts +351 -302
  123. package/src/server/bootstrapDev.ts +11 -2
  124. package/src/server/bootstrapInstalled.ts +6 -1
  125. package/src/server/index.ts +33 -7
  126. package/src/server/migrations/013_cli_policy_and_provenance.ts +2 -2
  127. package/src/server/migrations/019_cli_global_create_board_default_on.ts +14 -0
  128. package/src/server/migrations/registry.ts +43 -41
  129. package/src/server/parseBootstrapProfile.ts +42 -0
  130. package/src/server/storage/cliPolicy.ts +2 -1
  131. package/src/shared/runtimeConfig.ts +256 -237
  132. package/src/shared/runtimeIdentity.test.ts +47 -0
  133. package/src/shared/runtimeIdentity.ts +35 -0
  134. package/src/shared/serverStatus.ts +21 -0
  135. package/src/shared/skillsInstall.ts +71 -0
  136. package/src/shared/terminalColors.ts +24 -0
  137. package/dist/assets/architecture-YZFGNWBL-3h1eIYfB.js +0 -1
  138. package/dist/assets/clone-BRQpYu_n.js +0 -1
  139. package/dist/assets/gitGraph-7Q5UKJZL-CG8f8JF7.js +0 -1
  140. package/dist/assets/info-OMHHGYJF-C8_SHoRO.js +0 -1
  141. package/dist/assets/packet-4T2RLAQJ-BvpAX0kJ.js +0 -1
  142. package/dist/assets/pie-ZZUOXDRM-Ow26Yf-E.js +0 -1
  143. package/dist/assets/radar-PYXPWWZC-e_ron5jQ.js +0 -1
  144. package/dist/assets/treeView-SZITEDCU-DsEr3xeq.js +0 -1
  145. package/dist/assets/treemap-W4RFUUIX-DV7nk2AB.js +0 -1
  146. package/dist/assets/wardley-RL74JXVD-CrrFU9AE.js +0 -1
@@ -1,302 +1,351 @@
1
- import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
- import path from "node:path";
3
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
4
- import type { Context, MiddlewareHandler } from "hono";
5
- import { deleteCookie, getCookie, setCookie } from "hono/cookie";
6
- import {
7
- AUTH_SESSION_COOKIE_NAME,
8
- type AuthPrincipalType,
9
- type AuthSessionResponse,
10
- } from "../shared/auth";
11
- import { resolveAuthDir } from "../shared/runtimeConfig";
12
-
13
- interface StoredAuthState {
14
- version: 1;
15
- initializedAt: string;
16
- passphraseHash: string;
17
- recoveryKeyHash: string;
18
- activeSessionTokenHash: string | null;
19
- }
20
-
21
- export interface RequestAuthContext {
22
- initialized: boolean;
23
- principal: Exclude<AuthPrincipalType, "system">;
24
- authenticated: boolean;
25
- }
26
-
27
- export interface AppBindings {
28
- Variables: {
29
- auth: RequestAuthContext;
30
- };
31
- }
32
-
33
- // Hono enforces Max-Age ≤ 400 days (RFC-style cap); longer values throw at setCookie.
34
- const AUTH_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 400;
35
-
36
- let cachedAuthState: StoredAuthState | null | undefined;
37
-
38
- function resolveAuthRootDir(): string {
39
- return resolveAuthDir();
40
- }
41
-
42
- function resolveAuthFilePath(): string {
43
- return path.join(resolveAuthRootDir(), "auth.json");
44
- }
45
-
46
- async function applyOwnerOnlyPermissions(targetPath: string): Promise<void> {
47
- if (process.platform === "win32") return;
48
- try {
49
- await chmod(targetPath, 0o600);
50
- } catch {
51
- // Best effort only; some filesystems may reject chmod semantics.
52
- }
53
- }
54
-
55
- async function ensureAuthDir(): Promise<void> {
56
- const dir = resolveAuthRootDir();
57
- await mkdir(dir, { recursive: true });
58
- if (process.platform === "win32") return;
59
- try {
60
- await chmod(dir, 0o700);
61
- } catch {
62
- // Best effort only; some filesystems may reject chmod semantics.
63
- }
64
- }
65
-
66
- function sha256Hex(value: string): string {
67
- return createHash("sha256").update(value).digest("hex");
68
- }
69
-
70
- function constantTimeHexEquals(a: string, b: string): boolean {
71
- if (!a || !b || a.length !== b.length) return false;
72
- const left = Buffer.from(a, "hex");
73
- const right = Buffer.from(b, "hex");
74
- if (left.length !== right.length) return false;
75
- return timingSafeEqual(left, right);
76
- }
77
-
78
- function normalizeRecoveryKeyInput(value: string): string {
79
- return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
80
- }
81
-
82
- function createRecoveryKey(): string {
83
- const raw = randomBytes(16).toString("hex").toUpperCase();
84
- return raw.match(/.{1,4}/g)?.join("-") ?? raw;
85
- }
86
-
87
- function createSessionToken(): string {
88
- return randomBytes(32).toString("hex");
89
- }
90
-
91
- async function readStoredAuthStateFromDisk(): Promise<StoredAuthState | null> {
92
- const filePath = resolveAuthFilePath();
93
- try {
94
- const raw = await readFile(filePath, "utf8");
95
- const parsed = JSON.parse(raw) as Partial<StoredAuthState>;
96
- if (
97
- parsed?.version !== 1 ||
98
- typeof parsed.initializedAt !== "string" ||
99
- typeof parsed.passphraseHash !== "string" ||
100
- typeof parsed.recoveryKeyHash !== "string" ||
101
- !("activeSessionTokenHash" in parsed)
102
- ) {
103
- throw new Error("Invalid auth state");
104
- }
105
- return {
106
- version: 1,
107
- initializedAt: parsed.initializedAt,
108
- passphraseHash: parsed.passphraseHash,
109
- recoveryKeyHash: parsed.recoveryKeyHash,
110
- activeSessionTokenHash:
111
- typeof parsed.activeSessionTokenHash === "string"
112
- ? parsed.activeSessionTokenHash
113
- : null,
114
- };
115
- } catch (error) {
116
- if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
117
- return null;
118
- }
119
- throw error;
120
- }
121
- }
122
-
123
- async function loadStoredAuthState(): Promise<StoredAuthState | null> {
124
- if (cachedAuthState !== undefined) return cachedAuthState;
125
- cachedAuthState = await readStoredAuthStateFromDisk();
126
- return cachedAuthState;
127
- }
128
-
129
- async function writeStoredAuthState(next: StoredAuthState): Promise<void> {
130
- await ensureAuthDir();
131
- const filePath = resolveAuthFilePath();
132
- const tmpPath = `${filePath}.tmp`;
133
- const payload = `${JSON.stringify(next, null, 2)}\n`;
134
- await writeFile(tmpPath, payload, "utf8");
135
- await applyOwnerOnlyPermissions(tmpPath);
136
- await rename(tmpPath, filePath);
137
- await applyOwnerOnlyPermissions(filePath);
138
- cachedAuthState = next;
139
- }
140
-
141
- async function updateStoredAuthState(
142
- updater: (current: StoredAuthState) => StoredAuthState,
143
- ): Promise<StoredAuthState> {
144
- const current = await loadStoredAuthState();
145
- if (!current) {
146
- throw new Error("Auth not initialized");
147
- }
148
- const next = updater(current);
149
- await writeStoredAuthState(next);
150
- return next;
151
- }
152
-
153
- function isAuthRoute(pathname: string): boolean {
154
- return pathname === "/api/auth" || pathname.startsWith("/api/auth/");
155
- }
156
-
157
- function isSetupSafeRoute(pathname: string): boolean {
158
- return pathname === "/api/health" || isAuthRoute(pathname);
159
- }
160
-
161
- function buildLoginRequiredResponse(c: Context<AppBindings>): Response {
162
- return c.json({ error: "Login required", code: "auth_login_required" }, 401);
163
- }
164
-
165
- function buildSetupRequiredResponse(c: Context<AppBindings>): Response {
166
- return c.json({ error: "TaskManager setup required", code: "auth_setup_required" }, 503);
167
- }
168
-
169
- // Treat the browser session cookie as the real security boundary so spoofable client
170
- // headers only affect identity/provenance and never privileged access.
171
- export const authMiddleware: MiddlewareHandler<AppBindings> = async (c, next) => {
172
- const pathname = new URL(c.req.url).pathname;
173
- const stored = await loadStoredAuthState();
174
-
175
- if (!stored) {
176
- c.set("auth", {
177
- initialized: false,
178
- principal: "cli",
179
- authenticated: false,
180
- });
181
- if (isSetupSafeRoute(pathname)) {
182
- await next();
183
- return;
184
- }
185
- return buildSetupRequiredResponse(c);
186
- }
187
-
188
- const sessionToken = getCookie(c, AUTH_SESSION_COOKIE_NAME) ?? "";
189
- const authenticated =
190
- !!stored.activeSessionTokenHash &&
191
- sessionToken.length > 0 &&
192
- constantTimeHexEquals(sha256Hex(sessionToken), stored.activeSessionTokenHash);
193
-
194
- c.set("auth", {
195
- initialized: true,
196
- principal: authenticated ? "web" : "cli",
197
- authenticated,
198
- });
199
-
200
- await next();
201
- };
202
-
203
- export function getRequestAuthContext(c: Context<AppBindings>): RequestAuthContext {
204
- return c.get("auth");
205
- }
206
-
207
- export function requireWebSession(c: Context<AppBindings>): Response | undefined {
208
- const auth = getRequestAuthContext(c);
209
- if (!auth.initialized) return buildSetupRequiredResponse(c);
210
- if (!auth.authenticated) return buildLoginRequiredResponse(c);
211
- return undefined;
212
- }
213
-
214
- export async function getAuthSessionResponse(
215
- c: Context<AppBindings>,
216
- ): Promise<AuthSessionResponse> {
217
- const auth = getRequestAuthContext(c);
218
- return {
219
- initialized: auth.initialized,
220
- authenticated: auth.authenticated,
221
- };
222
- }
223
-
224
- export async function setupPassphrase(passphrase: string): Promise<void> {
225
- if (!passphrase) {
226
- throw new Error("Passphrase required");
227
- }
228
- const existing = await loadStoredAuthState();
229
- if (existing) {
230
- throw new Error("Auth already initialized");
231
- }
232
- const recoveryKey = createRecoveryKey();
233
- const next: StoredAuthState = {
234
- version: 1,
235
- initializedAt: new Date().toISOString(),
236
- passphraseHash: await Bun.password.hash(passphrase),
237
- recoveryKeyHash: sha256Hex(normalizeRecoveryKeyInput(recoveryKey)),
238
- activeSessionTokenHash: null,
239
- };
240
- await writeStoredAuthState(next);
241
- console.log("TaskManager recovery key (shown once):");
242
- console.log(recoveryKey);
243
- console.log("Save this recovery key somewhere safe outside the app.");
244
- }
245
-
246
- export async function loginWithPassphrase(passphrase: string): Promise<string | null> {
247
- const stored = await loadStoredAuthState();
248
- if (!stored) return null;
249
- const ok = await Bun.password.verify(passphrase, stored.passphraseHash);
250
- if (!ok) return null;
251
- const sessionToken = createSessionToken();
252
- await writeStoredAuthState({
253
- ...stored,
254
- activeSessionTokenHash: sha256Hex(sessionToken),
255
- });
256
- return sessionToken;
257
- }
258
-
259
- export async function resetPassphraseWithRecoveryKey(
260
- recoveryKey: string,
261
- nextPassphrase: string,
262
- ): Promise<boolean> {
263
- if (!nextPassphrase) {
264
- throw new Error("Passphrase required");
265
- }
266
- const stored = await loadStoredAuthState();
267
- if (!stored) return false;
268
- const recoveryKeyHash = sha256Hex(normalizeRecoveryKeyInput(recoveryKey));
269
- if (!constantTimeHexEquals(recoveryKeyHash, stored.recoveryKeyHash)) {
270
- return false;
271
- }
272
- await writeStoredAuthState({
273
- ...stored,
274
- passphraseHash: await Bun.password.hash(nextPassphrase),
275
- activeSessionTokenHash: null,
276
- });
277
- return true;
278
- }
279
-
280
- export async function clearActiveSession(): Promise<void> {
281
- await updateStoredAuthState((current) => ({
282
- ...current,
283
- activeSessionTokenHash: null,
284
- }));
285
- }
286
-
287
- export function setAuthSessionCookie(c: Context, sessionToken: string): void {
288
- const url = new URL(c.req.url);
289
- setCookie(c, AUTH_SESSION_COOKIE_NAME, sessionToken, {
290
- httpOnly: true,
291
- sameSite: "Lax",
292
- path: "/",
293
- maxAge: AUTH_COOKIE_MAX_AGE_SECONDS,
294
- secure: url.protocol === "https:",
295
- });
296
- }
297
-
298
- export function clearAuthSessionCookie(c: Context): void {
299
- deleteCookie(c, AUTH_SESSION_COOKIE_NAME, {
300
- path: "/",
301
- });
302
- }
1
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
4
+ import type { Context, MiddlewareHandler } from "hono";
5
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
6
+ import {
7
+ AUTH_SESSION_COOKIE_NAME,
8
+ type AuthPrincipalType,
9
+ type AuthSessionResponse,
10
+ } from "../shared/auth";
11
+ import { ansi, colorEnabled, paint } from "../shared/terminalColors";
12
+ import { resolveAuthDir } from "../shared/runtimeConfig";
13
+
14
+ interface StoredAuthState {
15
+ version: 1;
16
+ initializedAt: string;
17
+ passphraseHash: string;
18
+ recoveryKeyHash: string;
19
+ activeSessionTokenHash: string | null;
20
+ }
21
+
22
+ export interface RequestAuthContext {
23
+ initialized: boolean;
24
+ principal: Exclude<AuthPrincipalType, "system">;
25
+ authenticated: boolean;
26
+ }
27
+
28
+ export interface AppBindings {
29
+ Variables: {
30
+ auth: RequestAuthContext;
31
+ };
32
+ }
33
+
34
+ // Hono enforces Max-Age 400 days (RFC-style cap); longer values throw at setCookie.
35
+ const AUTH_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 400;
36
+
37
+ let cachedAuthState: StoredAuthState | null | undefined;
38
+
39
+ function resolveAuthRootDir(): string {
40
+ return resolveAuthDir();
41
+ }
42
+
43
+ function resolveAuthFilePath(): string {
44
+ return path.join(resolveAuthRootDir(), "auth.json");
45
+ }
46
+
47
+ async function applyOwnerOnlyPermissions(targetPath: string): Promise<void> {
48
+ if (process.platform === "win32") return;
49
+ try {
50
+ await chmod(targetPath, 0o600);
51
+ } catch {
52
+ // Best effort only; some filesystems may reject chmod semantics.
53
+ }
54
+ }
55
+
56
+ async function ensureAuthDir(): Promise<void> {
57
+ const dir = resolveAuthRootDir();
58
+ await mkdir(dir, { recursive: true });
59
+ if (process.platform === "win32") return;
60
+ try {
61
+ await chmod(dir, 0o700);
62
+ } catch {
63
+ // Best effort only; some filesystems may reject chmod semantics.
64
+ }
65
+ }
66
+
67
+ function sha256Hex(value: string): string {
68
+ return createHash("sha256").update(value).digest("hex");
69
+ }
70
+
71
+ function constantTimeHexEquals(a: string, b: string): boolean {
72
+ if (!a || !b || a.length !== b.length) return false;
73
+ const left = Buffer.from(a, "hex");
74
+ const right = Buffer.from(b, "hex");
75
+ if (left.length !== right.length) return false;
76
+ return timingSafeEqual(left, right);
77
+ }
78
+
79
+ function normalizeRecoveryKeyInput(value: string): string {
80
+ return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
81
+ }
82
+
83
+ function createRecoveryKey(): string {
84
+ const raw = randomBytes(16).toString("hex").toUpperCase();
85
+ return raw.match(/.{1,4}/g)?.join("-") ?? raw;
86
+ }
87
+
88
+ function createSessionToken(): string {
89
+ return randomBytes(32).toString("hex");
90
+ }
91
+
92
+ async function readStoredAuthStateFromDisk(): Promise<StoredAuthState | null> {
93
+ const filePath = resolveAuthFilePath();
94
+ try {
95
+ const raw = await readFile(filePath, "utf8");
96
+ const parsed = JSON.parse(raw) as Partial<StoredAuthState>;
97
+ if (
98
+ parsed?.version !== 1 ||
99
+ typeof parsed.initializedAt !== "string" ||
100
+ typeof parsed.passphraseHash !== "string" ||
101
+ typeof parsed.recoveryKeyHash !== "string" ||
102
+ !("activeSessionTokenHash" in parsed)
103
+ ) {
104
+ throw new Error("Invalid auth state");
105
+ }
106
+ return {
107
+ version: 1,
108
+ initializedAt: parsed.initializedAt,
109
+ passphraseHash: parsed.passphraseHash,
110
+ recoveryKeyHash: parsed.recoveryKeyHash,
111
+ activeSessionTokenHash:
112
+ typeof parsed.activeSessionTokenHash === "string"
113
+ ? parsed.activeSessionTokenHash
114
+ : null,
115
+ };
116
+ } catch (error) {
117
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
118
+ return null;
119
+ }
120
+ throw error;
121
+ }
122
+ }
123
+
124
+ async function loadStoredAuthState(): Promise<StoredAuthState | null> {
125
+ if (cachedAuthState !== undefined) return cachedAuthState;
126
+ cachedAuthState = await readStoredAuthStateFromDisk();
127
+ return cachedAuthState;
128
+ }
129
+
130
+ async function writeStoredAuthState(next: StoredAuthState): Promise<void> {
131
+ await ensureAuthDir();
132
+ const filePath = resolveAuthFilePath();
133
+ const tmpPath = `${filePath}.tmp`;
134
+ const payload = `${JSON.stringify(next, null, 2)}\n`;
135
+ await writeFile(tmpPath, payload, "utf8");
136
+ await applyOwnerOnlyPermissions(tmpPath);
137
+ await rename(tmpPath, filePath);
138
+ await applyOwnerOnlyPermissions(filePath);
139
+ cachedAuthState = next;
140
+ }
141
+
142
+ async function updateStoredAuthState(
143
+ updater: (current: StoredAuthState) => StoredAuthState,
144
+ ): Promise<StoredAuthState> {
145
+ const current = await loadStoredAuthState();
146
+ if (!current) {
147
+ throw new Error("Auth not initialized");
148
+ }
149
+ const next = updater(current);
150
+ await writeStoredAuthState(next);
151
+ return next;
152
+ }
153
+
154
+ function isAuthRoute(pathname: string): boolean {
155
+ return pathname === "/api/auth" || pathname.startsWith("/api/auth/");
156
+ }
157
+
158
+ function isSetupSafeRoute(pathname: string): boolean {
159
+ return pathname === "/api/health" || isAuthRoute(pathname);
160
+ }
161
+
162
+ function buildLoginRequiredResponse(c: Context<AppBindings>): Response {
163
+ return c.json({ error: "Login required", code: "auth_login_required" }, 401);
164
+ }
165
+
166
+ function buildSetupRequiredResponse(c: Context<AppBindings>): Response {
167
+ return c.json({ error: "TaskManager setup required", code: "auth_setup_required" }, 503);
168
+ }
169
+
170
+ // Treat the browser session cookie as the real security boundary so spoofable client
171
+ // headers only affect identity/provenance and never privileged access.
172
+ export const authMiddleware: MiddlewareHandler<AppBindings> = async (c, next) => {
173
+ const pathname = new URL(c.req.url).pathname;
174
+ const stored = await loadStoredAuthState();
175
+
176
+ if (!stored) {
177
+ c.set("auth", {
178
+ initialized: false,
179
+ principal: "cli",
180
+ authenticated: false,
181
+ });
182
+ if (isSetupSafeRoute(pathname)) {
183
+ await next();
184
+ return;
185
+ }
186
+ return buildSetupRequiredResponse(c);
187
+ }
188
+
189
+ const sessionToken = getCookie(c, AUTH_SESSION_COOKIE_NAME) ?? "";
190
+ const authenticated =
191
+ !!stored.activeSessionTokenHash &&
192
+ sessionToken.length > 0 &&
193
+ constantTimeHexEquals(sha256Hex(sessionToken), stored.activeSessionTokenHash);
194
+
195
+ c.set("auth", {
196
+ initialized: true,
197
+ principal: authenticated ? "web" : "cli",
198
+ authenticated,
199
+ });
200
+
201
+ await next();
202
+ };
203
+
204
+ export function getRequestAuthContext(c: Context<AppBindings>): RequestAuthContext {
205
+ return c.get("auth");
206
+ }
207
+
208
+ export function requireWebSession(c: Context<AppBindings>): Response | undefined {
209
+ const auth = getRequestAuthContext(c);
210
+ if (!auth.initialized) return buildSetupRequiredResponse(c);
211
+ if (!auth.authenticated) return buildLoginRequiredResponse(c);
212
+ return undefined;
213
+ }
214
+
215
+ export async function getAuthSessionResponse(
216
+ c: Context<AppBindings>,
217
+ ): Promise<AuthSessionResponse> {
218
+ const auth = getRequestAuthContext(c);
219
+ return {
220
+ initialized: auth.initialized,
221
+ authenticated: auth.authenticated,
222
+ };
223
+ }
224
+
225
+ export async function setupPassphrase(passphrase: string): Promise<void> {
226
+ if (!passphrase) {
227
+ throw new Error("Passphrase required");
228
+ }
229
+ const existing = await loadStoredAuthState();
230
+ if (existing) {
231
+ throw new Error("Auth already initialized");
232
+ }
233
+ const recoveryKey = createRecoveryKey();
234
+ const next: StoredAuthState = {
235
+ version: 1,
236
+ initializedAt: new Date().toISOString(),
237
+ passphraseHash: await Bun.password.hash(passphrase),
238
+ recoveryKeyHash: sha256Hex(normalizeRecoveryKeyInput(recoveryKey)),
239
+ activeSessionTokenHash: null,
240
+ };
241
+ await writeStoredAuthState(next);
242
+
243
+ // Write the one-time key to a sidecar file so the launcher process can read
244
+ // and display it even when the server runs as a detached child (avoids
245
+ // cross-process stdout races on Windows).
246
+ let wroteKeyFile = false;
247
+ try {
248
+ const keyFilePath = resolveRecoveryKeyFilePath();
249
+ await writeFile(keyFilePath, recoveryKey, "utf8");
250
+ await applyOwnerOnlyPermissions(keyFilePath);
251
+ wroteKeyFile = true;
252
+ } catch {
253
+ // Best-effort; fall through to console printing as a fallback.
254
+ }
255
+
256
+ // When the launcher owns terminal output it reads the sidecar file instead,
257
+ // so skip console printing to avoid duplicates.
258
+ if (!wroteKeyFile || process.env.TASKMANAGER_SILENT_STARTUP_LOG !== "1") {
259
+ printRecoveryKeyToConsole(recoveryKey);
260
+ }
261
+ }
262
+
263
+ /** Path to the one-time recovery key sidecar written during first setup. */
264
+ export function resolveRecoveryKeyFilePath(): string {
265
+ return path.join(resolveAuthRootDir(), "recovery-key.tmp");
266
+ }
267
+
268
+ function printRecoveryKeyToConsole(recoveryKey: string): void {
269
+ const o = process.stdout;
270
+ if (colorEnabled(o)) {
271
+ console.log(paint(o, "Recovery Key:", ansi.bold + ansi.yellow));
272
+ console.log(paint(o, recoveryKey, ansi.cyan + ansi.bold));
273
+ console.log(
274
+ paint(
275
+ o,
276
+ "Store it on a separate device. It will never show again.",
277
+ ansi.dim,
278
+ ),
279
+ );
280
+ console.log(
281
+ paint(
282
+ o,
283
+ "Use it to recover your passphrase and access your server/data.",
284
+ ansi.dim,
285
+ ),
286
+ );
287
+ } else {
288
+ console.log("Recovery Key:");
289
+ console.log(recoveryKey);
290
+ console.log("Store it on a separate device. It will never show again.");
291
+ console.log("Use it to recover your passphrase and access your server/data.");
292
+ }
293
+ }
294
+
295
+ export async function loginWithPassphrase(passphrase: string): Promise<string | null> {
296
+ const stored = await loadStoredAuthState();
297
+ if (!stored) return null;
298
+ const ok = await Bun.password.verify(passphrase, stored.passphraseHash);
299
+ if (!ok) return null;
300
+ const sessionToken = createSessionToken();
301
+ await writeStoredAuthState({
302
+ ...stored,
303
+ activeSessionTokenHash: sha256Hex(sessionToken),
304
+ });
305
+ return sessionToken;
306
+ }
307
+
308
+ export async function resetPassphraseWithRecoveryKey(
309
+ recoveryKey: string,
310
+ nextPassphrase: string,
311
+ ): Promise<boolean> {
312
+ if (!nextPassphrase) {
313
+ throw new Error("Passphrase required");
314
+ }
315
+ const stored = await loadStoredAuthState();
316
+ if (!stored) return false;
317
+ const recoveryKeyHash = sha256Hex(normalizeRecoveryKeyInput(recoveryKey));
318
+ if (!constantTimeHexEquals(recoveryKeyHash, stored.recoveryKeyHash)) {
319
+ return false;
320
+ }
321
+ await writeStoredAuthState({
322
+ ...stored,
323
+ passphraseHash: await Bun.password.hash(nextPassphrase),
324
+ activeSessionTokenHash: null,
325
+ });
326
+ return true;
327
+ }
328
+
329
+ export async function clearActiveSession(): Promise<void> {
330
+ await updateStoredAuthState((current) => ({
331
+ ...current,
332
+ activeSessionTokenHash: null,
333
+ }));
334
+ }
335
+
336
+ export function setAuthSessionCookie(c: Context, sessionToken: string): void {
337
+ const url = new URL(c.req.url);
338
+ setCookie(c, AUTH_SESSION_COOKIE_NAME, sessionToken, {
339
+ httpOnly: true,
340
+ sameSite: "Lax",
341
+ path: "/",
342
+ maxAge: AUTH_COOKIE_MAX_AGE_SECONDS,
343
+ secure: url.protocol === "https:",
344
+ });
345
+ }
346
+
347
+ export function clearAuthSessionCookie(c: Context): void {
348
+ deleteCookie(c, AUTH_SESSION_COOKIE_NAME, {
349
+ path: "/",
350
+ });
351
+ }