crewly 1.20.407 → 1.20.409

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 (86) hide show
  1. package/dist/backend/backend/src/constants.d.ts +34 -0
  2. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  3. package/dist/backend/backend/src/constants.js +34 -0
  4. package/dist/backend/backend/src/constants.js.map +1 -1
  5. package/dist/backend/backend/src/controllers/activity/activity.controller.d.ts +29 -0
  6. package/dist/backend/backend/src/controllers/activity/activity.controller.d.ts.map +1 -0
  7. package/dist/backend/backend/src/controllers/activity/activity.controller.js +77 -0
  8. package/dist/backend/backend/src/controllers/activity/activity.controller.js.map +1 -0
  9. package/dist/backend/backend/src/controllers/google/google.controller.d.ts.map +1 -1
  10. package/dist/backend/backend/src/controllers/google/google.controller.js +3 -0
  11. package/dist/backend/backend/src/controllers/google/google.controller.js.map +1 -1
  12. package/dist/backend/backend/src/controllers/team/team-charter.controller.d.ts.map +1 -1
  13. package/dist/backend/backend/src/controllers/team/team-charter.controller.js +7 -0
  14. package/dist/backend/backend/src/controllers/team/team-charter.controller.js.map +1 -1
  15. package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.d.ts.map +1 -1
  16. package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.js +5 -1
  17. package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.js.map +1 -1
  18. package/dist/backend/backend/src/controllers/team/team-pause.controller.d.ts.map +1 -1
  19. package/dist/backend/backend/src/controllers/team/team-pause.controller.js +9 -0
  20. package/dist/backend/backend/src/controllers/team/team-pause.controller.js.map +1 -1
  21. package/dist/backend/backend/src/controllers/team/team.controller.d.ts.map +1 -1
  22. package/dist/backend/backend/src/controllers/team/team.controller.js +49 -0
  23. package/dist/backend/backend/src/controllers/team/team.controller.js.map +1 -1
  24. package/dist/backend/backend/src/controllers/team-move/team-move.controller.d.ts.map +1 -1
  25. package/dist/backend/backend/src/controllers/team-move/team-move.controller.js +2 -0
  26. package/dist/backend/backend/src/controllers/team-move/team-move.controller.js.map +1 -1
  27. package/dist/backend/backend/src/routes/modules/system.routes.d.ts.map +1 -1
  28. package/dist/backend/backend/src/routes/modules/system.routes.js +3 -0
  29. package/dist/backend/backend/src/routes/modules/system.routes.js.map +1 -1
  30. package/dist/backend/backend/src/services/activity/activity-log.service.d.ts +192 -0
  31. package/dist/backend/backend/src/services/activity/activity-log.service.d.ts.map +1 -0
  32. package/dist/backend/backend/src/services/activity/activity-log.service.js +349 -0
  33. package/dist/backend/backend/src/services/activity/activity-log.service.js.map +1 -0
  34. package/dist/backend/backend/src/services/browser/browser-approval.service.d.ts.map +1 -1
  35. package/dist/backend/backend/src/services/browser/browser-approval.service.js +7 -0
  36. package/dist/backend/backend/src/services/browser/browser-approval.service.js.map +1 -1
  37. package/dist/backend/backend/src/services/budget/team-budget-gate.service.d.ts.map +1 -1
  38. package/dist/backend/backend/src/services/budget/team-budget-gate.service.js +9 -0
  39. package/dist/backend/backend/src/services/budget/team-budget-gate.service.js.map +1 -1
  40. package/dist/backend/backend/src/services/decisions/decision.service.d.ts +13 -0
  41. package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/decisions/decision.service.js +39 -0
  43. package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
  44. package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts.map +1 -1
  45. package/dist/backend/backend/src/services/decisions/decision.wiring.js +2 -0
  46. package/dist/backend/backend/src/services/decisions/decision.wiring.js.map +1 -1
  47. package/dist/backend/backend/src/services/google/gmail-send-approval.service.d.ts.map +1 -1
  48. package/dist/backend/backend/src/services/google/gmail-send-approval.service.js +26 -0
  49. package/dist/backend/backend/src/services/google/gmail-send-approval.service.js.map +1 -1
  50. package/dist/backend/build-info.json +2 -2
  51. package/dist/cli/backend/src/constants.d.ts +34 -0
  52. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  53. package/dist/cli/backend/src/constants.js +34 -0
  54. package/dist/cli/backend/src/constants.js.map +1 -1
  55. package/dist/cli/backend/src/middleware/agent-origin-correction.d.ts +31 -0
  56. package/dist/cli/backend/src/middleware/agent-origin-correction.d.ts.map +1 -0
  57. package/dist/cli/backend/src/middleware/agent-origin-correction.js +31 -0
  58. package/dist/cli/backend/src/middleware/agent-origin-correction.js.map +1 -0
  59. package/dist/cli/backend/src/middleware/api-token.middleware.d.ts +171 -0
  60. package/dist/cli/backend/src/middleware/api-token.middleware.d.ts.map +1 -0
  61. package/dist/cli/backend/src/middleware/api-token.middleware.js +329 -0
  62. package/dist/cli/backend/src/middleware/api-token.middleware.js.map +1 -0
  63. package/dist/cli/backend/src/middleware/caller-identity.middleware.d.ts +290 -0
  64. package/dist/cli/backend/src/middleware/caller-identity.middleware.d.ts.map +1 -0
  65. package/dist/cli/backend/src/middleware/caller-identity.middleware.js +658 -0
  66. package/dist/cli/backend/src/middleware/caller-identity.middleware.js.map +1 -0
  67. package/dist/cli/backend/src/middleware/require-auth.middleware.d.ts +60 -0
  68. package/dist/cli/backend/src/middleware/require-auth.middleware.d.ts.map +1 -0
  69. package/dist/cli/backend/src/middleware/require-auth.middleware.js +127 -0
  70. package/dist/cli/backend/src/middleware/require-auth.middleware.js.map +1 -0
  71. package/dist/cli/backend/src/services/activity/activity-log.service.d.ts +192 -0
  72. package/dist/cli/backend/src/services/activity/activity-log.service.d.ts.map +1 -0
  73. package/dist/cli/backend/src/services/activity/activity-log.service.js +349 -0
  74. package/dist/cli/backend/src/services/activity/activity-log.service.js.map +1 -0
  75. package/dist/cli/backend/src/services/budget/team-budget-gate.service.d.ts.map +1 -1
  76. package/dist/cli/backend/src/services/budget/team-budget-gate.service.js +9 -0
  77. package/dist/cli/backend/src/services/budget/team-budget-gate.service.js.map +1 -1
  78. package/dist/cli/backend/src/services/core/peer-process.service.d.ts +168 -0
  79. package/dist/cli/backend/src/services/core/peer-process.service.d.ts.map +1 -0
  80. package/dist/cli/backend/src/services/core/peer-process.service.js +352 -0
  81. package/dist/cli/backend/src/services/core/peer-process.service.js.map +1 -0
  82. package/dist/cli/backend/src/services/decisions/decision.service.d.ts +13 -0
  83. package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  84. package/dist/cli/backend/src/services/decisions/decision.service.js +39 -0
  85. package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
  86. package/package.json +1 -1
