Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# production
/dist
/lib
/.test-dist

# log
*.log
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@

## Project Structure

Remote content and service URLs are supplied by the host. See [Host-provided plugin services](./docs/host-plugin-services.md) for the runtime contract, capability reporting, and CCW/Cocrea profiles.

```console
├──plugin-template // Plugin template directory
│ ├──plugin-index-js.hbs // Handlebars template for JavaScript plugin index
Expand Down
116 changes: 116 additions & 0 deletions docs/host-plugin-services.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Host-provided plugin services

`gandi-plugins` does not infer a site from `window.location`. A host supplies the remote services it owns or has verified through the `pluginServices` option passed to `PluginsController`.

Missing configuration is a capability decision, not a request to guess a sibling domain. The controller automatically adds fully remote plugins with missing service configuration to `unavailablePlugins`. `block-sharing` remains available because local SVG export, copy, paste, and file-drop import do not require a remote service.

## Contract

```ts
interface PluginServices {
blockSharing?: {
contentBaseUrl?: string;
importAssetBaseUrl?: string;
};
costumePiskel?: { editorUrl: string };
customExtensionVersionManager?: {
apiBaseUrl: string;
webBaseUrl: string;
};
customCss?: { defaultStylesheetUrl?: string };
inspiro?: {
apiBaseUrl: string;
assetBaseUrl: string;
};
voiceCooperation?: {
livekitUrl: string;
connectedAudioUrl?: string;
memberJoinedAudioUrl?: string;
};
}
```

HTTP content must use an absolute `http:` or `https:` URL. LiveKit must use an absolute `ws:` or `wss:` URL. Invalid configured URLs fail when the controller is created; absent services are reported as unavailable.

`customExtensionVersionManager` requires both marketplace URLs and `inspiro` requires both API and asset URLs. Their capabilities stay false if either half is absent. `voiceCooperation` additionally depends on the host's existing `server.hosts.GANDI_MAIN/rtc/join` token endpoint; configuring LiveKit does not create or replace that backend.

The bundle exports `normalizePluginServices`, `getPluginCapabilities`, and `getServiceUnavailablePlugins`. A controller instance also exposes its derived `capabilities` property.

Capabilities report validated configuration availability, not continuous service health. The host can call `getPluginCapabilities(pluginServices)` before controller construction and use the result for UI or telemetry. `blockSharingLocalSvg` is always true; remote content and remote deep-link import are separate flags.

## CCW profile

These are the existing CCW routes, moved out of plugin implementations and spot-checked on 2026-08-28. The host must still own environment selection, monitoring, and rollout:

```ts
const pluginServices = {
blockSharing: {
contentBaseUrl: "https://learn.ccw.site",
importAssetBaseUrl: "https://m.ccw.site/creator-college/images",
},
costumePiskel: {
editorUrl: "https://ai-static.ccw.site/test/piskel-1e80317/index.html",
},
customExtensionVersionManager: {
apiBaseUrl: "https://bfs-web.ccw.site",
webBaseUrl: "https://assets.ccw.site",
},
customCss: {
defaultStylesheetUrl: "https://m.ccw.site/gandi/default.css",
},
inspiro: {
apiBaseUrl: "https://gandi-main.ccw.site",
assetBaseUrl: "https://m.ccw.site",
},
voiceCooperation: {
livekitUrl: "wss://voice.ccw.site",
},
};
```

The custom CSS URL is only the initial value shown in the URL input; it is not loaded until the user changes that setting. Hosts may omit it.

## Cocrea profile

Only the existing Inspiro routes had current evidence in the Cocrea stack on 2026-08-28:

```ts
const pluginServices = {
inspiro: {
apiBaseUrl: "https://gandi-main.cocrea.world",
assetBaseUrl: "https://d3sh2pl0ajjotb.cloudfront.net",
},
};
```

The image and music endpoints exist, return the expected authentication error while signed out, allow credentialed requests from `https://cocrea.world`, and the configured avatar assets are PNG files. A signed-in generation smoke test is still required before enabling Inspiro in production.

Do not add inferred `learn.cocrea.world`, `ai-static.cocrea.world`, `bfs-web.cocrea.world`, `assets.cocrea.world` marketplace paths, or `voice.cocrea.world` routes. No matching deployed business service was verified for them. In this profile:

- `block-sharing` keeps local SVG export, copy, paste, and file-drop import; its remote browser and deep-link fetch are unavailable.
- `costume-piskel` and `custom-extension-version-manager` are unavailable.
- `custom-css` has an empty URL example but keeps all local theme and user-entered URL behavior.
- `code-find` uses no site-owned empty-state image.
- `voice-cooperation` is explicitly unavailable because no Cocrea LiveKit service is configured.

## Consumer wiring

`gandi-gui` needs a `pluginServices` prop. On the current `origin/main` integration path:

