fraim 2.0.226 → 2.0.228

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.
@@ -0,0 +1,253 @@
1
+ "use strict";
2
+ /**
3
+ * Handoff Contract Validation (Issue #916)
4
+ *
5
+ * Pure helpers for validating the three evidence fields that FRAIM jobs must
6
+ * emit on key seekMentoring calls so the Hub can render its review surfaces:
7
+ *
8
+ * - evidence.reviewHandoff (submit phase, status === "awaiting_mentor")
9
+ * - evidence.nextJobRecommendations (retrospective phase completion)
10
+ * - evidence.delegationLedger (create-delegation-graph phase)
11
+ *
12
+ * Lives in src/core so both the local MCP proxy and evals can import the same
13
+ * contract without cross-layer dependencies — same pattern as quality-evidence.ts.
14
+ *
15
+ * Primary call site: src/local-mcp-server/stdio-server.ts seekMentoring handler,
16
+ * after the existing quality enforcement block.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.isSubmitPhase = isSubmitPhase;
20
+ exports.isRetrospectivePhase = isRetrospectivePhase;
21
+ exports.isDelegationGraphPhase = isDelegationGraphPhase;
22
+ exports.validateReviewHandoff = validateReviewHandoff;
23
+ exports.validateNextJobRecommendations = validateNextJobRecommendations;
24
+ exports.validateDelegationLedger = validateDelegationLedger;
25
+ exports.validateHandoffContracts = validateHandoffContracts;
26
+ exports.buildHandoffRejectionMessage = buildHandoffRejectionMessage;
27
+ // ---------------------------------------------------------------------------
28
+ // Phase detection
29
+ // ---------------------------------------------------------------------------
30
+ /**
31
+ * Returns true when a seekMentoring call is a submit-phase handoff requiring
32
+ * a reviewHandoff evidence field. The canonical signal is status === "awaiting_mentor"
33
+ * — set only by submit-phase jobs requesting a review gate — rather than a
34
+ * hardcoded phase name list, which is fragile for personalized jobs.
35
+ */
36
+ function isSubmitPhase(currentPhase, status) {
37
+ return status === 'awaiting_mentor';
38
+ }
39
+ /**
40
+ * Returns true when a seekMentoring call is a retrospective completion.
41
+ * Retrospective jobs emit nextJobRecommendations at this boundary.
42
+ */
43
+ function isRetrospectivePhase(currentPhase, status) {
44
+ return currentPhase === 'retrospective' && status === 'complete';
45
+ }
46
+ /**
47
+ * Returns true when a seekMentoring call is the delegation graph phase.
48
+ * This matches the existing Hub test fixture for the delegation board.
49
+ */
50
+ function isDelegationGraphPhase(currentPhase) {
51
+ return currentPhase === 'create-delegation-graph';
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // Individual validators
55
+ // ---------------------------------------------------------------------------
56
+ /**
57
+ * Validates evidence.reviewHandoff. Returns null if valid, or an array of
58
+ * human-readable error strings describing what is wrong.
59
+ *
60
+ * Required minimum shape:
61
+ * {
62
+ * reviewRequired: boolean,
63
+ * reviewTarget: object | null,
64
+ * artifacts: array,
65
+ * }
66
+ */
67
+ function validateReviewHandoff(value) {
68
+ if (value === undefined || value === null) {
69
+ return ['evidence.reviewHandoff is missing'];
70
+ }
71
+ if (typeof value !== 'object' || Array.isArray(value)) {
72
+ return ['evidence.reviewHandoff must be an object'];
73
+ }
74
+ const obj = value;
75
+ const errors = [];
76
+ if (typeof obj.reviewRequired !== 'boolean') {
77
+ errors.push(`evidence.reviewHandoff.reviewRequired must be a boolean (got ${obj.reviewRequired === undefined ? 'missing' : typeof obj.reviewRequired})`);
78
+ }
79
+ if (obj.reviewRequired === true) {
80
+ if (!Array.isArray(obj.reviewActions) || obj.reviewActions.length === 0) {
81
+ errors.push('evidence.reviewHandoff.reviewActions must be a non-empty array when reviewRequired is true');
82
+ }
83
+ else {
84
+ const actions = obj.reviewActions;
85
+ const hasApprove = actions.some((a) => a.kind === 'approve');
86
+ const hasRequestChanges = actions.some((a) => a.kind === 'request_changes');
87
+ if (!hasApprove) {
88
+ errors.push('evidence.reviewHandoff.reviewActions must include an entry with kind: "approve"');
89
+ }
90
+ if (!hasRequestChanges) {
91
+ errors.push('evidence.reviewHandoff.reviewActions must include an entry with kind: "request_changes"');
92
+ }
93
+ }
94
+ }
95
+ return errors.length > 0 ? errors : null;
96
+ }
97
+ /**
98
+ * Validates evidence.nextJobRecommendations. Returns null if valid, or an
99
+ * array of error strings.
100
+ *
101
+ * Required minimum shape: an array (may be empty) of entries, each with:
102
+ * { jobId: string, label: string, reason?: string, contextSummary?: string }
103
+ */
104
+ function validateNextJobRecommendations(value) {
105
+ if (value === undefined || value === null) {
106
+ return ['evidence.nextJobRecommendations is missing'];
107
+ }
108
+ if (!Array.isArray(value)) {
109
+ return ['evidence.nextJobRecommendations must be an array'];
110
+ }
111
+ const errors = [];
112
+ value.forEach((entry, i) => {
113
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
114
+ errors.push(`evidence.nextJobRecommendations[${i}] must be an object`);
115
+ return;
116
+ }
117
+ const rec = entry;
118
+ if (typeof rec.jobId !== 'string' || !rec.jobId.trim()) {
119
+ errors.push(`evidence.nextJobRecommendations[${i}].jobId must be a non-empty string`);
120
+ }
121
+ if (typeof rec.label !== 'string' || !rec.label.trim()) {
122
+ errors.push(`evidence.nextJobRecommendations[${i}].label must be a non-empty string`);
123
+ }
124
+ });
125
+ return errors.length > 0 ? errors : null;
126
+ }
127
+ /**
128
+ * Validates evidence.delegationLedger. Returns null if valid, or an array
129
+ * of error strings.
130
+ *
131
+ * Required minimum shape:
132
+ * {
133
+ * delegationRequired: boolean,
134
+ * tasks: array,
135
+ * }
136
+ */
137
+ function validateDelegationLedger(value) {
138
+ if (value === undefined || value === null) {
139
+ return ['evidence.delegationLedger is missing'];
140
+ }
141
+ if (typeof value !== 'object' || Array.isArray(value)) {
142
+ return ['evidence.delegationLedger must be an object'];
143
+ }
144
+ const obj = value;
145
+ const errors = [];
146
+ if (typeof obj.delegationRequired !== 'boolean') {
147
+ errors.push(`evidence.delegationLedger.delegationRequired must be a boolean (got ${obj.delegationRequired === undefined ? 'missing' : typeof obj.delegationRequired})`);
148
+ }
149
+ if (!('tasks' in obj)) {
150
+ errors.push('evidence.delegationLedger.tasks is missing');
151
+ }
152
+ else if (!Array.isArray(obj.tasks)) {
153
+ errors.push('evidence.delegationLedger.tasks must be an array');
154
+ }
155
+ return errors.length > 0 ? errors : null;
156
+ }
157
+ // ---------------------------------------------------------------------------
158
+ // Orchestrator: called from the seekMentoring handler
159
+ // ---------------------------------------------------------------------------
160
+ /**
161
+ * Validates all handoff contracts that apply to the given seekMentoring call.
162
+ * Returns an empty array when no contract is violated; otherwise returns the
163
+ * error strings from whichever applicable validator(s) failed.
164
+ */
165
+ function validateHandoffContracts(args) {
166
+ const phase = args.currentPhase ?? '';
167
+ const status = args.status ?? '';
168
+ const evidence = args.evidence ?? {};
169
+ if (isSubmitPhase(phase, status)) {
170
+ const errors = validateReviewHandoff(evidence.reviewHandoff);
171
+ if (errors)
172
+ return errors;
173
+ }
174
+ if (isRetrospectivePhase(phase, status)) {
175
+ const errors = validateNextJobRecommendations(evidence.nextJobRecommendations);
176
+ if (errors)
177
+ return errors;
178
+ }
179
+ if (isDelegationGraphPhase(phase)) {
180
+ const errors = validateDelegationLedger(evidence.delegationLedger);
181
+ if (errors)
182
+ return errors;
183
+ }
184
+ return [];
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // Rejection message builder
188
+ // ---------------------------------------------------------------------------
189
+ const REVIEW_HANDOFF_SCHEMA = `\`\`\`javascript
190
+ evidence: {
191
+ reviewHandoff: {
192
+ reviewRequired: true,
193
+ reviewTarget: { kind: "pull_request", url: "<PR URL>" } | { kind: "artifact_set", files: ["<path>"] } | null,
194
+ artifacts: [{ label: "<label>", path: "<path>", kind: "<kind>" }],
195
+ summary: "<optional summary>",
196
+ reviewActions: [
197
+ { kind: "approve", label: "Approve" },
198
+ { kind: "request_changes", label: "Request Changes" }
199
+ ]
200
+ }
201
+ }
202
+ \`\`\``;
203
+ const NEXT_JOB_RECOMMENDATIONS_SCHEMA = `\`\`\`javascript
204
+ evidence: {
205
+ nextJobRecommendations: [
206
+ {
207
+ jobId: "<job-slug>",
208
+ label: "<human-readable label>",
209
+ reason: "<optional: why this job is recommended>",
210
+ contextSummary: "<optional: one sentence context for the next agent>"
211
+ }
212
+ // 0–3 entries; an empty array [] is valid
213
+ ]
214
+ }
215
+ \`\`\``;
216
+ const DELEGATION_LEDGER_SCHEMA = `\`\`\`javascript
217
+ evidence: {
218
+ delegationLedger: {
219
+ delegationRequired: true,
220
+ objective: "<optional objective string>",
221
+ tasks: [
222
+ { jobId: "<job-slug>", personaKey: "<persona key or null>", briefing: "<optional briefing>" }
223
+ ]
224
+ }
225
+ }
226
+ \`\`\``;
227
+ const FIELD_SCHEMAS = {
228
+ reviewHandoff: REVIEW_HANDOFF_SCHEMA,
229
+ nextJobRecommendations: NEXT_JOB_RECOMMENDATIONS_SCHEMA,
230
+ delegationLedger: DELEGATION_LEDGER_SCHEMA,
231
+ };
232
+ /**
233
+ * Builds the rejection message returned to the agent when a handoff contract
234
+ * is violated. The message names the missing field and provides the required
235
+ * minimum schema so the agent can fix everything in one retry.
236
+ */
237
+ function buildHandoffRejectionMessage(currentPhase, field, errors) {
238
+ const errorBullets = errors.map((e) => `- ${e}`).join('\n');
239
+ const schemaHint = FIELD_SCHEMAS[field] ?? '';
240
+ return [
241
+ `❌ **seekMentoring rejected** at phase \`${currentPhase}\`.`,
242
+ '',
243
+ `This call requires \`evidence.${field}\` to be present and valid so the Hub can render the correct management surface. The following problems were found:`,
244
+ '',
245
+ errorBullets,
246
+ '',
247
+ `Required minimum schema for \`evidence.${field}\`:`,
248
+ '',
249
+ schemaHint,
250
+ '',
251
+ `The job is **not** marked complete. Add the \`evidence.${field}\` object and resubmit this phase.`,
252
+ ].join('\n');
253
+ }
@@ -63,6 +63,7 @@ const object_utils_1 = require("../core/utils/object-utils");
63
63
  const local_registry_resolver_1 = require("../core/utils/local-registry-resolver");
64
64
  const ai_mentor_1 = require("../core/ai-mentor");
65
65
  const quality_evidence_1 = require("../core/quality-evidence");
66
+ const handoff_contracts_1 = require("../core/handoff-contracts");
66
67
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
67
68
  const usage_collector_js_1 = require("./usage-collector.js");
68
69
  const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
@@ -2151,6 +2152,26 @@ class FraimLocalMCPServer {
2151
2152
  this.log(`⚠️ Quality score emission failed: ${err?.message || err}`);
2152
2153
  });
