Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 39 additions & 33 deletions client/src/lib/README.md

Large diffs are not rendered by default.

20 changes: 5 additions & 15 deletions client/src/lib/appIdentity.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
/**
* The managed-apps registry's baseline identity — PortOS itself. Mirrors
* `server/lib/appIdentity.js`.
* The product name/tagline every surface prints.
*
* Split out of `services/apiCore.js` for the same reason the server split it out
* of `services/apps.js`: a module that only needs to SAY "this record is PortOS"
* shouldn't have to import the API client — which pulls in `ui/Toast` and
* therefore React. `client/src/components/apps/constants.js` is imported by a
* node-env SERVER test (`server/services/streamingDetect.test.js`, the
* DESKTOP_TYPES parity check), where that React import fails to resolve.
* `apiCore.js` re-exports the constant, so every existing
* `import { PORTOS_APP_ID } from '../services/api'` is unchanged.
*
* Data only, no dependencies — keep it that way.
* Re-export of `server/lib/appIdentity.js` — the one definition of this rule,
* imported rather than copied so the two runtimes cannot drift. The file stays
* so every `lib/appIdentity` import path in the client is unchanged.
*/

/** Stable id of the baseline PortOS app — always present, never deletable. */
export const PORTOS_APP_ID = 'portos-default';
export { PORTOS_APP_ID } from '../../../server/lib/appIdentity.js';
217 changes: 22 additions & 195 deletions client/src/lib/assetProvenance.js
Original file line number Diff line number Diff line change
@@ -1,198 +1,25 @@
/**
* Asset license provenance — stamp at finalize time, never re-read later.
* Provenance vocabulary + readers for a generated asset.
*
* PortOS already resolves a model's license when it downloads one
* (`licenseOf` in huggingFaceCatalog, Civitai/HF LoRA cards) and then drops
* it on the floor. Create-suite outputs leave the machine (collections,
* pipeline export, albums), so the terms that applied WHEN THE PIXELS WERE
* MADE have to travel with the asset. A license re-read months later can
* differ from the one in force at render; unknown stays unknown (`null`),
* displayed as "unknown" — never a permissive default.
*
* Shape (schemaVersion 1):
* {
* schemaVersion: 1,
* capturedAt: ISO-8601 | null,
* sources: [{ kind: 'model'|'lora', id, name, license, sourceUrl }]
* }
*
* Pure — no I/O. Server and client share this module byte-for-byte.
* Re-export of `server/lib/assetProvenance.js` — the one definition of this rule,
* imported rather than copied so the two runtimes cannot drift. The file stays
* so every `lib/assetProvenance` import path in the client is unchanged.
*/

export const PROVENANCE_SCHEMA_VERSION = 1;
export const PROVENANCE_SOURCE_KINDS = Object.freeze(['model', 'lora']);
export const UNKNOWN_LICENSE_LABEL = 'unknown';

export function normalizeLicense(value) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed || null;
}

export function licenseLabel(license) {
const normalized = normalizeLicense(license);
return normalized || UNKNOWN_LICENSE_LABEL;
}

export function huggingfaceUrl(repo) {
if (typeof repo !== 'string') return null;
const id = repo.trim();
return id ? `https://huggingface.co/${id}` : null;
}

export function licenseFromHuggingFaceModel(model) {
const card = normalizeLicense(model?.cardData?.license || model?.license);
if (card) return card;
const tags = Array.isArray(model?.tags) ? model.tags : [];
const tag = tags.find((t) => typeof t === 'string' && /^license:/i.test(t));
return tag ? normalizeLicense(tag.slice(tag.indexOf(':') + 1)) : null;
}

export function licenseFromCivitaiModel(model) {
// Civitai's `allowCommercialUse` is a policy flag, not a license string —
// never promote it into one. Only a real `license` field counts.
return normalizeLicense(model?.license);
}

export function buildProvenanceSource({ kind, id, name = null, license = null, sourceUrl = null } = {}) {
if (!PROVENANCE_SOURCE_KINDS.includes(kind)) return null;
if (typeof id !== 'string' || !id.trim()) return null;
const url = typeof sourceUrl === 'string' && sourceUrl.trim() ? sourceUrl.trim() : null;
const display = typeof name === 'string' && name.trim() ? name.trim() : null;
return {
kind,
id: id.trim(),
name: display,
license: normalizeLicense(license),
sourceUrl: url,
};
}