1. `src/components/gui/gui.jsx` passes the prop to `src/containers/plugins.jsx`.
2. `src/containers/plugins.jsx` passes it to `usePluginsContext` and declares its PropType.
3. `src/hooks/usePluginsContext.js` includes it in the initial context and updates it when the prop changes.

That context is already passed directly to `new PluginsClass(context)`. Keep `unavailablePlugins` as an additional product-policy override; it is unioned with service-derived unavailability inside `gandi-plugins`.

`hub-international` should define its Cocrea profile from reviewed public environment configuration and pass it through:

```tsx
<GandiGUI
pluginServices={COCREA_PLUGIN_SERVICES}
pluginsScriptSrc={process.env.NEXT_PUBLIC_GANDI_PLUGINS_JS_FILE}
unavailablePlugins={UNAVAILABLE_PLUGINS}
/>
```

After a signed-in Inspiro smoke test, `hub-international` may remove `inspiro` from its explicit `UNAVAILABLE_PLUGINS`. It should keep no voice service entry; the plugin bundle will report and enforce `voiceCooperation: false`.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"build": "webpack --config webpack.config.js --progress",
"buildSinglePlugin": "node scripts/build-single-plugin.js",
"lint": "eslint src --ext .js,.ts",
"test:plugin-services": "rimraf .test-dist && tsc --module commonjs --target ES2020 --outDir .test-dist --rootDir src --declaration false --sourceMap false --esModuleInterop true --skipLibCheck true src/lib/plugin-services.ts src/plugins/custom-extension-version-manager/marketplace.ts src/plugins/custom-extension-version-manager/types.ts && node --test test/plugin-services.test.js",
"prepublishOnly": "pnpm install && pnpm build",
"clean": "rimraf lib && mkdirp lib && rimraf ./dist && mkdirp dist"
},
Expand Down Expand Up @@ -104,4 +105,4 @@
"last 2 versions",
"> 1%"
]
}
}
4 changes: 2 additions & 2 deletions src/l10n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@
"plugins.extensionManager.title": "Extension Manager",
"plugins.extensionManager.intro": "Manage your extensions.",
"plugins.customExtensionVersionManager.title": "Custom Extension Versions",
"plugins.customExtensionVersionManager.description": "Load available versions of installed custom extensions from the CCW asset marketplace and switch safely.",
"plugins.customExtensionVersionManager.description": "Load available versions of installed custom extensions from the configured asset marketplace and switch safely.",
"plugins.customExtensionVersionManager.extension": "Custom extension",
"plugins.customExtensionVersionManager.noExtensions": "No loaded custom extensions",
"plugins.customExtensionVersionManager.refreshExtensions": "Refresh extensions",
Expand Down Expand Up @@ -420,4 +420,4 @@
"plugins.debuggerAddon.terminalLoadMode.auto": "Load automatically with project",
"plugins.debuggerAddon.terminalLoadMode.onClick": "Load on icon click",
"plugins.debuggerAddon.terminalLoadMode.manual": "Do not load automatically"
}
}
170 changes: 170 additions & 0 deletions src/lib/plugin-services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
export interface PluginServices {
blockSharing?: {
contentBaseUrl?: string;
importAssetBaseUrl?: string;
};
costumePiskel?: {
editorUrl: string;
};
customExtensionVersionManager?: {
apiBaseUrl: string;
webBaseUrl: string;
};
customCss?: {
defaultStylesheetUrl?: string;
};
inspiro?: {
apiBaseUrl: string;
assetBaseUrl: string;
};
voiceCooperation?: {
livekitUrl: string;
connectedAudioUrl?: string;
memberJoinedAudioUrl?: string;
};
}

export interface PluginCapabilities {
blockSharingLocalSvg: true;
blockSharingRemoteContent: boolean;
blockSharingRemoteImport: boolean;
costumePiskel: boolean;
customExtensionVersionManager: boolean;
customCssDefaultStylesheet: boolean;
inspiro: boolean;
voiceCooperation: boolean;
}

type UrlProtocol = "http:" | "https:" | "ws:" | "wss:";

const normalizeUrl = (value: unknown, field: string, protocols: UrlProtocol[]): string | undefined => {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value !== "string") {
throw new TypeError(`${field} must be an absolute URL.`);
}

let url: URL;
try {
url = new URL(value.trim());
} catch {
throw new TypeError(`${field} must be an absolute URL.`);
}

if (!protocols.includes(url.protocol as UrlProtocol)) {
throw new TypeError(`${field} must use one of these protocols: ${protocols.join(", ")}.`);
}
return url.toString();
};

const normalizeHttpUrl = (value: unknown, field: string) => normalizeUrl(value, field, ["http:", "https:"]);

