botmux 2.88.1 → 2.89.0

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 (171) hide show
  1. package/dist/bot-registry.d.ts +2 -0
  2. package/dist/bot-registry.d.ts.map +1 -1
  3. package/dist/bot-registry.js +4 -0
  4. package/dist/bot-registry.js.map +1 -1
  5. package/dist/cli/dashboard-endpoint.d.ts.map +1 -1
  6. package/dist/cli/dashboard-endpoint.js +9 -3
  7. package/dist/cli/dashboard-endpoint.js.map +1 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +11 -3
  10. package/dist/cli.js.map +1 -1
  11. package/dist/core/command-handler.d.ts.map +1 -1
  12. package/dist/core/command-handler.js +9 -1
  13. package/dist/core/command-handler.js.map +1 -1
  14. package/dist/core/dashboard-command/groups.d.ts +20 -0
  15. package/dist/core/dashboard-command/groups.d.ts.map +1 -0
  16. package/dist/core/dashboard-command/groups.js +52 -0
  17. package/dist/core/dashboard-command/groups.js.map +1 -0
  18. package/dist/core/dashboard-command/index.d.ts +30 -0
  19. package/dist/core/dashboard-command/index.d.ts.map +1 -0
  20. package/dist/core/dashboard-command/index.js +81 -0
  21. package/dist/core/dashboard-command/index.js.map +1 -0
  22. package/dist/core/dashboard-command/overview.d.ts +29 -0
  23. package/dist/core/dashboard-command/overview.d.ts.map +1 -0
  24. package/dist/core/dashboard-command/overview.js +61 -0
  25. package/dist/core/dashboard-command/overview.js.map +1 -0
  26. package/dist/core/dashboard-command/owner-gate.d.ts +41 -0
  27. package/dist/core/dashboard-command/owner-gate.d.ts.map +1 -0
  28. package/dist/core/dashboard-command/owner-gate.js +40 -0
  29. package/dist/core/dashboard-command/owner-gate.js.map +1 -0
  30. package/dist/core/dashboard-command/schedules.d.ts +20 -0
  31. package/dist/core/dashboard-command/schedules.d.ts.map +1 -0
  32. package/dist/core/dashboard-command/schedules.js +47 -0
  33. package/dist/core/dashboard-command/schedules.js.map +1 -0
  34. package/dist/core/dashboard-command/sessions.d.ts +22 -0
  35. package/dist/core/dashboard-command/sessions.d.ts.map +1 -0
  36. package/dist/core/dashboard-command/sessions.js +43 -0
  37. package/dist/core/dashboard-command/sessions.js.map +1 -0
  38. package/dist/core/dashboard-command/settings.d.ts +21 -0
  39. package/dist/core/dashboard-command/settings.d.ts.map +1 -0
  40. package/dist/core/dashboard-command/settings.js +55 -0
  41. package/dist/core/dashboard-command/settings.js.map +1 -0
  42. package/dist/core/dashboard-command/stub.d.ts +16 -0
  43. package/dist/core/dashboard-command/stub.d.ts.map +1 -0
  44. package/dist/core/dashboard-command/stub.js +28 -0
  45. package/dist/core/dashboard-command/stub.js.map +1 -0
  46. package/dist/core/dashboard-command/workflows.d.ts +20 -0
  47. package/dist/core/dashboard-command/workflows.d.ts.map +1 -0
  48. package/dist/core/dashboard-command/workflows.js +45 -0
  49. package/dist/core/dashboard-command/workflows.js.map +1 -0
  50. package/dist/core/passthrough-commands.d.ts.map +1 -1
  51. package/dist/core/passthrough-commands.js +1 -1
  52. package/dist/core/passthrough-commands.js.map +1 -1
  53. package/dist/daemon-internal-client-wrapper.d.ts +41 -0
  54. package/dist/daemon-internal-client-wrapper.d.ts.map +1 -0
  55. package/dist/daemon-internal-client-wrapper.js +65 -0
  56. package/dist/daemon-internal-client-wrapper.js.map +1 -0
  57. package/dist/dashboard/aggregator.d.ts +12 -0
  58. package/dist/dashboard/aggregator.d.ts.map +1 -1
  59. package/dist/dashboard/aggregator.js +16 -0
  60. package/dist/dashboard/aggregator.js.map +1 -1
  61. package/dist/dashboard/auth.d.ts +7 -0
  62. package/dist/dashboard/auth.d.ts.map +1 -1
  63. package/dist/dashboard/auth.js +21 -0
  64. package/dist/dashboard/auth.js.map +1 -1
  65. package/dist/dashboard/card-model-types.d.ts +50 -0
  66. package/dist/dashboard/card-model-types.d.ts.map +1 -0
  67. package/dist/dashboard/card-model-types.js +9 -0
  68. package/dist/dashboard/card-model-types.js.map +1 -0
  69. package/dist/dashboard/daemon-internal-api.d.ts +90 -0
  70. package/dist/dashboard/daemon-internal-api.d.ts.map +1 -0
  71. package/dist/dashboard/daemon-internal-api.js +534 -0
  72. package/dist/dashboard/daemon-internal-api.js.map +1 -0
  73. package/dist/dashboard/daemon-internal-auth.d.ts +124 -0
  74. package/dist/dashboard/daemon-internal-auth.d.ts.map +1 -0
  75. package/dist/dashboard/daemon-internal-auth.js +188 -0
  76. package/dist/dashboard/daemon-internal-auth.js.map +1 -0
  77. package/dist/dashboard/daemon-internal-client.d.ts +67 -0
  78. package/dist/dashboard/daemon-internal-client.d.ts.map +1 -0
  79. package/dist/dashboard/daemon-internal-client.js +157 -0
  80. package/dist/dashboard/daemon-internal-client.js.map +1 -0
  81. package/dist/dashboard/dashboard-admins.d.ts +8 -0
  82. package/dist/dashboard/dashboard-admins.d.ts.map +1 -0
  83. package/dist/dashboard/dashboard-admins.js +15 -0
  84. package/dist/dashboard/dashboard-admins.js.map +1 -0
  85. package/dist/dashboard/federation-spoke-api.d.ts +3 -0
  86. package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
  87. package/dist/dashboard/federation-spoke-api.js +11 -8
  88. package/dist/dashboard/federation-spoke-api.js.map +1 -1
  89. package/dist/dashboard/groups-action-helpers.d.ts +69 -0
  90. package/dist/dashboard/groups-action-helpers.d.ts.map +1 -0
  91. package/dist/dashboard/groups-action-helpers.js +141 -0
  92. package/dist/dashboard/groups-action-helpers.js.map +1 -0
  93. package/dist/dashboard/groups-card-model.d.ts +120 -0
  94. package/dist/dashboard/groups-card-model.d.ts.map +1 -0
  95. package/dist/dashboard/groups-card-model.js +175 -0
  96. package/dist/dashboard/groups-card-model.js.map +1 -0
  97. package/dist/dashboard/schedule-card-model.d.ts +172 -0
  98. package/dist/dashboard/schedule-card-model.d.ts.map +1 -0
  99. package/dist/dashboard/schedule-card-model.js +290 -0
  100. package/dist/dashboard/schedule-card-model.js.map +1 -0
  101. package/dist/dashboard/session-card-model.d.ts +83 -0
  102. package/dist/dashboard/session-card-model.d.ts.map +1 -0
  103. package/dist/dashboard/session-card-model.js +196 -0
  104. package/dist/dashboard/session-card-model.js.map +1 -0
  105. package/dist/dashboard/settings-card-model.d.ts +73 -0
  106. package/dist/dashboard/settings-card-model.d.ts.map +1 -0
  107. package/dist/dashboard/settings-card-model.js +94 -0
  108. package/dist/dashboard/settings-card-model.js.map +1 -0
  109. package/dist/dashboard/settings-owner-resolver.d.ts +32 -0
  110. package/dist/dashboard/settings-owner-resolver.d.ts.map +1 -0
  111. package/dist/dashboard/settings-owner-resolver.js +43 -0
  112. package/dist/dashboard/settings-owner-resolver.js.map +1 -0
  113. package/dist/dashboard/settings-write-applier.d.ts +84 -0
  114. package/dist/dashboard/settings-write-applier.d.ts.map +1 -0
  115. package/dist/dashboard/settings-write-applier.js +119 -0
  116. package/dist/dashboard/settings-write-applier.js.map +1 -0
  117. package/dist/dashboard/workflow-api.d.ts.map +1 -1
  118. package/dist/dashboard/workflow-api.js +38 -119
  119. package/dist/dashboard/workflow-api.js.map +1 -1
  120. package/dist/dashboard/workflow-card-model.d.ts +127 -0
  121. package/dist/dashboard/workflow-card-model.d.ts.map +1 -0
  122. package/dist/dashboard/workflow-card-model.js +186 -0
  123. package/dist/dashboard/workflow-card-model.js.map +1 -0
  124. package/dist/dashboard/workflows-action-helpers.d.ts +105 -0
  125. package/dist/dashboard/workflows-action-helpers.d.ts.map +1 -0
  126. package/dist/dashboard/workflows-action-helpers.js +196 -0
  127. package/dist/dashboard/workflows-action-helpers.js.map +1 -0
  128. package/dist/dashboard.js +196 -269
  129. package/dist/dashboard.js.map +1 -1
  130. package/dist/i18n/en.d.ts.map +1 -1
  131. package/dist/i18n/en.js +266 -0
  132. package/dist/i18n/en.js.map +1 -1
  133. package/dist/i18n/zh.d.ts.map +1 -1
  134. package/dist/i18n/zh.js +266 -0
  135. package/dist/i18n/zh.js.map +1 -1
  136. package/dist/im/lark/card-builder.d.ts +2 -0
  137. package/dist/im/lark/card-builder.d.ts.map +1 -1
  138. package/dist/im/lark/card-builder.js +2 -1
  139. package/dist/im/lark/card-builder.js.map +1 -1
  140. package/dist/im/lark/card-handler.d.ts +54 -2
  141. package/dist/im/lark/card-handler.d.ts.map +1 -1
  142. package/dist/im/lark/card-handler.js +127 -1
  143. package/dist/im/lark/card-handler.js.map +1 -1
  144. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  145. package/dist/im/lark/event-dispatcher.js +22 -0
  146. package/dist/im/lark/event-dispatcher.js.map +1 -1
  147. package/dist/im/lark/groups-card.d.ts +90 -0
  148. package/dist/im/lark/groups-card.d.ts.map +1 -0
  149. package/dist/im/lark/groups-card.js +832 -0
  150. package/dist/im/lark/groups-card.js.map +1 -0
  151. package/dist/im/lark/overview-card.d.ts +101 -0
  152. package/dist/im/lark/overview-card.d.ts.map +1 -0
  153. package/dist/im/lark/overview-card.js +475 -0
  154. package/dist/im/lark/overview-card.js.map +1 -0
  155. package/dist/im/lark/schedules-card.d.ts +96 -0
  156. package/dist/im/lark/schedules-card.d.ts.map +1 -0
  157. package/dist/im/lark/schedules-card.js +892 -0
  158. package/dist/im/lark/schedules-card.js.map +1 -0
  159. package/dist/im/lark/sessions-card.d.ts +117 -0
  160. package/dist/im/lark/sessions-card.d.ts.map +1 -0
  161. package/dist/im/lark/sessions-card.js +870 -0
  162. package/dist/im/lark/sessions-card.js.map +1 -0
  163. package/dist/im/lark/settings-card.d.ts +125 -0
  164. package/dist/im/lark/settings-card.d.ts.map +1 -0
  165. package/dist/im/lark/settings-card.js +433 -0
  166. package/dist/im/lark/settings-card.js.map +1 -0
  167. package/dist/im/lark/workflows-card.d.ts +102 -0
  168. package/dist/im/lark/workflows-card.d.ts.map +1 -0
  169. package/dist/im/lark/workflows-card.js +737 -0
  170. package/dist/im/lark/workflows-card.js.map +1 -0
  171. package/package.json +1 -1
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Daemon-internal API — typed Route B server for `/__daemon/*`.
3
+ *
4
+ * Dispatch pipeline:
5
+ * 1) `verifyDaemonRequest` checks HMAC + loopback + ts ±60s + nonce replay.
6
+ * Reads the body stream EXACTLY ONCE and returns `bodyRaw`.
7
+ * 2) `bodyRaw` is JSON-parsed (empty body → `undefined`); a parse failure
8
+ * after a valid HMAC returns 400 `bad_json` without re-reading `req`.
9
+ * 3) Dispatch matches `(method, path)` against a typed allowlist
10
+ * endpoints — there is intentionally NO generic forward, so a daemon
11
+ * can never use Route B as a path-shifting proxy.
12
+ *
13
+ * Settings-write also enforces the union_id owner gate: the body must
14
+ * carry an `ownerUnionId` (`on_`-prefixed) that resolves to a candidate in
15
+ * the global owner set, or the request returns 403 `owner_only`.
16
+ *
17
+ * The factory exposes both `handle(req,res,url)` (production wiring) and
18
+ * `dispatchForTest(method, url, bodyRaw)` (skips HMAC for unit tests that
19
+ * focus on route shape; full HMAC flow is covered by daemon-internal-auth).
20
+ */
21
+ import type { IncomingMessage, ServerResponse } from 'node:http';
22
+ import { type ClockLike, type NonceStore } from './daemon-internal-auth.js';
23
+ import { type GroupsActionDeps, type HandlerResult } from './groups-action-helpers.js';
24
+ import { type ResolvedDashboardSettingsView, type SettingsWriteApplierDeps } from './settings-write-applier.js';
25
+ import { type SettingsOwnerResolverDeps } from './settings-owner-resolver.js';
26
+ import { type WorkflowsActionDeps } from './workflows-action-helpers.js';
27
+ export type SimpleHttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
28
+ /** Deps the dispatcher needs — all IO is injected. */
29
+ export interface DaemonInternalApiDeps {
30
+ /** `.dashboard-secret` body (string used directly as HMAC key — same convention as `/__cli/rotate`). */
31
+ secret: string;
32
+ /** Override for tests to inject a fake clock-aware nonce store. Production uses `createNonceStore()`. */
33
+ nonceStore?: NonceStore;
34
+ /** Override for tests to advance time deterministically inside verifyDaemonRequest. */
35
+ clock?: ClockLike;
36
+ getSessions: () => unknown[];
37
+ getSchedules: () => unknown[];
38
+ resolveDashboardSettings: () => ResolvedDashboardSettingsView;
39
+ /** Returns `{ chats, bots }`; groups model requires both for missingOnly accuracy. */
40
+ buildGroupsMatrix: () => Promise<{
41
+ chats: unknown[];
42
+ bots: unknown[];
43
+ }>;
44
+ settingsApplierDeps: SettingsWriteApplierDeps;
45
+ groupsActionDeps: GroupsActionDeps;
46
+ workflowsActionDeps: WorkflowsActionDeps<any>;
47
+ proxyToDaemon: (larkAppId: string, daemonPath: string, init: RequestInit) => Promise<Response>;
48
+ ownerOf: (sessionId: string) => string | undefined;
49
+ /** Companion of `ownerOf` — tells "row missing" apart from "legacy row".
50
+ * Same rationale as `scheduleExists`. */
51
+ sessionExists: (sessionId: string) => boolean;
52
+ scheduleOwnerOf: (id: string) => string | undefined;
53
+ /** True iff a schedule row with this id exists at all in the aggregator,
54
+ * regardless of its `larkAppId` presence. Used by the Route B write gate
55
+ * to tell apart "legacy schedule (no owner field)" from "unknown id". */
56
+ scheduleExists: (id: string) => boolean;
57
+ /** Override for unit tests; production omits and uses the real federation helper. */
58
+ settingsOwnerDeps?: SettingsOwnerResolverDeps;
59
+ }
60
+ export interface DispatchContext {
61
+ bodyRaw: string;
62
+ body: unknown;
63
+ url: URL;
64
+ /**
65
+ * Authenticated caller's bot `larkAppId` — populated by `handle()` from
66
+ * `verify.appId` (`daemon-internal-auth.ts:232`). Aggregated read routes
67
+ * use this id for their default per-bot view; `?scope=global` explicitly
68
+ * widens `/dashboard` list reads to the Bot Owner's deployment-wide view.
69
+ * undefined only on the test seam (`dispatchForTest`) where the caller is
70
+ * trusted to assert their own scope.
71
+ */
72
+ callerAppId?: string;
73
+ }
74
+ /**
75
+ * Pure dispatcher: matches `(method, path)` against the typed allowlist.
76
+ * Returns `unknown_endpoint` (404) when no path matches, `method_not_allowed`
77
+ * (405) when a path matches but the method does not, or hands off to the
78
+ * matched handler.
79
+ */
80
+ export declare function dispatchDaemonInternalRequest(method: string, url: URL, bodyRaw: string, deps: DaemonInternalApiDeps, callerAppId?: string): Promise<HandlerResult>;
81
+ export interface DaemonInternalApi {
82
+ /** Production entry point: verify HMAC, JSON-parse, dispatch, write response. */
83
+ handle(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean>;
84
+ /** Test seam: bypass HMAC, exercise dispatch shape directly. `callerAppId`
85
+ * emulates the authenticated bot id so read-scoping tests can drive the
86
+ * per-bot filter without going through HMAC. */
87
+ dispatchForTest(method: string, url: URL, bodyRaw?: string, callerAppId?: string): Promise<HandlerResult>;
88
+ }
89
+ export declare function createDaemonInternalApi(deps: DaemonInternalApiDeps): DaemonInternalApi;
90
+ //# sourceMappingURL=daemon-internal-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"daemon-internal-api.d.ts","sourceRoot":"","sources":["../../src/dashboard/daemon-internal-api.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,534 @@
1
+ /**
2
+ * Daemon-internal API — typed Route B server for `/__daemon/*`.
3
+ *
4
+ * Dispatch pipeline:
5
+ * 1) `verifyDaemonRequest` checks HMAC + loopback + ts ±60s + nonce replay.
6
+ * Reads the body stream EXACTLY ONCE and returns `bodyRaw`.
7
+ * 2) `bodyRaw` is JSON-parsed (empty body → `undefined`); a parse failure
8
+ * after a valid HMAC returns 400 `bad_json` without re-reading `req`.
9
+ * 3) Dispatch matches `(method, path)` against a typed allowlist
10
+ * endpoints — there is intentionally NO generic forward, so a daemon
11
+ * can never use Route B as a path-shifting proxy.
12
+ *
13
+ * Settings-write also enforces the union_id owner gate: the body must
14
+ * carry an `ownerUnionId` (`on_`-prefixed) that resolves to a candidate in
15
+ * the global owner set, or the request returns 403 `owner_only`.
16
+ *
17
+ * The factory exposes both `handle(req,res,url)` (production wiring) and
18
+ * `dispatchForTest(method, url, bodyRaw)` (skips HMAC for unit tests that
19
+ * focus on route shape; full HMAC flow is covered by daemon-internal-auth).
20
+ */
21
+ import { createNonceStore, verifyDaemonRequest, } from './daemon-internal-auth.js';
22
+ import { addBotsToGroup, bindOncall, disbandGroup, leaveGroup, unbindOncall, } from './groups-action-helpers.js';
23
+ import { applySettingsWrite, } from './settings-write-applier.js';
24
+ import { isAuthorizedForGlobalSettings, } from './settings-owner-resolver.js';
25
+ import { listWorkflowRuns, runApproveReject, runCancel, } from './workflows-action-helpers.js';
26
+ /** ─── Helpers ──────────────────────────────────────────────────────── */
27
+ function parseStatusesParam(raw) {
28
+ if (raw === null)
29
+ return undefined;
30
+ const s = new Set(raw.split(',').map(x => x.trim()).filter(Boolean));
31
+ return s.size > 0 ? s : undefined;
32
+ }
33
+ async function readUpstream(upstream) {
34
+ const text = await upstream.text();
35
+ try {
36
+ return JSON.parse(text);
37
+ }
38
+ catch {
39
+ return text;
40
+ }
41
+ }
42
+ function bodyField(body, name) {
43
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
44
+ return body[name];
45
+ }
46
+ return undefined;
47
+ }
48
+ /**
49
+ * Generic per-bot scoping helper — restricts aggregator rows to those owned
50
+ * by the authenticated caller's bot.
51
+ *
52
+ * Default list reads are per-bot: a caller authenticated as bot A should not
53
+ * see bot B rows unless the route explicitly opts into `?scope=global`.
54
+ *
55
+ * Rows whose owner getter returns undefined / empty string (legacy
56
+ * persistence shape) are KEPT so they don't disappear from a freshly-
57
+ * upgraded deploy. callerAppId === undefined means the test seam
58
+ * (`dispatchForTest`) is in use; the test is trusted to assert its own
59
+ * scope so we pass everything through.
60
+ *
61
+ * The owner getter argument lets workflows (nested
62
+ * `chatBinding.larkAppId`) reuse the same filter pipeline as sessions /
63
+ * schedules (top-level `larkAppId`).
64
+ */
65
+ function scopeRowsByCaller(rows, callerAppId, getOwnerAppId) {
66
+ if (!callerAppId)
67
+ return rows.slice();
68
+ return rows.filter(r => {
69
+ const owner = getOwnerAppId(r);
70
+ // Keep legacy rows (no owner resolvable) so a fresh deploy doesn't lose them.
71
+ if (typeof owner !== 'string' || owner.length === 0)
72
+ return true;
73
+ return owner === callerAppId;
74
+ });
75
+ }
76
+ /**
77
+ * Thin wrapper around `scopeRowsByCaller` for rows with a top-level
78
+ * `larkAppId` field (sessions / schedules / aggregator-shape). Workflows
79
+ * call `scopeRowsByCaller` directly with their own owner getter.
80
+ */
81
+ function scopeByCaller(rows, callerAppId) {
82
+ return scopeRowsByCaller(rows, callerAppId, r => r?.larkAppId);
83
+ }
84
+ /**
85
+ * Per-bot scoping for the `groups-matrix` endpoint.
86
+ *
87
+ * The groups matrix returns `{ chats, bots }` where neither container has a
88
+ * top-level `larkAppId`, so the generic `scopeByCaller` / `scopeRowsByCaller`
89
+ * helpers don't fit. Default fail-closed rules:
90
+ *
91
+ * - bots: filter to ONLY entries whose `larkAppId === callerAppId`.
92
+ * - chats: keep ONLY chats where some memberBots entry has
93
+ * `larkAppId === callerAppId AND inChat === true`. A bot that's listed as
94
+ * a member but `inChat=false` does NOT qualify.
95
+ * - each retained chat's `memberBots` is trimmed to JUST the caller's
96
+ * single entry, so other bots' roster never leaks.
97
+ * - NO legacy fallback: rows / bots without a recognized `larkAppId` are
98
+ * dropped (fail-closed). Unlike sessions / schedules, the groups matrix
99
+ * has no historical persistence shape to preserve.
100
+ *
101
+ * `callerAppId === undefined` is the `dispatchForTest` seam — pass through
102
+ * the full unscoped matrix so tests can assert raw aggregator output.
103
+ *
104
+ * The helper does NOT mutate the input matrix: each kept chat is spread into
105
+ * a new object before its `memberBots` is overwritten.
106
+ */
107
+ function scopeGroupsMatrixByCaller(matrix, callerAppId) {
108
+ if (callerAppId === undefined)
109
+ return matrix;
110
+ const filteredBots = matrix.bots.filter(b => typeof b?.larkAppId === 'string' &&
111
+ b.larkAppId === callerAppId);
112
+ const filteredChats = [];
113
+ for (const c of matrix.chats) {
114
+ const members = c?.memberBots;
115
+ if (!Array.isArray(members))
116
+ continue;
117
+ const ourMember = members.find(m => m?.larkAppId === callerAppId && m?.inChat === true);
118
+ if (!ourMember)
119
+ continue;
120
+ filteredChats.push({ ...c, memberBots: [ourMember] });
121
+ }
122
+ return { chats: filteredChats, bots: filteredBots };
123
+ }
124
+ /** ─── Route table ────────────────────────────────────────────────── */
125
+ const ROUTES = [
126
+ // ── READ ──────────────────────────────
127
+ {
128
+ method: 'GET',
129
+ pathRe: /^\/__daemon\/sessions-list$/,
130
+ handle: async (_m, ctx, deps) => {
131
+ const isGlobal = ctx.url.searchParams.get('scope') === 'global';
132
+ const sessions = isGlobal
133
+ ? deps.getSessions()
134
+ : scopeByCaller(deps.getSessions(), ctx.callerAppId);
135
+ return { status: 200, body: { sessions } };
136
+ },
137
+ },
138
+ // Dedicated schedules list endpoint. `?scope=global` widens only the read
139
+ // row set; HMAC, admin, invoker, and write owner-routing gates are unchanged.
140
+ {
141
+ method: 'GET',
142
+ pathRe: /^\/__daemon\/schedules-list$/,
143
+ handle: async (_m, ctx, deps) => {
144
+ const isGlobal = ctx.url.searchParams.get('scope') === 'global';
145
+ const schedules = isGlobal
146
+ ? deps.getSchedules()
147
+ : scopeByCaller(deps.getSchedules(), ctx.callerAppId);
148
+ return { status: 200, body: { schedules } };
149
+ },
150
+ },
151
+ {
152
+ method: 'GET',
153
+ pathRe: /^\/__daemon\/settings-snapshot$/,
154
+ handle: async (_m, _ctx, deps) => ({ status: 200, body: { settings: deps.resolveDashboardSettings() } }),
155
+ },
156
+ {
157
+ method: 'GET',
158
+ pathRe: /^\/__daemon\/groups-matrix$/,
159
+ handle: async (_m, ctx, deps) => {
160
+ // Default: per-bot owner gate. Filter the matrix so the caller's bot
161
+ // only sees rows where it's actually a member (`inChat`), and trim
162
+ // each chat's memberBots to the caller's single entry to avoid leaking
163
+ // other bots' membership state.
164
+ //
165
+ // `?scope=global`: `/dashboard` is a Bot Owner global tool panel, so
166
+ // return the full matrix. HMAC / owner / invoker gates are unchanged;
167
+ // only the read row scope widens.
168
+ const matrix = await deps.buildGroupsMatrix();
169
+ const scoped = ctx.url.searchParams.get('scope') === 'global'
170
+ ? matrix
171
+ : scopeGroupsMatrixByCaller(matrix, ctx.callerAppId);
172
+ return { status: 200, body: scoped };
173
+ },
174
+ },
175
+ {
176
+ method: 'GET',
177
+ pathRe: /^\/__daemon\/workflows-runs-snapshot$/,
178
+ handle: async (_m, ctx, deps) => {
179
+ const query = {
180
+ all: ctx.url.searchParams.get('all') === '1',
181
+ statuses: parseStatusesParam(ctx.url.searchParams.get('status')),
182
+ };
183
+ // listWorkflowRuns returns HandlerResult, not a raw array. Filter only
184
+ // successful `{ runs }` bodies; pass through errors and malformed bodies
185
+ // so callers see the real failure instead of an empty list.
186
+ const result = await listWorkflowRuns(query, deps.workflowsActionDeps);
187
+ if (result.status === 200 &&
188
+ result.body &&
189
+ typeof result.body === 'object' &&
190
+ Array.isArray(result.body.runs)) {
191
+ const runs = result.body.runs;
192
+ const scoped = ctx.url.searchParams.get('scope') === 'global'
193
+ ? runs.slice()
194
+ : scopeRowsByCaller(runs, ctx.callerAppId, r => r?.chatBinding?.larkAppId);
195
+ return {
196
+ status: 200,
197
+ body: { ...result.body, runs: scoped },
198
+ };
199
+ }
200
+ return result;
201
+ },
202
+ },
203
+ {
204
+ method: 'GET',
205
+ pathRe: /^\/__daemon\/overview-snapshot$/,
206
+ handle: async (_m, ctx, deps) => {
207
+ // Default: apply the same per-bot scoping as the dedicated list
208
+ // endpoints when overview bundles aggregator state.
209
+ //
210
+ // `?scope=global`: `/dashboard` is a global tool panel. All list
211
+ // modules except settings widen their read scope together; settings
212
+ // remains per-calling-bot until it has an explicit global write model.
213
+ const isGlobal = ctx.url.searchParams.get('scope') === 'global';
214
+ const groups = await deps.buildGroupsMatrix();
215
+ return {
216
+ status: 200,
217
+ body: {
218
+ sessions: isGlobal
219
+ ? deps.getSessions()
220
+ : scopeByCaller(deps.getSessions(), ctx.callerAppId),
221
+ schedules: isGlobal
222
+ ? deps.getSchedules()
223
+ : scopeByCaller(deps.getSchedules(), ctx.callerAppId),
224
+ settings: deps.resolveDashboardSettings(),
225
+ groups,
226
+ },
227
+ };
228
+ },
229
+ },
230
+ // ── WRITE: settings ───────────────────
231
+ {
232
+ method: 'PUT',
233
+ pathRe: /^\/__daemon\/settings-write$/,
234
+ handle: async (_m, ctx, deps) => {
235
+ const ownerUnionId = bodyField(ctx.body, 'ownerUnionId');
236
+ const allowed = await isAuthorizedForGlobalSettings({ senderUnionId: typeof ownerUnionId === 'string' ? ownerUnionId : undefined }, deps.settingsOwnerDeps);
237
+ if (!allowed)
238
+ return { status: 403, body: { ok: false, error: 'owner_only' } };
239
+ const patch = bodyField(ctx.body, 'patch');
240
+ const r = await applySettingsWrite(patch, deps.settingsApplierDeps);
241
+ if (!r.ok)
242
+ return { status: 400, body: { ok: false, error: r.error } };
243
+ return { status: 200, body: { ok: true, settings: r.settings } };
244
+ },
245
+ },
246
+ // ── WRITE: sessions × 3 ───────────────
247
+ {
248
+ method: 'POST',
249
+ pathRe: /^\/__daemon\/sessions\/([^/]+)\/(close|resume|locate)$/,
250
+ handle: async (m, ctx, deps) => {
251
+ const sessionId = decodeURIComponent(m[1]);
252
+ const action = m[2];
253
+ // Three-state routing mirrors schedules:
254
+ // - owner !== undefined + caller mismatch → 403 session_owner_mismatch
255
+ // - owner !== undefined + caller match (or test seam) → proxy owner
256
+ // - owner === undefined + sessionExists + callerAppId set → legacy,
257
+ // proxy to caller's bot (same bot that fetched the row via the
258
+ // scoped read endpoint).
259
+ // - row genuinely missing → 404 unknown_session
260
+ // Route B fails closed too, not only the IM card layer.
261
+ const owner = deps.ownerOf(sessionId);
262
+ if (owner === undefined) {
263
+ if (!deps.sessionExists(sessionId)) {
264
+ return { status: 404, body: { ok: false, error: 'unknown_session' } };
265
+ }
266
+ if (ctx.callerAppId === undefined) {
267
+ // test seam preserves the historical 404 — production callers
268
+ // always have an HMAC-resolved appId.
269
+ return { status: 404, body: { ok: false, error: 'unknown_session' } };
270
+ }
271
+ const upstream = await deps.proxyToDaemon(ctx.callerAppId, `/api/sessions/${encodeURIComponent(sessionId)}/${action}`, {
272
+ method: 'POST',
273
+ headers: { 'content-type': 'application/json' },
274
+ body: ctx.bodyRaw.length > 0 ? ctx.bodyRaw : '{}',
275
+ });
276
+ return { status: upstream.status, body: await readUpstream(upstream) };
277
+ }
278
+ const isGlobal = ctx.url.searchParams.get('scope') === 'global';
279
+ if (!isGlobal && ctx.callerAppId !== undefined && owner !== ctx.callerAppId) {
280
+ return { status: 403, body: { ok: false, error: 'session_owner_mismatch' } };
281
+ }
282
+ const upstream = await deps.proxyToDaemon(owner, `/api/sessions/${encodeURIComponent(sessionId)}/${action}`, {
283
+ method: 'POST',
284
+ headers: { 'content-type': 'application/json' },
285
+ body: ctx.bodyRaw.length > 0 ? ctx.bodyRaw : '{}',
286
+ });
287
+ return { status: upstream.status, body: await readUpstream(upstream) };
288
+ },
289
+ },
290
+ // ── WRITE: groups × 5 ─────────────────
291
+ {
292
+ method: 'POST',
293
+ pathRe: /^\/__daemon\/groups\/([^/]+)\/leave$/,
294
+ handle: async (m, ctx, deps) => leaveGroup(decodeURIComponent(m[1]), ctx.body, deps.groupsActionDeps),
295
+ },
296
+ {
297
+ method: 'POST',
298
+ pathRe: /^\/__daemon\/groups\/([^/]+)\/disband$/,
299
+ handle: async (m, ctx, deps) => disbandGroup(decodeURIComponent(m[1]), ctx.body, deps.groupsActionDeps),
300
+ },
301
+ {
302
+ method: 'POST',
303
+ pathRe: /^\/__daemon\/groups\/([^/]+)\/add-bots$/,
304
+ handle: async (m, ctx, deps) => addBotsToGroup(decodeURIComponent(m[1]), ctx.bodyRaw, deps.groupsActionDeps),
305
+ },
306
+ {
307
+ method: 'POST',
308
+ pathRe: /^\/__daemon\/groups\/([^/]+)\/oncall\/([^/]+)\/bind$/,
309
+ handle: async (m, ctx, deps) => bindOncall(decodeURIComponent(m[1]), decodeURIComponent(m[2]), ctx.bodyRaw, deps.groupsActionDeps),
310
+ },
311
+ {
312
+ method: 'POST',
313
+ pathRe: /^\/__daemon\/groups\/([^/]+)\/oncall\/([^/]+)\/unbind$/,
314
+ handle: async (m, _ctx, deps) => unbindOncall(decodeURIComponent(m[1]), decodeURIComponent(m[2]), deps.groupsActionDeps),
315
+ },
316
+ {
317
+ method: 'GET',
318
+ pathRe: /^\/__daemon\/groups\/([^/]+)\/roles\/([^/]+)$/,
319
+ handle: async (m, _ctx, deps) => {
320
+ const chatId = decodeURIComponent(m[1]);
321
+ const appId = decodeURIComponent(m[2]);
322
+ const upstream = await deps.proxyToDaemon(appId, `/api/roles/${encodeURIComponent(chatId)}`, { method: 'GET' });
323
+ return { status: upstream.status, body: await readUpstream(upstream) };
324
+ },
325
+ },
326
+ {
327
+ method: 'PUT',
328
+ pathRe: /^\/__daemon\/groups\/([^/]+)\/roles\/([^/]+)$/,
329
+ handle: async (m, ctx, deps) => {
330
+ const chatId = decodeURIComponent(m[1]);
331
+ const appId = decodeURIComponent(m[2]);
332
+ const upstream = await deps.proxyToDaemon(appId, `/api/roles/${encodeURIComponent(chatId)}`, {
333
+ method: 'PUT',
334
+ headers: { 'content-type': 'application/json' },
335
+ body: ctx.bodyRaw.length > 0 ? ctx.bodyRaw : '{}',
336
+ });
337
+ return { status: upstream.status, body: await readUpstream(upstream) };
338
+ },
339
+ },
340
+ {
341
+ method: 'DELETE',
342
+ pathRe: /^\/__daemon\/groups\/([^/]+)\/roles\/([^/]+)$/,
343
+ handle: async (m, _ctx, deps) => {
344
+ const chatId = decodeURIComponent(m[1]);
345
+ const appId = decodeURIComponent(m[2]);
346
+ const upstream = await deps.proxyToDaemon(appId, `/api/roles/${encodeURIComponent(chatId)}`, { method: 'DELETE' });
347
+ return { status: upstream.status, body: await readUpstream(upstream) };
348
+ },
349
+ },
350
+ // ── WRITE: workflows × 3 ──────────────
351
+ //
352
+ // Cross-bot owner gate. The workflow helpers resolve routing from
353
+ // readRunSnapshot, so Route B checks the owner before proxying. Unlike
354
+ // sessions/schedules, workflows have no legacy "proxy to caller" fallback:
355
+ // a run without chatBinding has no routable owner and should fall through to
356
+ // the helper's existing 409 (`needs_lark_or_cli` / `needs_cli_cancel`).
357
+ //
358
+ // - snapshot null → 404 unknown_run (don't expose the helper's
359
+ // non-existent run path)
360
+ // - owner !== undefined + caller mismatch → 403 workflow_owner_mismatch
361
+ // - owner !== undefined + caller match (or test seam) → fall through
362
+ // - owner === undefined → fall through (helper returns 409)
363
+ //
364
+ // Apply the same gate to approve/reject even if the current card only
365
+ // exposes cancel.
366
+ {
367
+ method: 'POST',
368
+ pathRe: /^\/__daemon\/workflows-runs\/([^/]+)\/(approve|reject)$/,
369
+ handle: async (m, ctx, deps) => {
370
+ const runId = decodeURIComponent(m[1]);
371
+ const action = m[2];
372
+ // Preserve bad-input semantics: invalid ids return 400 bad_run_id rather
373
+ // than being shadowed as 404 unknown_run by the owner snapshot lookup.
374
+ if (!deps.workflowsActionDeps.isValidRunId(runId)) {
375
+ return { status: 400, body: { ok: false, error: 'bad_run_id' } };
376
+ }
377
+ const snap = await deps.workflowsActionDeps.readRunSnapshot(deps.workflowsActionDeps.runsDir, runId);
378
+ if (!snap)
379
+ return { status: 404, body: { ok: false, error: 'unknown_run' } };
380
+ const owner = snap.chatBinding?.larkAppId;
381
+ const isGlobal = ctx.url.searchParams.get('scope') === 'global';
382
+ if (!isGlobal && owner !== undefined && ctx.callerAppId !== undefined && owner !== ctx.callerAppId) {
383
+ return { status: 403, body: { ok: false, error: 'workflow_owner_mismatch' } };
384
+ }
385
+ return runApproveReject(runId, action, ctx.bodyRaw, deps.workflowsActionDeps);
386
+ },
387
+ },
388
+ {
389
+ method: 'POST',
390
+ pathRe: /^\/__daemon\/workflows-runs\/([^/]+)\/cancel$/,
391
+ handle: async (m, ctx, deps) => {
392
+ const runId = decodeURIComponent(m[1]);
393
+ // Preserve the same bad-input semantics as approve/reject.
394
+ if (!deps.workflowsActionDeps.isValidRunId(runId)) {
395
+ return { status: 400, body: { ok: false, error: 'bad_run_id' } };
396
+ }
397
+ const snap = await deps.workflowsActionDeps.readRunSnapshot(deps.workflowsActionDeps.runsDir, runId);
398
+ if (!snap)
399
+ return { status: 404, body: { ok: false, error: 'unknown_run' } };
400
+ const owner = snap.chatBinding?.larkAppId;
401
+ const isGlobal = ctx.url.searchParams.get('scope') === 'global';
402
+ if (!isGlobal && owner !== undefined && ctx.callerAppId !== undefined && owner !== ctx.callerAppId) {
403
+ return { status: 403, body: { ok: false, error: 'workflow_owner_mismatch' } };
404
+ }
405
+ return runCancel(runId, ctx.bodyRaw, deps.workflowsActionDeps);
406
+ },
407
+ },
408
+ // ── WRITE: schedules × 4 ──────────────
409
+ {
410
+ method: 'POST',
411
+ pathRe: /^\/__daemon\/schedules\/([^/]+)\/(run|pause|resume|delivery)$/,
412
+ handle: async (m, ctx, deps) => {
413
+ const id = decodeURIComponent(m[1]);
414
+ const action = m[2];
415
+ // Three-state routing:
416
+ // - row missing entirely → 404 unknown_schedule
417
+ // - row present with larkAppId → cross-bot gate (403 on mismatch)
418
+ // - row present WITHOUT larkAppId (legacy, e.g. pre-v0.4 persistence)
419
+ // → proxy to the caller's own bot. legacy rows are kept visible
420
+ // in the read path (`scopeByCaller` short-circuits when caller is
421
+ // undefined OR when the row has no owner) AND continue to be
422
+ // executed by `scheduler.belongsToOwner` on the primary daemon.
423
+ // Without this branch, the user would see the row + actionable
424
+ // buttons but every POST would 404. With this branch, the caller's
425
+ // bot proxies the action just like an explicit-owner row would.
426
+ const owner = deps.scheduleOwnerOf(id);
427
+ if (owner === undefined) {
428
+ if (!deps.scheduleExists(id)) {
429
+ return { status: 404, body: { ok: false, error: 'unknown_schedule' } };
430
+ }
431
+ // Legacy row. Production: route to the authenticated caller's bot.
432
+ // Test seam (callerAppId undefined): preserve historical behaviour
433
+ // (404) so existing dispatchForTest tests stay deterministic.
434
+ if (ctx.callerAppId === undefined) {
435
+ return { status: 404, body: { ok: false, error: 'unknown_schedule' } };
436
+ }
437
+ const upstream = await deps.proxyToDaemon(ctx.callerAppId, `/api/schedules/${encodeURIComponent(id)}/${action}`, {
438
+ method: 'POST',
439
+ headers: { 'content-type': 'application/json' },
440
+ body: ctx.bodyRaw.length > 0 ? ctx.bodyRaw : '{}',
441
+ });
442
+ return { status: upstream.status, body: await readUpstream(upstream) };
443
+ }
444
+ // Owned row. Cross-bot guard: refuse when the caller is not the
445
+ // owning bot. test seam keeps the historical pass-through.
446
+ //
447
+ // In global dashboard scope, the card can show rows from any bot. We
448
+ // still proxy to the row's true owner daemon, so global only changes
449
+ // "owner exists + caller mismatch" from 403 to "proxy owner".
450
+ const isGlobal = ctx.url.searchParams.get('scope') === 'global';
451
+ if (!isGlobal && ctx.callerAppId !== undefined && owner !== ctx.callerAppId) {
452
+ return { status: 403, body: { ok: false, error: 'schedule_owner_mismatch' } };
453
+ }
454
+ const upstream = await deps.proxyToDaemon(owner, `/api/schedules/${encodeURIComponent(id)}/${action}`, {
455
+ method: 'POST',
456
+ headers: { 'content-type': 'application/json' },
457
+ body: ctx.bodyRaw.length > 0 ? ctx.bodyRaw : '{}',
458
+ });
459
+ return { status: upstream.status, body: await readUpstream(upstream) };
460
+ },
461
+ },
462
+ ];
463
+ Object.freeze(ROUTES);
464
+ for (const r of ROUTES)
465
+ Object.freeze(r);
466
+ /**
467
+ * Pure dispatcher: matches `(method, path)` against the typed allowlist.
468
+ * Returns `unknown_endpoint` (404) when no path matches, `method_not_allowed`
469
+ * (405) when a path matches but the method does not, or hands off to the
470
+ * matched handler.
471
+ */
472
+ export async function dispatchDaemonInternalRequest(method, url, bodyRaw, deps, callerAppId) {
473
+ let body = undefined;
474
+ if (bodyRaw.length > 0) {
475
+ try {
476
+ body = JSON.parse(bodyRaw);
477
+ }
478
+ catch {
479
+ return { status: 400, body: { ok: false, error: 'bad_json' } };
480
+ }
481
+ }
482
+ const ctx = { bodyRaw, body, url, callerAppId };
483
+ let pathMatchedButMethodWrong = false;
484
+ for (const route of ROUTES) {
485
+ const m = url.pathname.match(route.pathRe);
486
+ if (!m)
487
+ continue;
488
+ if (route.method !== method) {
489
+ pathMatchedButMethodWrong = true;
490
+ continue;
491
+ }
492
+ return route.handle(m, ctx, deps);
493
+ }
494
+ if (pathMatchedButMethodWrong) {
495
+ return { status: 405, body: { ok: false, error: 'method_not_allowed' } };
496
+ }
497
+ return { status: 404, body: { ok: false, error: 'unknown_endpoint' } };
498
+ }
499
+ /** Render a HandlerResult onto a ServerResponse. */
500
+ function writeHandlerResult(res, result) {
501
+ const headers = { 'content-type': 'application/json', ...(result.headers ?? {}) };
502
+ res.writeHead(result.status, headers);
503
+ res.end(typeof result.body === 'string' ? result.body : JSON.stringify(result.body));
504
+ }
505
+ export function createDaemonInternalApi(deps) {
506
+ const nonceStore = deps.nonceStore ?? createNonceStore();
507
+ async function handle(req, res, url) {
508
+ // ⚠️ Source-of-truth for both the `/__daemon/` gate and dispatch routing
509
+ // MUST be `req.url` (the exact bytes the HMAC was computed over). The
510
+ // caller's `url` is only trusted for its `origin` so URL parsing succeeds.
511
+ // Decoupling these allows a signature minted for path X to drive
512
+ // dispatch to path Y if a caller passes a mismatched `url`.
513
+ const reqPath = req.url ?? '/';
514
+ const requestUrl = new URL(reqPath, url.origin);
515
+ if (!requestUrl.pathname.startsWith('/__daemon/'))
516
+ return false;
517
+ const verify = await verifyDaemonRequest(req, deps.secret, nonceStore, { clock: deps.clock });
518
+ if (!verify.ok) {
519
+ writeHandlerResult(res, {
520
+ status: verify.httpStatus,
521
+ body: { ok: false, error: verify.reason },
522
+ });
523
+ return true;
524
+ }
525
+ const result = await dispatchDaemonInternalRequest(req.method ?? 'GET', requestUrl, verify.bodyRaw, deps, verify.appId);
526
+ writeHandlerResult(res, result);
527
+ return true;
528
+ }
529
+ async function dispatchForTest(method, url, bodyRaw = '', callerAppId) {
530
+ return dispatchDaemonInternalRequest(method, url, bodyRaw, deps, callerAppId);
531
+ }
532
+ return { handle, dispatchForTest };
533
+ }
534
+ //# sourceMappingURL=daemon-internal-api.js.map