const sourceKey = (src) => `${src.kind}:${src.id}`;

export function buildProvenance({ sources = [], capturedAt = null } = {}) {
const captured = typeof capturedAt === 'string' && capturedAt.trim() ? capturedAt.trim() : null;
const byKey = new Map();
for (const raw of Array.isArray(sources) ? sources : []) {
const src = buildProvenanceSource(raw);
if (!src) continue;
const key = sourceKey(src);
const existing = byKey.get(key);
if (!existing) {
byKey.set(key, src);
continue;
}
// Prefer a known license over unknown when the same source appears twice
// in one stamp (model + LoRA list shouldn't collide, but a rollup can).
if (existing.license == null && src.license != null) {
byKey.set(key, {
...existing,
license: src.license,
name: existing.name || src.name,
sourceUrl: existing.sourceUrl || src.sourceUrl,
});
continue;
}
byKey.set(key, {
...existing,
name: existing.name || src.name,
sourceUrl: existing.sourceUrl || src.sourceUrl,
});
}
return {
schemaVersion: PROVENANCE_SCHEMA_VERSION,
capturedAt: captured,
sources: [...byKey.values()],
};
}

export function readProvenance(record) {
const raw = record?.provenance;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const built = buildProvenance({
sources: Array.isArray(raw.sources) ? raw.sources : [],
capturedAt: raw.capturedAt,
});
return built.sources.length ? built : null;
}

function pickLoraFilenames(record) {
if (Array.isArray(record?.loraFilenames)) return record.loraFilenames;
if (Array.isArray(record?.lora_filenames)) return record.lora_filenames;
return [];
}

export function resolveAssetProvenance(record) {
const stamped = readProvenance(record);
if (stamped) return { ...stamped, reconstructed: false };
if (!record || typeof record !== 'object') return null;
const modelId = record.modelId || record.model;
const loras = pickLoraFilenames(record).filter((f) => typeof f === 'string' && f);
if (!modelId && !loras.length) return null;
const capturedAt = typeof record.createdAt === 'string' ? record.createdAt : null;
return {
...buildProvenance({
sources: [
...(modelId ? [{ kind: 'model', id: String(modelId), license: null }] : []),
...loras.map((id) => ({ kind: 'lora', id, license: null })),
],
capturedAt,
}),
reconstructed: true,
};
}

export function licenseFromRegistryModel(model) {
// Weights terms only. `disclosure.runtimeLicense` is the inference stack
// (often MIT) and must never be promoted into the asset's model license.
return normalizeLicense(model?.license)
|| normalizeLicense(model?.disclosure?.weightsLicense?.name);
}

export function provenanceForRender({ model = null, loras = [], capturedAt = null } = {}) {
const sources = [];
if (model && (model.id || model.name)) {
const id = String(model.id || model.name);
const disclosureUrl = typeof model.disclosure?.modelCardUrl === 'string'
? model.disclosure.modelCardUrl
: null;
const weightsUrl = typeof model.disclosure?.weightsLicense?.url === 'string'
? model.disclosure.weightsLicense.url
: null;
sources.push({
kind: 'model',
id,
name: model.name || null,
license: licenseFromRegistryModel(model),
sourceUrl: model.sourceUrl || huggingfaceUrl(model.repo) || disclosureUrl || weightsUrl,
});
}
for (const lora of Array.isArray(loras) ? loras : []) {
const filename = typeof lora === 'string' ? lora : lora?.filename;
if (typeof filename !== 'string' || !filename) continue;
sources.push({
kind: 'lora',
id: filename,
name: typeof lora === 'object' ? (lora.name || null) : null,
license: typeof lora === 'object' ? lora.license : null,
sourceUrl: typeof lora === 'object' ? lora.sourceUrl : null,
});
}
return buildProvenance({ sources, capturedAt });
}