2153
2154
  }
2155
+ // Handoff contract enforcement (Issue #916).
2156
+ //
2157
+ // The proxy is the primary enforcement point for reviewHandoff,
2158
+ // nextJobRecommendations, and delegationLedger evidence fields.
2159
+ // The Hub cannot render review bars, next-job chips, or the
2160
+ // delegation board when these fields are absent.
2161
+ const handoffErrors = (0, handoff_contracts_1.validateHandoffContracts)({
2162
+ jobName: args.jobName,
2163
+ currentPhase: args.currentPhase,
2164
+ status: args.status,
2165
+ evidence: args.evidence,
2166
+ });
2167
+ if (handoffErrors.length > 0) {
2168
+ const missingField = handoffErrors[0].includes('reviewHandoff') ? 'reviewHandoff'
2169
+ : handoffErrors[0].includes('nextJobRecommendations') ? 'nextJobRecommendations'
2170
+ : 'delegationLedger';
2171
+ this.log(`⚠️ Handoff contract enforcement rejected seekMentoring for ${args.jobName}:${args.currentPhase}: ${handoffErrors.join('; ')}`);
2172
+ const rejection = (0, handoff_contracts_1.buildHandoffRejectionMessage)(args.currentPhase, missingField, handoffErrors);
2173
+ return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, rejection);
2174
+ }
2154
2175
  return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, tutoringResponse.message);