@@ -0,0 +1,658 @@
1
+ /**
2
+ * Caller identity (#999, specs/2026-10-03-owner-auth.md).
3
+ *
4
+ * Decides who made an `/api` request from the credentials it carries — never
5
+ * from a header it left out:
6
+ *
7
+ * | kind | credential |
8
+ * |---------------|---------------------------------------------------------------|
9
+ * | `agent` | a valid agent badge, or (migration window) `X-Agent-Session` |
10
+ * | `relay-owner` | the in-memory relay credential (phone / portal over the relay) |
11
+ * | `cloud` | the in-memory cloud credential (Cloud-forwarded Slack) |
12
+ * | `scheduler` | the scheduler credential a scheduled command's child carries |
13
+ * | `owner` | the owner session cookie (+ CSRF on writes) or the API token |
14
+ * | `anonymous` | nothing of the above — never the owner |
15
+ *
16
+ * `scheduler` is neither an owner nor an agent: it may only queue a plain
17
+ * message to an agent (`POST /terminal/:session/write`, see
18
+ * {@link ownerOrVerifiedAgentOrScheduler}); every other gated route answers it 401.
19
+ *
20
+ * Any sign of an agent wins over an owner credential. The owner API token
21
+ * presented from THIS machine is checked against the process tree: a client
22
+ * process that belongs to an agent is that agent, not the owner.
23
+ *
24
+ * The answer is kept per request in a WeakMap (a client cannot set it) and
25
+ * read through {@link getCallerIdentity}, {@link isOwnerCaller} and
26
+ * {@link rejectNonOwner}.
27
+ *
28
+ * @module middleware/caller-identity
29
+ */
30
+ import { OWNER_AUTH_CONSTANTS, API_SECURITY_CONSTANTS, SCHEDULED_COMMANDS, ORCHESTRATOR_SESSION_NAME } from '../constants.js';
31
+ import { ownerSessionCookieName, verifyAgentBadge, verifyCsrfToken, verifyInternalCredential, verifyOwnerSession, } from '../services/core/owner-auth.service.js';
32
+ import { createHash } from 'crypto';
33
+ import { getApiTokenFingerprint, verifyApiToken } from '../services/core/api-token.service.js';
34
+ import { PeerProcessService, isHostAddress } from '../services/core/peer-process.service.js';
35
+ import { LoggerService } from '../services/core/logger.service.js';
36
+ import { extractPresentedTokenWithSource, parseCookieHeader } from './api-token.middleware.js';
37
+ import { getAgentOriginCorrection } from './agent-origin-correction.js';
38
+ const logger = LoggerService.getInstance().createComponentLogger('CallerIdentity');
39
+ /**
40
+ * Exhaustiveness guard: a new verdict kind must be handled explicitly.
41
+ *
42
+ * @param value - The unhandled value
43
+ * @throws Always
44
+ */
45
+ function assertNever(value) {
46
+ throw new Error(`Unhandled peer verdict: ${JSON.stringify(value)}`);
47
+ }
48
+ /** Sentinel: the answer depends on which local process sent the request. */
49
+ const NEEDS_PEER = Symbol('needs-peer');
50
+ const identities = new WeakMap();
51
+ /**
52
+ * First value of a header.
53
+ *
54
+ * @param req - Request
55
+ * @param name - Lower-case header name
56
+ * @returns Trimmed value or undefined
57
+ */
58
+ function header(req, name) {
59
+ const raw = (req.headers ?? {})[name];
60
+ const value = Array.isArray(raw) ? raw[0] : raw;
61
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
62
+ }
63
+ /**
64
+ * The `X-Agent-Session` (or legacy alias) a request carries.
65
+ *
66
+ * @param req - Request
67
+ * @returns Session or undefined
68
+ */
69
+ function rawSessionHeader(req) {
70
+ return header(req, API_SECURITY_CONSTANTS.AGENT_SESSION_HEADER) ?? header(req, API_SECURITY_CONSTANTS.AGENT_SESSION_HEADER_LEGACY);
71
+ }
72
+ /**
73
+ * The sender label of a scheduled command: `scheduler:<name>`. The name comes
74
+ * from a header the child sets itself, so it only labels: it is kept only if it
75
+ * has the shape of a scheduled-command name (no spaces, brackets or newlines
76
+ * that could forge another sender inside the delivered note).
77
+ *
78
+ * @param name - Presented name
79
+ * @returns `scheduler:<name>`, or `scheduler:unknown`
80
+ */
81
+ function schedulerSender(name) {
82
+ const valid = name !== undefined && SCHEDULED_COMMANDS.NAME_PATTERN.test(name);
83
+ return `${OWNER_AUTH_CONSTANTS.SCHEDULER_SENDER_PREFIX}${valid ? name : 'unknown'}`;
84
+ }
85
+ /**
86
+ * Whether the method changes state.
87
+ *
88
+ * @param req - Request
89
+ * @returns True for POST/PUT/PATCH/DELETE (and for an unknown method — fail safe)
90
+ */
91
+ function isWrite(req) {
92
+ const method = (req.method ?? 'POST').toUpperCase();
93
+ return OWNER_AUTH_CONSTANTS.MUTATING_METHODS.includes(method);
94
+ }
95
+ /**
96
+ * The owner session a request's cookies carry, if any verifies. Every
97
+ * `crewly_owner_*` cookie is tried: the port suffix only keeps instances on
98
+ * one host from overwriting each other, and a cookie from another instance
99
+ * was signed with another secret and simply does not verify.
100
+ *
101
+ * @param req - Request
102
+ * @returns The session, or null
103
+ */
104
+ export function readOwnerSession(req) {
105
+ const cookies = parseCookieHeader(typeof req.headers?.cookie === 'string' ? req.headers.cookie : undefined);
106
+ const preferred = ownerSessionCookieName(req.socket?.localPort);
107
+ const names = [preferred, ...Object.keys(cookies).filter((n) => n !== preferred && n.startsWith(OWNER_AUTH_CONSTANTS.OWNER_SESSION_COOKIE_PREFIX))];
108
+ for (const name of names) {
109
+ const session = verifyOwnerSession(cookies[name]);
110
+ if (session)
111
+ return session;
112
+ }
113
+ return null;
114
+ }
115
+ /**
116
+ * Classify a request. Returns {@link NEEDS_PEER} when the only credential is
117
+ * the owner API token from this machine and no peer verdict was given.
118
+ *
119
+ * @param req - Request
120
+ * @param peer - Verdict on the local client process, when known
121
+ * @returns The identity, or NEEDS_PEER
122
+ */
123
+ function classify(req, peer) {
124
+ const headerSession = rawSessionHeader(req);
125
+ const badgeRaw = header(req, OWNER_AUTH_CONSTANTS.AGENT_BADGE_HEADER);
126
+ const badgeSession = verifyAgentBadge(badgeRaw);
127
+ // 1. A valid badge: always that agent — unless the process tree proved the
128
+ // shell belongs to another agent PTY (a runtime leaked the environment).
129
+ if (badgeSession) {
130
+ const correction = getAgentOriginCorrection(req);
131
+ const session = correction && correction.claimed === badgeSession ? correction.actual : badgeSession;
132
+ return { kind: 'agent', via: 'agent-badge', session, ...(session !== badgeSession ? { note: `process tree overrode badge ${badgeSession}` } : {}) };
133
+ }
134
+ // 2. Any X-Agent-Session: an agent (migration window). Never the owner.
135
+ if (headerSession) {
136
+ return { kind: 'agent', via: 'legacy-header', session: headerSession, ...(badgeRaw ? { note: 'invalid agent badge' } : {}) };
137
+ }
138
+ // An invalid badge with no session header is not anybody we know.
139
+ if (badgeRaw)
140
+ return { kind: 'anonymous', via: 'none', note: 'invalid agent badge' };
141
+ // 3. The in-process forwarders.
142
+ const internal = verifyInternalCredential(header(req, OWNER_AUTH_CONSTANTS.INTERNAL_HEADER));
143
+ if (internal === 'relay')
144
+ return { kind: 'relay-owner', via: 'relay' };
145
+ if (internal === 'cloud')
146
+ return { kind: 'cloud', via: 'cloud' };
147
+ if (internal === 'scheduler')
148
+ return { kind: 'scheduler', via: 'scheduler', sender: schedulerSender(header(req, OWNER_AUTH_CONSTANTS.SCHEDULER_NAME_HEADER)) };
149
+ // 4. The dashboard's session (CSRF on writes).
150
+ let note;
151
+ const ownerSession = readOwnerSession(req);
152
+ if (ownerSession) {
153
+ if (!isWrite(req) || verifyCsrfToken(ownerSession.id, header(req, OWNER_AUTH_CONSTANTS.CSRF_HEADER))) {
154
+ return { kind: 'owner', via: 'owner-session', ownerSessionId: ownerSession.id };
155
+ }
156
+ note = 'owner session without a valid CSRF token on a write';
157
+ }
158
+ // 5. The owner API token.
159
+ const presented = extractPresentedTokenWithSource(req);
160
+ if (presented && verifyApiToken(presented.token)) {
161
+ if (presented.source === 'cookie' && isWrite(req)) {
162
+ return { kind: 'anonymous', via: 'none', note: 'API token only in a cookie on a write' };
163
+ }
164
+ if (!peer) {
165
+ const address = req.socket?.remoteAddress ?? '';
166
+ // An empty address is a socket the sender already closed, not a remote
167
+ // caller: never the owner on that basis (#1010 review).
168
+ if (address && !isHostAddress(address))
169
+ return { kind: 'owner', via: 'api-token' };
170
+ return NEEDS_PEER;
171
+ }
172
+ switch (peer.kind) {
173
+ case 'agent':
174
+ return {
175
+ kind: 'agent',
176
+ via: 'process-tree',
177
+ ...(peer.session ? { session: peer.session } : {}),
178
+ note: `agent process (${peer.signal}) presented the owner API token`,
179
+ };
180
+ case 'gone':
181
+ // The lookup ran and the sender was not there (it exited first — an
182
+ // agent can write over a raw socket and quit). Fail closed.
183
+ return { kind: 'anonymous', via: 'none', note: `owner token from a vanished local process: ${peer.reason}` };
184
+ case 'unknown':
185
+ // The lookup could not run (tool missing, timeout): fail open.
186
+ return { kind: 'owner', via: 'api-token', note: `process check skipped: ${peer.reason}` };
187
+ case 'remote':
188
+ case 'self':
189
+ case 'not-agent':
190
+ return { kind: 'owner', via: 'api-token' };
191
+ default:
192
+ return assertNever(peer);
193
+ }
194
+ }
195
+ return { kind: 'anonymous', via: 'none', ...(note ? { note } : {}) };
196
+ }
197
+ /**
198
+ * Classify without a process lookup. A local API-token caller is anonymous
199
+ * here (it needs {@link callerIdentityMiddleware}'s process check to be the
200
+ * owner); everything else is decided exactly as the middleware would.
201
+ *
202
+ * @param req - Request
203
+ * @returns The identity
204
+ */
205
+ export function classifyCallerSync(req) {
206
+ const result = classify(req);
207
+ return result === NEEDS_PEER ? { kind: 'anonymous', via: 'none', note: 'local API token needs the process check' } : result;
208
+ }
209
+ /**
210
+ * Classify with the process check for a local API-token caller.
211
+ *
212
+ * @param req - Request
213
+ * @param peers - Process classifier
214
+ * @returns The identity
215
+ */
216
+ export async function classifyCaller(req, peers) {
217
+ const first = classify(req);
218
+ if (first !== NEEDS_PEER && first.via === 'legacy-header')
219
+ return confirmLegacyAgent(req, first, peers);
220
+ if (first !== NEEDS_PEER)
221
+ return first;
222
+ const verdict = await peers.classify(req.socket ?? null);
223
+ const second = classify(req, verdict);
224
+ return second === NEEDS_PEER ? { kind: 'anonymous', via: 'none' } : second;
225
+ }
226
+ /** Note on a legacy-header call the process tree confirmed. */
227
+ export const LEGACY_CONFIRMED_NOTE = 'session header confirmed by the process tree';
228
+ /**
229
+ * A call with `X-Agent-Session` but no valid badge (none sent, or one minted
230
+ * by an earlier backend process — the secret is per process) is that agent
231
+ * only when the process tree says so (#1024 review): the client process on
232
+ * the other end of THIS connection (lsof / ss, never the self-reported
233
+ * `X-Agent-Pid`) must run under that same session's agent PTY (or be that
234
+ * session's Crewly Agent child). Then it counts as verified (`process-tree`);
235
+ * otherwise it stays `legacy-header`, which the routes that need proof
236
+ * (terminal writes, key reads) refuse with 403.
237
+ *
238
+ * @param req - Request
239
+ * @param legacy - The legacy-header identity
240
+ * @param peers - Process classifier
241
+ * @returns The confirmed identity, or `legacy` unchanged
242
+ */
243
+ async function confirmLegacyAgent(req, legacy, peers) {
244
+ if (!legacy.session)
245
+ return legacy;
246
+ try {
247
+ const verdict = await peers.classify(req.socket ?? null);
248
+ if (verdict.kind === 'agent' && verdict.session === legacy.session) {
249
+ return { kind: 'agent', via: 'process-tree', session: legacy.session, note: LEGACY_CONFIRMED_NOTE };
250
+ }
251
+ }
252
+ catch {
253
+ /* no verdict: stays unconfirmed */
254
+ }
255
+ return legacy;
256
+ }
257
+ /**
258
+ * Store a request's identity (the middleware; tests).
259
+ *
260
+ * @param req - Request
261
+ * @param identity - Identity
262
+ */
263
+ export function setCallerIdentity(req, identity) {
264
+ identities.set(req, identity);
265
+ }
266
+ /**
267
+ * The identity of a request's caller: what the middleware decided, or a
268
+ * synchronous classification for a request it did not see (unit tests that
269
+ * mount a router alone).
270
+ *
271
+ * @param req - Request
272
+ * @returns The identity
273
+ */
274
+ export function getCallerIdentity(req) {
275
+ return identities.get(req) ?? classifyCallerSync(req);
276
+ }
277
+ /**
278
+ * Whether the caller holds an owner credential: the dashboard session, the
279
+ * owner API token, or the phone / portal relay.
280
+ *
281
+ * @param req - Request
282
+ * @returns True for the owner
283
+ */
284
+ export function isOwnerCaller(req) {
285
+ const { kind } = getCallerIdentity(req);
286
+ return kind === 'owner' || kind === 'relay-owner';
287
+ }
288
+ /**
289
+ * The agent session of the caller, when it is an agent.
290
+ *
291
+ * @param req - Request
292
+ * @returns Session or undefined
293
+ */
294
+ export function callerAgentSession(req) {
295
+ const id = getCallerIdentity(req);
296
+ return id.kind === 'agent' ? id.session : undefined;
297
+ }
298
+ /** The 401 body for a caller with no owner credential. */
299
+ export const OWNER_AUTH_REQUIRED_BODY = Object.freeze({
300
+ success: false,
301
+ error: OWNER_AUTH_CONSTANTS.ERRORS.OWNER_AUTH_REQUIRED,
302
+ code: OWNER_AUTH_CONSTANTS.ERRORS.OWNER_AUTH_REQUIRED,
303
+ message: 'This action needs the owner: use the dashboard on this machine, the phone app, or the API token (`crewly token`).',
304
+ });
305
+ /**
306
+ * Whether a request comes from a browser that holds no valid owner session:
307
+ * a dashboard tab opened before the upgrade (its bundle never asks for a
308
+ * session), or one whose session ended with a backend restart.
309
+ *
310
+ * @param req - Request
311
+ * @returns True for a browser request without a session
312
+ */
313
+ export function isBrowserWithoutSession(req) {
314
+ const h = req.headers ?? {};
315
+ const browser = Boolean(h['sec-fetch-site'] || h['sec-fetch-mode'] || h.origin || h.referer || /Mozilla\//.test(String(h['user-agent'] ?? '')));
316
+ return browser && !readOwnerSession(req);
317
+ }
318
+ /**
319
+ * The 401 body for a caller with no owner credential. A browser without a
320
+ * session gets the plain instruction in `error` — what an old dashboard tab
321
+ * shows the owner — and every body carries `code: owner_auth_required`,
322
+ * which the new dashboard reacts to by refreshing its session and retrying.
323
+ *
324
+ * @param req - Request (optional)
325
+ * @returns Response body
326
+ */
327
+ export function ownerAuthRequiredBody(req) {
328
+ if (req && isBrowserWithoutSession(req)) {
329
+ return {
330
+ success: false,
331
+ error: OWNER_AUTH_CONSTANTS.RELOAD_MESSAGE,
332
+ code: OWNER_AUTH_CONSTANTS.ERRORS.OWNER_AUTH_REQUIRED,
333
+ reload: true,
334
+ message: OWNER_AUTH_CONSTANTS.RELOAD_MESSAGE,
335
+ };
336
+ }
337
+ return { ...OWNER_AUTH_REQUIRED_BODY };
338
+ }
339
+ /**
340
+ * Thrown by handlers that map errors to responses themselves (decisions,
341
+ * project tickets): carries the owner-auth 401 body.
342
+ */
343
+ export class OwnerAuthRequiredError extends Error {
344
+ body;
345
+ status = 401;
346
+ /**
347
+ * @param body - The 401 body ({@link ownerAuthRequiredBody})
348
+ */
349
+ constructor(body) {
350
+ super(String(body.error));
351
+ this.body = body;
352
+ }
353
+ }
354
+ /**
355
+ * Answer 401 owner_auth_required. No `WWW-Authenticate: Crewly-Token`: the
356
+ * dashboard refreshes its session instead of prompting for the API token.
357
+ *
358
+ * @param res - Response
359
+ * @param req - Request, so a browser without a session is told to reload
360
+ */
361
+ export function sendOwnerAuthRequired(res, req) {
362
+ res.status(401).json(ownerAuthRequiredBody(req));
363
+ }
364
+ /**
365
+ * Refuse a caller that is not the owner. Agents get `agentBody` (403, the
366
+ * route's own wording); everyone else without an owner credential gets 401.
367
+ *
368
+ * @param req - Request
369
+ * @param res - Response
370
+ * @param agentBody - 403 body for an agent
371
+ * @returns True when refused (response written)
372
+ *
373
+ * @example
374
+ * ```ts
375
+ * if (rejectNonOwner(req, res, { success: false, error: 'Only the owner can dismiss a ticket' })) return;
376
+ * ```
377
+ */
378
+ export function rejectNonOwner(req, res, agentBody = { success: false, error: OWNER_AUTH_CONSTANTS.ERRORS.OWNER_ONLY, message: 'Only the owner can do this.' }) {
379
+ const id = getCallerIdentity(req);
380
+ if (id.kind === 'owner' || id.kind === 'relay-owner')
381
+ return false;
382
+ if (id.kind === 'agent') {
383
+ res.status(403).json(agentBody);
384
+ return true;
385
+ }
386
+ sendOwnerAuthRequired(res, req);
387
+ return true;
388
+ }
389
+ /**
390
+ * Whether the caller is the orchestrator itself, proven by its badge or the
391
+ * process tree (the bare `X-Agent-Session` header is not proof). No other
392
+ * agent qualifies.
393
+ *
394
+ * @param req - Request
395
+ * @returns True for the verified orchestrator
396
+ */
397
+ export function isOrchestratorCaller(req) {
398
+ const id = getCallerIdentity(req);
399
+ return isVerifiedAgent(id) && id.session === ORCHESTRATOR_SESSION_NAME;
400
+ }
401
+ /**
402
+ * {@link rejectNonOwner}, except that the verified orchestrator passes too.
403
+ *
404
+ * For actions the owner can ask the orchestrator to do on their behalf
405
+ * (owner-away rule: the owner is on the phone and talks to the orc, so a
406
+ * gate that only the dashboard can open leaves the request hanging). Every
407
+ * other agent still gets the route's 403.
408
+ *
409
+ * @param req - Request
410
+ * @param res - Response
411
+ * @param agentBody - 403 body for any other agent
412
+ * @returns True when a response was sent
413
+ */
414
+ export function rejectNonOwnerOrOrchestrator(req, res, agentBody = { success: false, error: OWNER_AUTH_CONSTANTS.ERRORS.OWNER_ONLY, message: 'Only the owner can do this.' }) {
415
+ if (isOrchestratorCaller(req))
416
+ return false;
417
+ return rejectNonOwner(req, res, agentBody);
418
+ }
419
+ /**
420
+ * Express middleware form of {@link rejectNonOwner}.
421
+ *
422
+ * @param req - Request
423
+ * @param res - Response
424
+ * @param next - Next
425
+ */
426
+ export const requireOwner = (req, res, next) => {
427
+ if (rejectNonOwner(req, res))
428
+ return;
429
+ next();
430
+ };
431
+ /**
432
+ * Owner-only decisions with an audit fingerprint (OKR approve/reject, signal
433
+ * digest items). Agents get 403 `owner_approval_required`; a caller with no
434
+ * owner credential gets 401. Before #999 this required the raw API token
435
+ * even from loopback, so the local dashboard could not use it; the owner
436
+ * session now counts too.
437
+ *
438
+ * On success `res.locals.ownerTokenFingerprint` names the credential for the
439
+ * audit trail: 8 hex of sha256(API token), `session-<8 hex>` for a dashboard
440
+ * session, or `relay`.
441
+ *
442
+ * @param req - Request
443
+ * @param res - Response
444
+ * @param next - Next
445
+ */
446
+ export const requireOwnerToken = (req, res, next) => {
447
+ const id = getCallerIdentity(req);
448
+ if (id.kind === 'agent') {
449
+ res.status(403).json({
450
+ success: false,
451
+ error: API_SECURITY_CONSTANTS.ERRORS.OWNER_APPROVAL_REQUIRED,
452
+ hint: 'This decision must be made by the owner from the dashboard or with the API token, not by an agent session.',
453
+ });
454
+ return;
455
+ }
456
+ if (id.kind !== 'owner' && id.kind !== 'relay-owner') {
457
+ sendOwnerAuthRequired(res, req);
458
+ return;
459
+ }
460
+ if (id.via === 'api-token') {
461
+ res.locals.ownerTokenFingerprint = getApiTokenFingerprint(extractPresentedTokenWithSource(req)?.token);
462
+ }
463
+ else if (id.via === 'owner-session' && id.ownerSessionId) {
464
+ res.locals.ownerTokenFingerprint = `session-${createHash('sha256').update(id.ownerSessionId).digest('hex').slice(0, 8)}`;
465
+ }
466
+ else {
467
+ res.locals.ownerTokenFingerprint = id.via;
468
+ }
469
+ next();
470
+ };
471
+ /**
472
+ * {@link requireOwner} with the route's own 403 wording for agents.
473
+ *
474
+ * @param agentBody - 403 body for an agent
475
+ * @returns Express middleware
476
+ */
477
+ export function ownerOnly(agentBody) {
478
+ return (req, res, next) => {
479
+ if (rejectNonOwner(req, res, agentBody))
480
+ return;
481
+ next();
482
+ };
483
+ }
484
+ /**
485
+ * Whether an identity is an agent proven by a credential: its badge, or the
486
+ * process tree (an agent PTY's process presented the owner token). The
487
+ * legacy `X-Agent-Session` header alone is not proof — any local process can
488
+ * set it.
489
+ *
490
+ * @param identity - Caller identity
491
+ * @returns True for a badge or process-tree agent with a known session
492
+ */
493
+ export function isVerifiedAgent(identity) {
494
+ return identity.kind === 'agent' && Boolean(identity.session) && (identity.via === 'agent-badge' || identity.via === 'process-tree');
495
+ }
496
+ /**
497
+ * Refuse a caller that is neither the owner nor a verified agent
498
+ * ({@link isVerifiedAgent}). An agent with only the legacy header gets 403
499
+ * `agent_badge_required`; everyone else without a credential gets 401.
500
+ *
501
+ * @param req - Request
502
+ * @param res - Response
503
+ * @param action - What the caller tried, for the 403 message (e.g. "Writing into an agent's terminal")
504
+ * @returns True when refused (response written)
505
+ */
506
+ export function rejectUnverifiedCaller(req, res, action) {
507
+ const id = getCallerIdentity(req);
508
+ if (id.kind === 'owner' || id.kind === 'relay-owner' || isVerifiedAgent(id))
509
+ return false;
510
+ if (id.kind === 'agent') {
511
+ res.status(403).json({
512
+ success: false,
513
+ error: OWNER_AUTH_CONSTANTS.ERRORS.AGENT_BADGE_REQUIRED,
514
+ code: OWNER_AUTH_CONSTANTS.ERRORS.AGENT_BADGE_REQUIRED,
515
+ message: `${action} needs the agent badge (CREWLY_AGENT_BADGE). Restart the agent so it gets one.`,
516
+ });
517
+ return true;
518
+ }
519
+ sendOwnerAuthRequired(res, req);
520
+ return true;
521
+ }
522
+ /**
523
+ * Express middleware form of {@link rejectUnverifiedCaller}: the owner, or an
524
+ * agent identified by its badge (or the process tree).
525
+ *
526
+ * @param action - What the route does, for the 403 message
527
+ * @returns Express middleware
528
+ *
529
+ * @example
530
+ * ```ts
531
+ * router.post('/terminal/:s/write', ownerOrVerifiedAgent("Writing into an agent's terminal"), handler);
532
+ * ```
533
+ */
534
+ export function ownerOrVerifiedAgent(action) {
535
+ return (req, res, next) => {
536
+ if (rejectUnverifiedCaller(req, res, action))
537
+ return;
538
+ next();
539
+ };
540
+ }
541
+ /**
542
+ * {@link ownerOrVerifiedAgent} for the one route a scheduled command may use:
543
+ * delivering a plain message to an agent. A `scheduler` caller passes only with
544
+ * `mode: "message"` and no `workItemId` (a hand-over can clear the agent's
545
+ * conversation); anything else is 403 `scheduler_message_only`. Owner and
546
+ * verified agents are treated exactly as {@link ownerOrVerifiedAgent} does.
547
+ *
548
+ * @param action - What the route does, for the 403 message
549
+ * @returns Express middleware
550
+ */
551
+ export function ownerOrVerifiedAgentOrScheduler(action) {
552
+ return (req, res, next) => {
553
+ if (getCallerIdentity(req).kind === 'scheduler') {
554
+ const body = (req.body ?? {});
555
+ if (body.mode !== 'message' || body.workItemId !== undefined) {
556
+ res.status(403).json({
557
+ success: false,
558
+ error: OWNER_AUTH_CONSTANTS.ERRORS.SCHEDULER_MESSAGE_ONLY,
559
+ code: OWNER_AUTH_CONSTANTS.ERRORS.SCHEDULER_MESSAGE_ONLY,
560
+ message: `${action}: a scheduled command may only deliver a plain message (mode "message", no workItemId).`,
561
+ });
562
+ return;
563
+ }
564
+ next();
565
+ return;
566
+ }
567
+ if (rejectUnverifiedCaller(req, res, action))
568
+ return;
569
+ next();
570
+ };
571
+ }
572
+ const lastWarned = new Map();
573
+ /**
574
+ * Warn at most once per LEGACY_WARN_THROTTLE_MS per key.
575
+ *
576
+ * @param key - Throttle key
577
+ * @param message - Message
578
+ * @param meta - Fields
579
+ */
580
+ function warnThrottled(key, message, meta) {
581
+ const now = Date.now();
582
+ const last = lastWarned.get(key);
583
+ if (last !== undefined && now - last < OWNER_AUTH_CONSTANTS.LEGACY_WARN_THROTTLE_MS)
584
+ return;
585
+ if (lastWarned.size > 1000)
586
+ lastWarned.clear();
587
+ lastWarned.set(key, now);
588
+ logger.warn(message, meta);
589
+ }
590
+ /**
591
+ * Point `X-Agent-Session` at the identified agent, so code that reads the
592
+ * header sees the authenticated identity. Never adds the header: a call
593
+ * that left it out on purpose (`remote-browser --no-bind`) keeps it out.
594
+ *
595
+ * @param req - Request (headers mutated)
596
+ * @param identity - Its identity
597
+ */
598
+ function normaliseSessionHeader(req, identity) {
599
+ if (identity.kind !== 'agent' || !identity.session)
600
+ return;
601
+ for (const name of [API_SECURITY_CONSTANTS.AGENT_SESSION_HEADER, API_SECURITY_CONSTANTS.AGENT_SESSION_HEADER_LEGACY]) {
602
+ const current = header(req, name);
603
+ if (current !== undefined && current !== identity.session)
604
+ req.headers[name] = identity.session;
605
+ }
606
+ }
607
+ /**
608
+ * Build the `/api` middleware.
609
+ *
610
+ * @param peers - Process classifier. The server passes one that knows the
611
+ * live agent PTYs (`liveSessionPids` from the agent-origin middleware), so
612
+ * the terminal check and session names work; without it only the
613
+ * ancestry and environment checks run.
614
+ * @returns Express middleware
615
+ */
616
+ export function createCallerIdentityMiddleware(peers = new PeerProcessService()) {
617
+ return (req, _res, next) => {
618
+ classifyCaller(req, peers)
619
+ .then((identity) => {
620
+ identities.set(req, identity);
621
+ normaliseSessionHeader(req, identity);
622
+ if (identity.via === 'legacy-header') {
623
+ warnThrottled(`legacy:${identity.session}`, 'Agent call without a valid agent badge, and the process tree did not confirm the session — treated as that agent only where a bare session header is still accepted; terminal writes and API-key reads refuse it (403 agent_badge_required). Restart the agent so it gets CREWLY_AGENT_BADGE.', {
624
+ session: identity.session,
625
+ path: req.path,
626
+ });
627
+ }
628
+ else if (identity.note === LEGACY_CONFIRMED_NOTE) {
629
+ warnThrottled(`legacy-ok:${identity.session}`, 'Agent call without a valid agent badge — accepted because its process runs under that agent\'s PTY. Restart the agent so it gets a current CREWLY_AGENT_BADGE.', {
630
+ session: identity.session,
631
+ path: req.path,
632
+ });
633
+ }
634
+ else if (identity.via === 'process-tree') {
635
+ warnThrottled(`tree:${identity.session ?? '?'}`, 'An agent process presented the owner API token — treated as that agent, not the owner.', {
636
+ session: identity.session,
637
+ path: req.path,
638
+ });
639
+ }
640
+ else if (identity.note?.startsWith('process check skipped')) {
641
+ warnThrottled('peer-unknown', 'Could not tell which local process sent an owner-token request — accepted as the owner (fail open).', {
642
+ reason: identity.note,
643
+ path: req.path,
644
+ });
645
+ }
646
+ next();
647
+ })
648
+ .catch((error) => {
649
+ // Classification never throws in practice; if it does, the request is anonymous.
650
+ identities.set(req, { kind: 'anonymous', via: 'none', note: 'classification failed' });
651
+ logger.warn('Caller classification failed — treating the request as anonymous', {
652
+ error: error instanceof Error ? error.message : String(error),
653
+ });
654
+ next();
655
+ });
656
+ };
657
+ }
658
+ //# sourceMappingURL=caller-identity.middleware.js.map