export function rollupProvenance(records) {
const sources = [];
for (const record of Array.isArray(records) ? records : []) {
const resolved = resolveAssetProvenance(record);
if (!resolved) continue;
sources.push(...resolved.sources);
}
return buildProvenance({ sources, capturedAt: null });
}

export function formatProvenanceSource(src) {
const built = buildProvenanceSource(src);
if (!built) return null;
return { ...built, licenseLabel: licenseLabel(built.license) };
}
export {
PROVENANCE_SCHEMA_VERSION,
PROVENANCE_SOURCE_KINDS,
UNKNOWN_LICENSE_LABEL,
buildProvenance,
buildProvenanceSource,
formatProvenanceSource,
huggingfaceUrl,
licenseFromCivitaiModel,
licenseFromHuggingFaceModel,
licenseFromRegistryModel,
licenseLabel,
normalizeLicense,
provenanceForRender,
readProvenance,
resolveAssetProvenance,
rollupProvenance,
} from '../../../server/lib/assetProvenance.js';
48 changes: 13 additions & 35 deletions client/src/lib/avatarStyles.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,16 @@
/**
* Single source of truth for the CoS avatar-style vocabulary. Every consumer
* that used to hand-maintain its own list derives from `AVATAR_STYLES`
* instead (#6253): the picker labels (`components/cos/constants.js`), the
* lazy-load map and WebGL-stage set (`pages/ChiefOfStaff.jsx`), and the
* server's `avatarStyle` zod enum (`server/routes/cosStatusRoutes.js`,
* imported directly — this leaf has no transitive deps, so it's safe from
* the server workspace the way `personaTraitBlend.js`'s `clamp` import is).
* Re-export of the authoritative CoS avatar-style vocabulary in
* `server/lib/avatarStyles.js`.
*
* `webgl: true` marks a style that needs the three.js canvas stage —
* `CANVAS_AVATAR_STYLES` derives from this flag. The 2D `core` canvas style
* and the inline `svg`/`ascii` styles are deliberately `webgl: false`.
* The registry lives server-side because the server's `avatarStyle` zod enum
* needs it too, and the dependency direction is one-way: the client imports
* pure `server/lib` leaves, never the reverse (a client-only dependency added
* to a file the server imports breaks the server CI job). This shim keeps the
* `lib/avatarStyles` import path every UI consumer already uses.
*/

export const AVATAR_STYLES = [
{ id: 'svg', label: 'Digital (SVG)', webgl: false },
{ id: 'cyber', label: 'Cyberpunk (3D)', webgl: true },
{ id: 'sigil', label: 'Arcane Sigil (3D)', webgl: true },
{ id: 'esoteric', label: 'Esoteric (3D)', webgl: true },
{ id: 'nexus', label: 'Neural Nexus (3D)', webgl: true },
{ id: 'muse', label: 'Cyber Muse (3D)', webgl: true },
// Kestrel Neon's rotating wireframe icosahedron — 2D canvas, no WebGL needed.
{ id: 'core', label: 'Core Assembly (Canvas)', webgl: false },
// Bundled CC0 Kenney Mini Characters — animated rigged GLB avatars.
{ id: 'miniMaleC', label: 'Mini Character — Male (3D)', webgl: true },
{ id: 'miniFemaleD', label: 'Mini Character — Female (3D)', webgl: true },
{ id: 'ascii', label: 'Minimalist (ASCII)', webgl: false },
];

export const AVATAR_STYLE_IDS = AVATAR_STYLES.map((style) => style.id);

export const AVATAR_STYLE_LABELS = Object.fromEntries(
AVATAR_STYLES.map((style) => [style.id, style.label])
);

export const WEBGL_AVATAR_STYLE_IDS = new Set(
AVATAR_STYLES.filter((style) => style.webgl).map((style) => style.id)
);
export {
AVATAR_STYLES,
AVATAR_STYLE_IDS,
AVATAR_STYLE_LABELS,
WEBGL_AVATAR_STYLE_IDS,
} from '../../../server/lib/avatarStyles.js';
79 changes: 5 additions & 74 deletions client/src/lib/bareUrl.js
Original file line number Diff line number Diff line change
@@ -1,77 +1,8 @@
/**
* Bare-URL detection — MIRROR of `server/lib/bareUrl.js` (authoritative there).
* The bare-URL detector shared by capture and validation.
*
* The server files a capture whose entire text is a URL straight to the links
* collection. The capture boxes preview that decision (the "will be saved to
* Links" hint, and the Creative toggle it disables), so they must answer the
* question exactly the way the server will — a looser client predicate promises
* a filing the server won't perform.
*
* Port any change from the server copy verbatim; parity is enforced by
* `server/lib/bareUrl.mirror.test.js`. (Distinct from `utils/urlNormalize.js`'s
* `isUrl`, which answers a deliberately looser question for the Links quick-add.)
*/

