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.
@@ -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, '&quot;')}"`);
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. A null result means
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
- // Legacy bypass: persona gating is not active for this workspace, so all personas are accessible.
1981
- // Mirrors the evaluatePersonaAccess legacy bypass in persona-entitlement-service.ts.
1982
- // Seats are not assignable on legacy workspaces (the hosted server is always DB-backed).
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
- key: bundle.personaKey,
2018
- displayName: bundle.catalogMetadata.displayName,
2019
- role: bundle.catalogMetadata.role,
2020
- avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
2021
- pricingLabel: hiredKeys.has(bundle.personaKey) ? '' : bundle.catalogMetadata.pricingLabel,
2022
- status: (hiredKeys.has(bundle.personaKey) ? 'hired' : 'locked'),
2023
- hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
2024
- seatCount: seatCountByKey[bundle.personaKey] ?? 0,
2025
- seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
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
- const entry = known.find((project) => project.id === req.params.id);
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.status(404).json({
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
- if (!entry.isFile() || !entry.name.endsWith('.md'))
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: `${dirName}/${entry.name}`,
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
- SAFE_PACK_RELATIVE_PATH.test(f.relativePath) &&
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 (!SAFE_PACK_RELATIVE_PATH.test(file.relativePath))
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
- fs_1.default.writeFileSync(destination, decorateManagedOrgFile(file.content, metadata.backend));
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 });
@@ -52,7 +52,7 @@ exports.FIRST_RUN_AGENT_OPTIONS = [
52
52
  },
53
53
  {
54
54
  id: 'copilot-cli',
55
- label: 'GitHub Copilot',
55
+ label: 'GitHub Copilot CLI',
56
56
  detectAliases: ['copilot', 'copilot-cli', 'github copilot cli'],
57
57
  loginCommand: 'copilot login',
58
58
  launchCommand: 'copilot',
@@ -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