2155
2176
  }
2156
2177
  catch (error) {
@@ -30,9 +30,21 @@ class AuthMiddleware {
30
30
  // Skip auth for public health check, admin routes, website signup, sales inquiries, and self-serve access flow
31
31
  const rawPath = req.path ?? req.originalUrl ?? req.url ?? '';
32
32
  const p = (typeof rawPath === 'string' ? rawPath : '').split('?')[0].replace(/\/$/, '') || '';
33
+ const publicBaseUrl = (process.env.FRAIM_PUBLIC_BASE_URL || 'https://fraim.wellnessatwork.me').replace(/\/+$/, '');
34
+ const mcpResourceMetadataUrl = `${publicBaseUrl}/.well-known/oauth-protected-resource/mcp`;
35
+ const isMcpPath = p === '/mcp' || p.startsWith('/mcp/');
36
+ const setMcpAuthenticateChallenge = () => {
37
+ if (isMcpPath) {
38
+ res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${mcpResourceMetadataUrl}"`);
39
+ }
40
+ };
33
41
  const publicPrefixes = ['/admin', '/dashboard', '/health', '/pricing', '/fraim-brain', '/api/signup', '/api/sales', '/api/request-access', '/api/installer-key', '/api/installer-download', '/api/installer-availability', '/api/payment/bypass', '/api/pricing', '/api/personas/catalog', '/auth', '/portfolio'];
34
42
  // Analytics dashboard is public, but API routes are protected
35
43
  const isAnalyticsPublic = p === '/analytics' || p === '/analytics/' || p.startsWith('/analytics/') && p.endsWith('.html');
44
+ const isOAuthMetadataPublic = p === '/.well-known/oauth-authorization-server'
45
+ || p === '/.well-known/oauth-protected-resource'
46
+ || p === '/.well-known/oauth-protected-resource/mcp'
47
+ || p === '/.well-known/openai-apps-challenge';
36
48
  // Homepage is public
37
49
  if (p === '' || p === '/')
38
50
  return next();
@@ -40,7 +52,8 @@ class AuthMiddleware {
40
52
  const isPublic = publicPrefixes.some(prefix => p === prefix || p.startsWith(prefix + '/'))
41
53
  || publicExtensions.some(ext => p.endsWith(ext))
42
54
  || p.startsWith('/css/') || p.startsWith('/js/') || p.startsWith('/images/')
43
- || isAnalyticsPublic;
55
+ || isAnalyticsPublic
56
+ || isOAuthMetadataPublic;
44
57
  if (isPublic) {
45
58
  return next();
46
59
  }
@@ -102,6 +115,7 @@ class AuthMiddleware {
102
115
  reason: !session ? 'not_found' : session.revoked ? 'revoked' : 'expired',
103
116
  outcome: 'failure',
104
117
  }).catch(() => undefined);
118
+ setMcpAuthenticateChallenge();
105
119
  return res.status(401).json({
106
120
  jsonrpc: '2.0',
107
121
  error: { code: -32001, message: 'Unauthorized: Session expired or revoked [AUTH-SESSION-INVALID]' },
@@ -111,6 +125,7 @@ class AuthMiddleware {
111
125
  const apiKeyData = await this.dbService.getApiKeyByUserId(session.userId, false);
112
126
  if (!apiKeyData) {
113
127
  console.error(`[FRAIM AUTH] Session valid but no API key for user ${session.userId}`);
128
+ setMcpAuthenticateChallenge();
114
129
  return res.status(401).json({
115
130
  jsonrpc: '2.0',
116
131
  error: { code: -32001, message: 'Unauthorized: No identity for session [AUTH-NO-IDENTITY]' },
@@ -146,6 +161,7 @@ class AuthMiddleware {
146
161
  outcome: 'failure',
147
162
  reason: `session_user=${session.userId}`,
148
163
  }).catch(() => undefined);
164
+ setMcpAuthenticateChallenge();
149
165
  return res.status(401).json({
150
166
  jsonrpc: '2.0',
151
167
  error: { code: -32001, message: 'Unauthorized: Mixed identity [AUTH-MIXED-IDENTITY]' },
@@ -169,6 +185,7 @@ class AuthMiddleware {
169
185
  return res.status(404).send('<h1>404</h1><p>Page not found.</p>');
170
186
  }
171
187
  console.error(`[FRAIM AUTH] Missing API key for ${req.method} ${req.path}`);
188
+ setMcpAuthenticateChallenge();
172
189
  return res.status(401).json({
173
190
  jsonrpc: '2.0',
174
191
  error: { code: -32001, message: 'Unauthorized: Missing API key [AUTH-MISSING]' },
@@ -194,6 +211,7 @@ class AuthMiddleware {
194
211
  const apiKeyData = await this.dbService.verifyApiKey(apiKey);
195
212
  if (!apiKeyData) {
196
213
  console.error(`❌ FRAIM AUTH: Invalid API key: ${apiKey}`);
214
+ setMcpAuthenticateChallenge();
197
215
  return res.status(401).json({
198
216
  jsonrpc: '2.0',
199
217
  error: { code: -32001, message: 'Unauthorized: Invalid x-api-key [AUTH-INVALID]' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.226",
3
+ "version": "2.0.228",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@octokit/rest": "^22.0.1",
41
- "adm-zip": "^0.5.16",
41
+ "adm-zip": "^0.6.0",
42
42
  "axios": "^1.7.0",
43
43
  "chalk": "4.1.2",
44
44
  "commander": "^14.0.2",