// Explicit http(s) scheme — the URL constructor does the real validation below.
const HTTP_SCHEME_PATTERN = /^https?:\/\//i;

// SSH remote: git@host:owner/repo(.git)
const SSH_GIT_PATTERN = /^git@[a-z0-9.-]+:[\w.-]+\/[\w.-]+$/i;

// Scheme-less host[:port][/path] with a plausible alphabetic TLD:
// "example.com", "sub.example.co.uk/path?q=1", "example.com:8080/x".
const DOMAIN_LIKE_PATTERN = /^(?:[a-z0-9-]+\.)+[a-z]{2,24}(?::\d{2,5})?(?:[/?#]\S*)?$/i;

// Several ccTLDs double as common file extensions (`.md` Moldova, `.sh` St
// Helena, `.py` Paraguay…), so a scheme-less bare token like `notes.md` or
// `deploy.sh` is far more likely a filename in a note than a host. Only the
// scheme-less, path-less form is filtered — `https://foo.md` and `foo.md/page`
// still read as URLs. Digit-bearing extensions (`mp4`, `h264`) need no entry:
// DOMAIN_LIKE_PATTERN's all-alphabetic TLD already rejects them. Not exhaustive
// by construction — an extension that isn't also a plausible TLD can't reach here.
const FILE_EXTENSION_TAIL = new Set([
'md', 'txt', 'log', 'csv', 'json', 'xml', 'yml', 'yaml', 'toml', 'ini', 'env', 'lock',
'js', 'jsx', 'ts', 'tsx', 'css', 'html', 'htm', 'py', 'rb', 'sh', 'zsh', 'go', 'rs',
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'tar', 'gz',
'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'mov', 'wav'
]);

/**
* True for a scheme-less, path-less `name.ext` whose tail is a known file
* extension (`notes.md`) rather than a host.
*/
function looksLikeFilename(token) {
if (/[/?#:]/.test(token)) return false;
const tail = token.slice(token.lastIndexOf('.') + 1).toLowerCase();
return FILE_EXTENSION_TAIL.has(tail);
}

/**
* If `text` is nothing but a single URL, return it normalized (an `https://`
* scheme is prepended to a bare host). Returns null for free text, multi-token
* input, a URL with surrounding prose, or a non-http(s)/git scheme.
*
* @param {string} text
* @returns {string|null}
* Re-export of `server/lib/bareUrl.js` — the one definition of this rule,
* imported rather than copied so the two runtimes cannot drift. The file stays
* so every `lib/bareUrl` import path in the client is unchanged.
*/
export function parseBareUrl(text) {
const trimmed = (text ?? '').trim();
// "Just a URL" means the whole capture is one token — any whitespace (a label,
// a trailing note, a second URL) makes it a thought that mentions a link.
if (!trimmed || /\s/.test(trimmed)) return null;

if (SSH_GIT_PATTERN.test(trimmed)) return trimmed;

let candidate = null;
if (HTTP_SCHEME_PATTERN.test(trimmed)) {
candidate = trimmed;
} else if (DOMAIN_LIKE_PATTERN.test(trimmed) && !looksLikeFilename(trimmed)) {
candidate = `https://${trimmed}`;
}
if (!candidate) return null;

// Final gate: the parser rejects shapes the regexes let through (bad port,
// malformed IPv6 host). The scheme needs no re-check — a candidate only exists
// here because it matched `http(s)://` or had `https://` prepended.
return URL.canParse(candidate) ? candidate : null;
}
export { parseBareUrl } from '../../../server/lib/bareUrl.js';
Loading