fraim-framework 2.0.195 → 2.0.197
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/ai-hub/brand-store.js +232 -0
- package/dist/src/ai-hub/server.js +78 -45
- package/dist/src/api/personas/me.js +6 -6
- package/dist/src/cli/utils/org-pack-sync.js +35 -5
- package/dist/src/first-run/types.js +1 -1
- package/dist/src/local-mcp-server/learning-context-builder.js +41 -0
- package/dist/src/services/persona-entitlement-service.js +71 -7
- package/package.json +1 -1
- package/public/ai-hub/index.html +17 -1
- package/public/ai-hub/script.js +469 -22
- package/public/ai-hub/styles.css +67 -0
- package/public/first-run/script.js +98 -25
- package/public/first-run/styles.css +34 -8
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MAX_LOGO_BYTES = exports.ORG_BRAND_FILENAME = void 0;
|
|
7
|
+
exports.resolveBrandColor = resolveBrandColor;
|
|
8
|
+
exports.relativeLuminance = relativeLuminance;
|
|
9
|
+
exports.contrastRatio = contrastRatio;
|
|
10
|
+
exports.hasSufficientContrast = hasSufficientContrast;
|
|
11
|
+
exports.sanitizeSvg = sanitizeSvg;
|
|
12
|
+
exports.sanitizeLogo = sanitizeLogo;
|
|
13
|
+
exports.normalizeBrand = normalizeBrand;
|
|
14
|
+
exports.serializeBrand = serializeBrand;
|
|
15
|
+
exports.parseBrand = parseBrand;
|
|
16
|
+
exports.readBrandFromDir = readBrandFromDir;
|
|
17
|
+
exports.writeBrandToDir = writeBrandToDir;
|
|
18
|
+
/**
|
|
19
|
+
* Org brand store — Issue #744 (Hub cobranding).
|
|
20
|
+
*
|
|
21
|
+
* Deterministic, dependency-free, AI-free logic (architecture-standards §1/§3):
|
|
22
|
+
* the org brand descriptor (company name, color, logo) is stored ALONGSIDE
|
|
23
|
+
* company info, in the same org context storage as `org_context.md`
|
|
24
|
+
* (manager directive, spec R7.4), as a single `org_brand.json` file.
|
|
25
|
+
*
|
|
26
|
+
* Security: a manager-supplied logo is a stored-XSS vector. `sanitizeSvg` uses a
|
|
27
|
+
* strict element/attribute allowlist (not a denylist) and drops scripts, event
|
|
28
|
+
* handlers, external references, styles, and any non-graphical element, so the
|
|
29
|
+
* stored logo is safe to inline in the Hub (spec Compliance: SOC2/ISO27001).
|
|
30
|
+
*/
|
|
31
|
+
const fs_1 = __importDefault(require("fs"));
|
|
32
|
+
const path_1 = __importDefault(require("path"));
|
|
33
|
+
/** File name of the brand descriptor, stored next to org_context.md. */
|
|
34
|
+
exports.ORG_BRAND_FILENAME = 'org_brand.json';
|
|
35
|
+
/** Max stored logo size (sanitized SVG markup or data: URI), in bytes. */
|
|
36
|
+
exports.MAX_LOGO_BYTES = 512 * 1024;
|
|
37
|
+
// ── Color ───────────────────────────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Normalize a hex color to lowercase `#rrggbb`, expanding `#rgb`. Returns null
|
|
40
|
+
* for anything that is not a valid 3- or 6-digit hex (named colors, functions,
|
|
41
|
+
* and injection attempts are rejected).
|
|
42
|
+
*/
|
|
43
|
+
function resolveBrandColor(input) {
|
|
44
|
+
if (typeof input !== 'string')
|
|
45
|
+
return null;
|
|
46
|
+
const raw = input.trim().replace(/^#/, '').toLowerCase();
|
|
47
|
+
if (/^[0-9a-f]{3}$/.test(raw)) {
|
|
48
|
+
return '#' + raw.split('').map((c) => c + c).join('');
|
|
49
|
+
}
|
|
50
|
+
if (/^[0-9a-f]{6}$/.test(raw)) {
|
|
51
|
+
return '#' + raw;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function hexToRgb(hex) {
|
|
56
|
+
const norm = resolveBrandColor(hex);
|
|
57
|
+
if (!norm)
|
|
58
|
+
return null;
|
|
59
|
+
const n = norm.slice(1);
|
|
60
|
+
return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
|
|
61
|
+
}
|
|
62
|
+
/** WCAG relative luminance of an sRGB color. */
|
|
63
|
+
function relativeLuminance(hex) {
|
|
64
|
+
const rgb = hexToRgb(hex);
|
|
65
|
+
if (!rgb)
|
|
66
|
+
return 0;
|
|
67
|
+
const [r, g, b] = rgb.map((v) => {
|
|
68
|
+
const s = v / 255;
|
|
69
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
70
|
+
});
|
|
71
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
72
|
+
}
|
|
73
|
+
/** WCAG contrast ratio between two colors (1..21). */
|
|
74
|
+
function contrastRatio(a, b) {
|
|
75
|
+
const la = relativeLuminance(a);
|
|
76
|
+
const lb = relativeLuminance(b);
|
|
77
|
+
const lighter = Math.max(la, lb);
|
|
78
|
+
const darker = Math.min(la, lb);
|
|
79
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* True when `color` is readable as a UI accent against `background`. Uses the
|
|
83
|
+
* WCAG 3:1 threshold for graphical/large UI objects (the accent is used on
|
|
84
|
+
* filled buttons and tab underlines, not as body text). Guards R2.3 so an
|
|
85
|
+
* unreadable brand color never ships; callers fall back to the FRAIM accent.
|
|
86
|
+
*/
|
|
87
|
+
function hasSufficientContrast(color, background, min = 3.0) {
|
|
88
|
+
if (!resolveBrandColor(color) || !resolveBrandColor(background))
|
|
89
|
+
return false;
|
|
90
|
+
return contrastRatio(color, background) >= min;
|
|
91
|
+
}
|
|
92
|
+
// ── SVG sanitization (allowlist) ──────────────────────────────────────────────
|
|
93
|
+
/** Only these elements survive sanitization. */
|
|
94
|
+
const ALLOWED_SVG_TAGS = new Set([
|
|
95
|
+
'svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon',
|
|
96
|
+
'text', 'tspan', 'defs', 'lineargradient', 'radialgradient', 'stop', 'title', 'desc',
|
|
97
|
+
]);
|
|
98
|
+
/** Only these attributes survive; everything else (incl. on*, href, style) is dropped. */
|
|
99
|
+
const ALLOWED_SVG_ATTRS = new Set([
|
|
100
|
+
'viewbox', 'xmlns', 'width', 'height', 'fill', 'fill-rule', 'fill-opacity', 'stroke',
|
|
101
|
+
'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-opacity',
|
|
102
|
+
'opacity', 'd', 'points', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry',
|
|
103
|
+
'transform', 'offset', 'stop-color', 'stop-opacity', 'gradientunits', 'gradienttransform',
|
|
104
|
+
'font-family', 'font-size', 'font-weight', 'text-anchor', 'letter-spacing',
|
|
105
|
+
]);
|
|
106
|
+
/**
|
|
107
|
+
* Sanitize an SVG string via a strict allowlist. Returns safe SVG markup, or ''
|
|
108
|
+
* if the input is not an <svg> document. Removes: <script>, <foreignObject>,
|
|
109
|
+
* <image>, <use>, <a>, comments/PIs/DOCTYPE, all event handlers, all href /
|
|
110
|
+
* xlink:href, inline styles, and any element or attribute not on the allowlist.
|
|
111
|
+
*/
|
|
112
|
+
function sanitizeSvg(input) {
|
|
113
|
+
if (typeof input !== 'string')
|
|
114
|
+
return '';
|
|
115
|
+
// Bound the work up front so a pathological logo can't drive the tag-walk
|
|
116
|
+
// regex into catastrophic backtracking (DoS guard).
|
|
117
|
+
if (input.length > exports.MAX_LOGO_BYTES)
|
|
118
|
+
return '';
|
|
119
|
+
let s = input.trim();
|
|
120
|
+
if (!/^<svg[\s>]/i.test(s))
|
|
121
|
+
return '';
|
|
122
|
+
// Drop comments, CDATA, processing instructions, and DOCTYPE outright.
|
|
123
|
+
s = s.replace(/<!--[\s\S]*?-->/g, '');
|
|
124
|
+
s = s.replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '');
|
|
125
|
+
s = s.replace(/<\?[\s\S]*?\?>/g, '');
|
|
126
|
+
s = s.replace(/<!DOCTYPE[\s\S]*?>/gi, '');
|
|
127
|
+
// Drop dangerous elements and their entire content.
|
|
128
|
+
s = s.replace(/<(script|style|foreignObject|iframe|use|image|a)\b[\s\S]*?<\/\1\s*>/gi, '');
|
|
129
|
+
// Drop self-closing / unclosed variants of the same dangerous elements.
|
|
130
|
+
s = s.replace(/<(script|style|foreignObject|iframe|use|image|a)\b[^>]*\/?>/gi, '');
|
|
131
|
+
// Walk every remaining tag; drop non-allowlisted elements and attributes.
|
|
132
|
+
s = s.replace(/<\/?([a-zA-Z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (full, rawName, rawAttrs) => {
|
|
133
|
+
const name = rawName.toLowerCase().replace(/^svg:/, '');
|
|
134
|
+
if (!ALLOWED_SVG_TAGS.has(name))
|
|
135
|
+
return '';
|
|
136
|
+
const isClose = /^<\//.test(full);
|
|
137
|
+
if (isClose)
|
|
138
|
+
return `</${name}>`;
|
|
139
|
+
const selfClose = /\/>$/.test(full);
|
|
140
|
+
const attrs = [];
|
|
141
|
+
const attrRe = /([a-zA-Z_:][\w:.-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
142
|
+
let m;
|
|
143
|
+
while ((m = attrRe.exec(rawAttrs)) !== null) {
|
|
144
|
+
const attr = m[1].toLowerCase().replace(/^xml:/, '');
|
|
145
|
+
const value = m[3] !== undefined ? m[3] : m[4] || '';
|
|
146
|
+
if (!ALLOWED_SVG_ATTRS.has(attr))
|
|
147
|
+
continue;
|
|
148
|
+
// Defense in depth: never allow url()/javascript:/data: sneaking through a value.
|
|
149
|
+
if (/javascript:|url\s*\(|data:|expression\s*\(|<|>/i.test(value))
|
|
150
|
+
continue;
|
|
151
|
+
attrs.push(`${attr}="${value.replace(/"/g, '"')}"`);
|
|
152
|
+
}
|
|
153
|
+
const attrStr = attrs.length ? ' ' + attrs.join(' ') : '';
|
|
154
|
+
return `<${name}${attrStr}${selfClose ? '/' : ''}>`;
|
|
155
|
+
});
|
|
156
|
+
// If sanitization emptied the root, treat as invalid.
|
|
157
|
+
if (!/^<svg[\s>]/i.test(s.trim()))
|
|
158
|
+
return '';
|
|
159
|
+
return s.trim();
|
|
160
|
+
}
|
|
161
|
+
/** Accept only png/jpeg data URIs (sanitized SVG is handled separately). */
|
|
162
|
+
function isSafeRasterDataUri(s) {
|
|
163
|
+
return /^data:image\/(png|jpe?g);base64,[a-z0-9+/=\s]+$/i.test(s);
|
|
164
|
+
}
|
|
165
|
+
/** Sanitize a logo value: SVG markup -> sanitized SVG; png/jpeg data URI -> kept; else ''. */
|
|
166
|
+
function sanitizeLogo(input) {
|
|
167
|
+
if (typeof input !== 'string' || !input.trim())
|
|
168
|
+
return '';
|
|
169
|
+
const s = input.trim();
|
|
170
|
+
if (/^<svg[\s>]/i.test(s)) {
|
|
171
|
+
const clean = sanitizeSvg(s);
|
|
172
|
+
return clean.length <= exports.MAX_LOGO_BYTES ? clean : '';
|
|
173
|
+
}
|
|
174
|
+
if (isSafeRasterDataUri(s)) {
|
|
175
|
+
return s.length <= exports.MAX_LOGO_BYTES ? s : '';
|
|
176
|
+
}
|
|
177
|
+
return '';
|
|
178
|
+
}
|
|
179
|
+
// ── Serialize / parse ─────────────────────────────────────────────────────────
|
|
180
|
+
/** Normalize + sanitize a brand into its safe stored form. */
|
|
181
|
+
function normalizeBrand(brand) {
|
|
182
|
+
const out = {};
|
|
183
|
+
if (typeof brand.name === 'string' && brand.name.trim()) {
|
|
184
|
+
out.name = brand.name.trim().slice(0, 120);
|
|
185
|
+
}
|
|
186
|
+
const color = resolveBrandColor(brand.color);
|
|
187
|
+
if (color)
|
|
188
|
+
out.color = color;
|
|
189
|
+
const logo = sanitizeLogo(brand.logo);
|
|
190
|
+
if (logo)
|
|
191
|
+
out.logo = logo;
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
function serializeBrand(brand) {
|
|
195
|
+
return JSON.stringify(normalizeBrand(brand), null, 2);
|
|
196
|
+
}
|
|
197
|
+
/** Parse a brand JSON string into a normalized OrgBrand, or null on any error. */
|
|
198
|
+
function parseBrand(json) {
|
|
199
|
+
if (typeof json !== 'string' || !json.trim())
|
|
200
|
+
return null;
|
|
201
|
+
let obj;
|
|
202
|
+
try {
|
|
203
|
+
obj = JSON.parse(json);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (!obj || typeof obj !== 'object')
|
|
209
|
+
return null;
|
|
210
|
+
const norm = normalizeBrand(obj);
|
|
211
|
+
return Object.keys(norm).length ? norm : null;
|
|
212
|
+
}
|
|
213
|
+
// ── Directory read / write ─────────────────────────────────────────────────────
|
|
214
|
+
/** Read + normalize the brand from `<dir>/org_brand.json`, or null when absent/invalid. */
|
|
215
|
+
function readBrandFromDir(dir) {
|
|
216
|
+
try {
|
|
217
|
+
const file = path_1.default.join(dir, exports.ORG_BRAND_FILENAME);
|
|
218
|
+
if (!fs_1.default.existsSync(file))
|
|
219
|
+
return null;
|
|
220
|
+
return parseBrand(fs_1.default.readFileSync(file, 'utf8'));
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Sanitize + normalize a brand and write it to `<dir>/org_brand.json`. Returns the stored brand. */
|
|
227
|
+
function writeBrandToDir(dir, brand) {
|
|
228
|
+
const normalized = normalizeBrand(brand);
|
|
229
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
230
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, exports.ORG_BRAND_FILENAME), JSON.stringify(normalized, null, 2), 'utf8');
|
|
231
|
+
return normalized;
|
|
232
|
+
}
|
|
@@ -49,6 +49,7 @@ const child_process_1 = require("child_process");
|
|
|
49
49
|
const https_1 = __importDefault(require("https"));
|
|
50
50
|
const types_1 = require("../first-run/types");
|
|
51
51
|
const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
|
|
52
|
+
const brand_store_1 = require("./brand-store");
|
|
52
53
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
53
54
|
const catalog_1 = require("./catalog");
|
|
54
55
|
const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
|
|
@@ -1447,6 +1448,9 @@ class AiHubServer {
|
|
|
1447
1448
|
// #533: the resolved account email, so the profile card shows the real
|
|
1448
1449
|
// identity (not a hardcoded placeholder) and the client can display it.
|
|
1449
1450
|
userEmail: getHubUserEmail(),
|
|
1451
|
+
// #744: the org cobrand identity (name/color/logo) from the org context
|
|
1452
|
+
// storage, or null when unset so the Hub falls back to FRAIM identity.
|
|
1453
|
+
orgBrand: (0, learning_context_builder_1.readOrgBrand)(normalizedProjectPath),
|
|
1450
1454
|
assignments: { byProject: {}, source: 'client-localStorage' },
|
|
1451
1455
|
// Issue #538 — source of truth for the "Hire a human manager" UI. Lazy-required
|
|
1452
1456
|
// (not a top-level import) so the lightweight client CLI paths that import this
|
|
@@ -1971,59 +1975,41 @@ class AiHubServer {
|
|
|
1971
1975
|
}));
|
|
1972
1976
|
try {
|
|
1973
1977
|
// Issue #701: persona state comes from the hosted server (GET /api/personas/me)
|
|
1974
|
-
// via the user's API key — never a local MongoDB connection.
|
|
1975
|
-
// no/expired key or the feature is off; render the locked fallback (not-signed-in).
|
|
1978
|
+
// via the user's API key — never a local MongoDB connection.
|
|
1976
1979
|
const state = await this.remoteGateway.getPersonaState(apiKey);
|
|
1980
|
+
// A null result means we could not reach the authority (no/expired key or a
|
|
1981
|
+
// transport error). That is the ONLY access decision the Hub makes on its own —
|
|
1982
|
+
// render the locked "not-signed-in" fallback. When a state IS returned, its
|
|
1983
|
+
// per-persona `status` is authoritative and rendered verbatim: the hired/locked
|
|
1984
|
+
// decision (feature-off, legacy bypass, per-entitlement gating) lives solely in
|
|
1985
|
+
// persona-entitlement-service.resolvePersonaAccessStatuses — the Hub does not
|
|
1986
|
+
// re-derive it.
|
|
1977
1987
|
if (!state) {
|
|
1978
1988
|
return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
|
|
1979
1989
|
}
|
|
1980
|
-
|
|
1981
|
-
//
|
|
1982
|
-
//
|
|
1983
|
-
if (!state.subscriptionActive) {
|
|
1984
|
-
const allPersonas = allBundles.map((bundle) => ({
|
|
1985
|
-
key: bundle.personaKey,
|
|
1986
|
-
displayName: bundle.catalogMetadata.displayName,
|
|
1987
|
-
role: bundle.catalogMetadata.role,
|
|
1988
|
-
avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
|
|
1989
|
-
pricingLabel: '',
|
|
1990
|
-
status: 'hired',
|
|
1991
|
-
hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
1992
|
-
seatCount: 0,
|
|
1993
|
-
seatsInUse: 0,
|
|
1994
|
-
}));
|
|
1995
|
-
return { personas: allPersonas, subscriptionActive: false, workspaceId: state.workspaceId, userKey: state.userId ?? null };
|
|
1996
|
-
}
|
|
1997
|
-
const hiredKeys = new Set(state.entitlements
|
|
1998
|
-
.filter((e) => e.status === 'active')
|
|
1999
|
-
.map((e) => e.personaKey));
|
|
2000
|
-
// Build per-persona seat counts from entitlement records.
|
|
2001
|
-
// jobCreditsRemaining represents the number of job-credits (seats) purchased.
|
|
2002
|
-
const seatCountByKey = {};
|
|
2003
|
-
for (const e of state.entitlements) {
|
|
2004
|
-
if (e.status === 'active') {
|
|
2005
|
-
seatCountByKey[e.personaKey] = (seatCountByKey[e.personaKey] ?? 0) + (e.jobCreditsRemaining ?? 1);
|
|
2006
|
-
}
|
|
2007
|
-
}
|
|
2008
|
-
// seatsInUse for display: derived from the hosted manager team. Authoritative
|
|
2009
|
-
// out-of-stock enforcement lives on the hosted assign endpoint (server-side),
|
|
2010
|
-
// so this display count does not need to be workspace-wide.
|
|
1990
|
+
const verdictByKey = new Map((state.personas || []).map((p) => [p.personaKey, p]));
|
|
1991
|
+
// seatsInUse (manager-team assignments) is display-only accounting fetched
|
|
1992
|
+
// separately; it is not part of the access verdict.
|
|
2011
1993
|
const seatsInUseByKey = {};
|
|
2012
1994
|
const team = await this.remoteGateway.listManagerTeam(apiKey);
|
|
2013
1995
|
for (const entry of team) {
|
|
2014
1996
|
seatsInUseByKey[entry.personaKey] = (seatsInUseByKey[entry.personaKey] ?? 0) + 1;
|
|
2015
1997
|
}
|
|
2016
|
-
const personas = allBundles.map((bundle) =>
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
1998
|
+
const personas = allBundles.map((bundle) => {
|
|
1999
|
+
const verdict = verdictByKey.get(bundle.personaKey);
|
|
2000
|
+
const status = (verdict?.status ?? 'locked');
|
|
2001
|
+
return {
|
|
2002
|
+
key: bundle.personaKey,
|
|
2003
|
+
displayName: bundle.catalogMetadata.displayName,
|
|
2004
|
+
role: bundle.catalogMetadata.role,
|
|
2005
|
+
avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
|
|
2006
|
+
pricingLabel: status === 'hired' ? '' : bundle.catalogMetadata.pricingLabel,
|
|
2007
|
+
status,
|
|
2008
|
+
hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
2009
|
+
seatCount: verdict?.seatCount ?? 0,
|
|
2010
|
+
seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
|
|
2011
|
+
};
|
|
2012
|
+
});
|
|
2027
2013
|
return { personas, subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey: state.userId ?? null };
|
|
2028
2014
|
}
|
|
2029
2015
|
catch (err) {
|
|
@@ -2320,7 +2306,15 @@ class AiHubServer {
|
|
|
2320
2306
|
? path_1.default.resolve(req.query.projectPath)
|
|
2321
2307
|
: this.projectPath;
|
|
2322
2308
|
const known = this.knownProjects(projectPath);
|
|
2323
|
-
|
|
2309
|
+
// Match by id, falling back to the canonical folderPath. The client and server
|
|
2310
|
+
// hash a path to an id with different algorithms, and the client mints its own id
|
|
2311
|
+
// for the CURRENT project (tfEnsureCurrentProject), so a delete-by-id can carry an
|
|
2312
|
+
// id the server never assigned — the folderPath is the true project identity (#726).
|
|
2313
|
+
const requestedFolderPath = typeof req.query.folderPath === 'string' && req.query.folderPath.length > 0
|
|
2314
|
+
? req.query.folderPath
|
|
2315
|
+
: null;
|
|
2316
|
+
const entry = known.find((project) => project.id === req.params.id)
|
|
2317
|
+
|| (requestedFolderPath ? known.find((project) => sameDirectoryPath(project.folderPath, requestedFolderPath)) : undefined);
|
|
2324
2318
|
if (!entry)
|
|
2325
2319
|
return res.status(404).json({ error: 'Project not found.' });
|
|
2326
2320
|
// The current projectPath is unconditionally re-injected on every load, so
|
|
@@ -2461,6 +2455,45 @@ class AiHubServer {
|
|
|
2461
2455
|
// Re-read so the client gets the canonical post-write state (present flips).
|
|
2462
2456
|
return res.json(readContextFile(projectPath, body.key));
|
|
2463
2457
|
});
|
|
2458
|
+
// Issue #744 — Hub cobranding.
|
|
2459
|
+
// GET /api/ai-hub/brand → { brand: OrgBrand | null } resolved from the org
|
|
2460
|
+
// context storage (same layering as org_context.md).
|
|
2461
|
+
// POST /api/ai-hub/brand { name?, color?, logo?, projectPath? } → sanitizes
|
|
2462
|
+
// + normalizes (SVG allowlist, hex normalize) and persists org_brand.json to
|
|
2463
|
+
// the org context write location. Loopback-only; write is constrained under
|
|
2464
|
+
// a personalized-employee/ directory (same path-traversal guard as context).
|
|
2465
|
+
this.app.get('/api/ai-hub/brand', (req, res) => {
|
|
2466
|
+
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2467
|
+
? path_1.default.resolve(req.query.projectPath)
|
|
2468
|
+
: this.projectPath;
|
|
2469
|
+
return res.json({ brand: (0, learning_context_builder_1.readOrgBrand)(projectPath) });
|
|
2470
|
+
});
|
|
2471
|
+
this.app.post('/api/ai-hub/brand', (req, res) => {
|
|
2472
|
+
const body = (req.body ?? {});
|
|
2473
|
+
const projectPath = typeof body.projectPath === 'string' && body.projectPath.length > 0
|
|
2474
|
+
? path_1.default.resolve(body.projectPath)
|
|
2475
|
+
: this.projectPath;
|
|
2476
|
+
const input = {
|
|
2477
|
+
name: typeof body.name === 'string' ? body.name : undefined,
|
|
2478
|
+
color: typeof body.color === 'string' ? body.color : undefined,
|
|
2479
|
+
logo: typeof body.logo === 'string' ? body.logo : undefined,
|
|
2480
|
+
};
|
|
2481
|
+
const dest = path_1.default.resolve((0, learning_context_builder_1.resolveOrgBrandWriteDir)(projectPath));
|
|
2482
|
+
// Path-traversal guard: the write dir must live under a personalized-employee
|
|
2483
|
+
// directory (matches the context write guard; never the managed org cache).
|
|
2484
|
+
if (!dest.split(path_1.default.sep).includes('personalized-employee')) {
|
|
2485
|
+
return res.status(403).json({ error: 'Brand write destination outside personalized-employee.' });
|
|
2486
|
+
}
|
|
2487
|
+
try {
|
|
2488
|
+
// writeBrandToDir sanitizes the logo (SVG allowlist) and normalizes the
|
|
2489
|
+
// color before persisting, so unsafe input never reaches disk.
|
|
2490
|
+
const stored = (0, brand_store_1.writeBrandToDir)(dest, input);
|
|
2491
|
+
return res.json({ brand: stored });
|
|
2492
|
+
}
|
|
2493
|
+
catch (err) {
|
|
2494
|
+
return res.status(500).json({ error: err instanceof Error ? err.message : 'Brand write failed.' });
|
|
2495
|
+
}
|
|
2496
|
+
});
|
|
2464
2497
|
this.app.post('/api/ai-hub/artifact/open', async (req, res) => {
|
|
2465
2498
|
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '';
|
|
2466
2499
|
const projectPath = typeof req.body?.projectPath === 'string' && req.body.projectPath.length > 0
|
|
@@ -10,13 +10,13 @@ async function getMyPersonas(req, res, dbService) {
|
|
|
10
10
|
res.status(401).json({ error: 'Authentication required' });
|
|
11
11
|
return;
|
|
12
12
|
}
|
|
13
|
+
// Feature OFF => the persona system isn't gating anything, so report the
|
|
14
|
+
// ungated state (every persona accessible) rather than a 404. This makes the
|
|
15
|
+
// authenticated persona view a single source of truth the Hub renders
|
|
16
|
+
// verbatim, and matches the execution gate (maybeBuildPersonaLockResponse),
|
|
17
|
+
// which also treats "flag off" as "allowed". No DB lookup needed.
|
|
13
18
|
if (!(0, feature_flags_1.isPersonaEntitlementsEnabled)()) {
|
|
14
|
-
res.
|
|
15
|
-
error: 'Not found',
|
|
16
|
-
featureFlags: {
|
|
17
|
-
personaEntitlements: false
|
|
18
|
-
}
|
|
19
|
-
});
|
|
19
|
+
res.json((0, persona_entitlement_service_1.buildUngatedPersonaWorkspaceState)(apiKeyData.userId, apiKeyData.key));
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
22
22
|
const state = await (0, persona_entitlement_service_1.getWorkspacePersonaState)(dbService, apiKeyData.userId, apiKeyData.key);
|
|
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.ORG_CACHE_MANAGED_HEADER = exports.ORG_SYNC_METADATA_FILE = exports.ORG_CACHE_DIRNAME = void 0;
|
|
7
|
+
exports.isSafePackPath = isSafePackPath;
|
|
8
|
+
exports.shouldDecorateAsMarkdown = shouldDecorateAsMarkdown;
|
|
7
9
|
exports.getOrgCacheDir = getOrgCacheDir;
|
|
8
10
|
exports.readOrgCacheMetadata = readOrgCacheMetadata;
|
|
9
11
|
exports.getOrgCacheAgeHours = getOrgCacheAgeHours;
|
|
@@ -37,6 +39,28 @@ const ORG_PACK_DIRS = ['context', 'rules', 'learnings'];
|
|
|
37
39
|
* backend response can never write outside the cache directory.
|
|
38
40
|
*/
|
|
39
41
|
const SAFE_PACK_RELATIVE_PATH = /^(context|rules|learnings)\/[\w.-]+\.md$/;
|
|
42
|
+
/**
|
|
43
|
+
* Issue #744: the org brand descriptor is stored ALONGSIDE company info, in the
|
|
44
|
+
* same org context scope as org_context.md, as a single JSON file. Allow exactly
|
|
45
|
+
* that path (not arbitrary JSON) so cobranding syncs like org context.
|
|
46
|
+
*/
|
|
47
|
+
const ORG_BRAND_PACK_PATH = 'context/org_brand.json';
|
|
48
|
+
/**
|
|
49
|
+
* True when a pack-relative path is safe to materialize: a markdown file inside
|
|
50
|
+
* one of the three org pack dirs, or the org brand descriptor. A single guard
|
|
51
|
+
* shared by the git and cloud paths.
|
|
52
|
+
*/
|
|
53
|
+
function isSafePackPath(relativePath) {
|
|
54
|
+
return SAFE_PACK_RELATIVE_PATH.test(relativePath) || relativePath === ORG_BRAND_PACK_PATH;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Managed-content decoration prepends a markdown "synced from your org" header.
|
|
58
|
+
* It must only touch markdown — prepending it to org_brand.json would corrupt
|
|
59
|
+
* the JSON (#744).
|
|
60
|
+
*/
|
|
61
|
+
function shouldDecorateAsMarkdown(relativePath) {
|
|
62
|
+
return relativePath.toLowerCase().endsWith('.md');
|
|
63
|
+
}
|
|
40
64
|
function getOrgCacheDir() {
|
|
41
65
|
return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), exports.ORG_CACHE_DIRNAME);
|
|
42
66
|
}
|
|
@@ -83,10 +107,12 @@ function collectGitPackFiles(snapshotDir) {
|
|
|
83
107
|
if (!fs_1.default.existsSync(dirPath))
|
|
84
108
|
continue;
|
|
85
109
|
for (const entry of fs_1.default.readdirSync(dirPath, { withFileTypes: true })) {
|
|
86
|
-
|
|
110
|
+
const relativePath = `${dirName}/${entry.name}`;
|
|
111
|
+
// Markdown in any pack dir, plus the org brand descriptor (#744).
|
|
112
|
+
if (!entry.isFile() || !isSafePackPath(relativePath))
|
|
87
113
|
continue;
|
|
88
114
|
files.push({
|
|
89
|
-
relativePath
|
|
115
|
+
relativePath,
|
|
90
116
|
content: fs_1.default.readFileSync(path_1.default.join(dirPath, entry.name), 'utf8')
|
|
91
117
|
});
|
|
92
118
|
}
|
|
@@ -101,7 +127,7 @@ async function fetchCloudPack(remoteUrl, apiKey) {
|
|
|
101
127
|
const files = Array.isArray(response.data?.files) ? response.data.files : [];
|
|
102
128
|
return {
|
|
103
129
|
files: files.filter((f) => typeof f?.relativePath === 'string' &&
|
|
104
|
-
|
|
130
|
+
isSafePackPath(f.relativePath) &&
|
|
105
131
|
typeof f?.content === 'string'),
|
|
106
132
|
version: String(response.data?.version ?? '0')
|
|
107
133
|
};
|
|
@@ -113,11 +139,15 @@ function materializeCache(files, metadata) {
|
|
|
113
139
|
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
114
140
|
fs_1.default.mkdirSync(stagingDir, { recursive: true });
|
|
115
141
|
for (const file of files) {
|
|
116
|
-
if (!
|
|
142
|
+
if (!isSafePackPath(file.relativePath))
|
|
117
143
|
continue;
|
|
118
144
|
const destination = path_1.default.join(stagingDir, file.relativePath);
|
|
119
145
|
fs_1.default.mkdirSync(path_1.default.dirname(destination), { recursive: true });
|
|
120
|
-
|
|
146
|
+
// #744: only markdown gets the managed-content header; JSON is written verbatim.
|
|
147
|
+
const content = shouldDecorateAsMarkdown(file.relativePath)
|
|
148
|
+
? decorateManagedOrgFile(file.content, metadata.backend)
|
|
149
|
+
: file.content;
|
|
150
|
+
fs_1.default.writeFileSync(destination, content);
|
|
121
151
|
}
|
|
122
152
|
fs_1.default.writeFileSync(path_1.default.join(stagingDir, exports.ORG_SYNC_METADATA_FILE), JSON.stringify(metadata, null, 2));
|
|
123
153
|
fs_1.default.rmSync(cacheDir, { recursive: true, force: true });
|
|
@@ -14,6 +14,8 @@ exports.buildTeamContextSection = buildTeamContextSection;
|
|
|
14
14
|
exports.resolveTeamContextFiles = resolveTeamContextFiles;
|
|
15
15
|
exports.isTeamContextKey = isTeamContextKey;
|
|
16
16
|
exports.resolveTeamContextFile = resolveTeamContextFile;
|
|
17
|
+
exports.readOrgBrand = readOrgBrand;
|
|
18
|
+
exports.resolveOrgBrandWriteDir = resolveOrgBrandWriteDir;
|
|
17
19
|
exports.countPreservedLearnings = countPreservedLearnings;
|
|
18
20
|
exports.readPreservedLearnings = readPreservedLearnings;
|
|
19
21
|
exports.applyLearningEntryChange = applyLearningEntryChange;
|
|
@@ -21,6 +23,7 @@ exports.isTruthyFlag = isTruthyFlag;
|
|
|
21
23
|
const fs_1 = require("fs");
|
|
22
24
|
const path_1 = require("path");
|
|
23
25
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
26
|
+
const brand_store_1 = require("../ai-hub/brand-store");
|
|
24
27
|
const REPO_LEARNINGS_REL = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('personalized-employee/learnings').replace(/\/$/, '');
|
|
25
28
|
const DEFAULT_THRESHOLD = 3.0;
|
|
26
29
|
const AGING_HORIZON_DAYS = 7;
|
|
@@ -695,6 +698,44 @@ function resolveTeamContextFile(workspaceRoot, key) {
|
|
|
695
698
|
scope
|
|
696
699
|
};
|
|
697
700
|
}
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
// Issue #744 — Org brand (Hub cobranding).
|
|
703
|
+
//
|
|
704
|
+
// The brand descriptor (company name/logo/color) is stored ALONGSIDE company
|
|
705
|
+
// info, in the SAME org context storage as org_context.md (manager directive,
|
|
706
|
+
// spec R7.4), as `context/org_brand.json`. Read resolves with the same org-scope
|
|
707
|
+
// layering as org context (repo-local override → synced org cache → user-level);
|
|
708
|
+
// writes target the same writable location org context edits use.
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
/** Ordered directories to look for org_brand.json, mirroring org-context layering. */
|
|
711
|
+
function orgBrandCandidateDirs(workspaceRoot) {
|
|
712
|
+
return [
|
|
713
|
+
(0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', 'context'),
|
|
714
|
+
(0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'org', 'context'),
|
|
715
|
+
(0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', 'context'),
|
|
716
|
+
];
|
|
717
|
+
}
|
|
718
|
+
/** Read the resolved org brand, or null when none is set on this machine. */
|
|
719
|
+
function readOrgBrand(workspaceRoot) {
|
|
720
|
+
for (const dir of orgBrandCandidateDirs(workspaceRoot)) {
|
|
721
|
+
const brand = (0, brand_store_1.readBrandFromDir)(dir);
|
|
722
|
+
if (brand)
|
|
723
|
+
return brand;
|
|
724
|
+
}
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Directory an org-brand edit should be written to. Mirrors org-context write
|
|
729
|
+
* resolution: the repo-local override dir when the repo carries its own org
|
|
730
|
+
* context, otherwise the portable user-level context dir (where org onboarding
|
|
731
|
+
* writes). Never the synced org cache (managed, overwritten on sync).
|
|
732
|
+
*/
|
|
733
|
+
function resolveOrgBrandWriteDir(workspaceRoot) {
|
|
734
|
+
const loc = resolveTeamContextFile(workspaceRoot, 'org');
|
|
735
|
+
if (loc.writePath)
|
|
736
|
+
return (0, path_1.dirname)(loc.writePath);
|
|
737
|
+
return (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', 'context');
|
|
738
|
+
}
|
|
698
739
|
/**
|
|
699
740
|
* Count the number of `## [P-...]`-style entries inside a preserved learning
|
|
700
741
|
* file. Returns 0 when the file is absent or unreadable. Counts ALL entries
|