export const normalizePluginServices = (services: PluginServices = {}): PluginServices => {
const normalized: PluginServices = {};

const blockSharingContent = normalizeHttpUrl(
services.blockSharing?.contentBaseUrl,
"pluginServices.blockSharing.contentBaseUrl",
);
const blockSharingImport = normalizeHttpUrl(
services.blockSharing?.importAssetBaseUrl,
"pluginServices.blockSharing.importAssetBaseUrl",
);
if (blockSharingContent || blockSharingImport) {
normalized.blockSharing = {
...(blockSharingContent ? { contentBaseUrl: blockSharingContent } : {}),
...(blockSharingImport ? { importAssetBaseUrl: blockSharingImport } : {}),
};
}

const piskelEditor = normalizeHttpUrl(services.costumePiskel?.editorUrl, "pluginServices.costumePiskel.editorUrl");
if (piskelEditor) normalized.costumePiskel = { editorUrl: piskelEditor };

const marketplaceApi = normalizeHttpUrl(
services.customExtensionVersionManager?.apiBaseUrl,
"pluginServices.customExtensionVersionManager.apiBaseUrl",
);
const marketplaceWeb = normalizeHttpUrl(
services.customExtensionVersionManager?.webBaseUrl,
"pluginServices.customExtensionVersionManager.webBaseUrl",
);
if (marketplaceApi && marketplaceWeb) {
normalized.customExtensionVersionManager = {
apiBaseUrl: marketplaceApi,
webBaseUrl: marketplaceWeb,
};
}

const defaultStylesheet = normalizeHttpUrl(
services.customCss?.defaultStylesheetUrl,
"pluginServices.customCss.defaultStylesheetUrl",
);
if (defaultStylesheet) normalized.customCss = { defaultStylesheetUrl: defaultStylesheet };

const inspiroApi = normalizeHttpUrl(services.inspiro?.apiBaseUrl, "pluginServices.inspiro.apiBaseUrl");
const inspiroAssets = normalizeHttpUrl(services.inspiro?.assetBaseUrl, "pluginServices.inspiro.assetBaseUrl");
if (inspiroApi && inspiroAssets) {
normalized.inspiro = {
apiBaseUrl: inspiroApi,
assetBaseUrl: inspiroAssets,
};
}

const voiceLivekit = normalizeUrl(
services.voiceCooperation?.livekitUrl,
"pluginServices.voiceCooperation.livekitUrl",
["ws:", "wss:"],
);
if (voiceLivekit) {
const connectedAudio = normalizeHttpUrl(
services.voiceCooperation?.connectedAudioUrl,
"pluginServices.voiceCooperation.connectedAudioUrl",
);
const memberJoinedAudio = normalizeHttpUrl(
services.voiceCooperation?.memberJoinedAudioUrl,
"pluginServices.voiceCooperation.memberJoinedAudioUrl",
);
normalized.voiceCooperation = {
livekitUrl: voiceLivekit,
...(connectedAudio ? { connectedAudioUrl: connectedAudio } : {}),
...(memberJoinedAudio ? { memberJoinedAudioUrl: memberJoinedAudio } : {}),
};
}

return normalized;
};

export const getPluginCapabilities = (services: PluginServices = {}): PluginCapabilities => {
const normalized = normalizePluginServices(services);
return {
blockSharingLocalSvg: true,
blockSharingRemoteContent: Boolean(normalized.blockSharing?.contentBaseUrl),
blockSharingRemoteImport: Boolean(normalized.blockSharing?.importAssetBaseUrl),
costumePiskel: Boolean(normalized.costumePiskel),
customExtensionVersionManager: Boolean(normalized.customExtensionVersionManager),
customCssDefaultStylesheet: Boolean(normalized.customCss?.defaultStylesheetUrl),
inspiro: Boolean(normalized.inspiro),
voiceCooperation: Boolean(normalized.voiceCooperation),
};
};

export const getServiceUnavailablePlugins = (capabilities: PluginCapabilities): string[] => {
const unavailablePlugins: string[] = [];
if (!capabilities.costumePiskel) unavailablePlugins.push("costume-piskel");
if (!capabilities.customExtensionVersionManager) unavailablePlugins.push("custom-extension-version-manager");
if (!capabilities.inspiro) unavailablePlugins.push("inspiro");
if (!capabilities.voiceCooperation) unavailablePlugins.push("voice-cooperation");
return unavailablePlugins;
};

export const buildPluginServiceUrl = (
baseUrl: string,
relativePath: string,
searchParams: Record<string, string | undefined> = {},
): string => {
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
const url = new URL(relativePath.replace(/^\/+/, ""), normalizedBase);
Object.entries(searchParams).forEach(([key, value]) => {
if (value !== undefined) url.searchParams.set(key, value);
});
return url.toString();
};
7 changes: 7 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,11 @@ import PluginsController from "./plugins-controller";
import Plugins from "./plugins-entry";

export const PluginNames = Object.keys(Plugins);
export {
buildPluginServiceUrl,
getPluginCapabilities,
getServiceUnavailablePlugins,
normalizePluginServices,
} from "./lib/plugin-services";
export type { PluginCapabilities, PluginServices } from "./lib/plugin-services";
export { PluginsController as default };
Loading