@kolbo/app-sdk
Advanced tools
+1
-1
| { | ||
| "name": "@kolbo/app-sdk", | ||
| "version": "2.3.1", | ||
| "version": "2.4.0", | ||
| "description": "Kolbo App Builder SDK — auth, data, storage and AI for generated apps", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+61
-91
@@ -502,36 +502,39 @@ // Kolbo-native adapter. | ||
| _buildAi() { | ||
| const env = (typeof import.meta !== 'undefined' && import.meta.env) ? import.meta.env : {}; | ||
| const apiKey = env.VITE_KOLBO_API_KEY || ''; | ||
| // AI endpoints live at `{apiUrl}/api/v1/*`. Default to the adapter's apiUrl | ||
| // (which comes from VITE_KOLBO_API_URL — the same host where auth / data / | ||
| // storage talk). Falling back to api.kolbo.ai hard-codes production and | ||
| // breaks dev/staging previews whose API key was issued in a different DB. | ||
| const fallbackBase = `${String(this._apiUrl || '').replace(/\/$/, '')}/api` || 'https://api.kolbo.ai/api'; | ||
| const apiBase = (env.VITE_KOLBO_BASE || fallbackBase).replace(/\/$/, ''); | ||
| const h = () => ({ 'X-API-Key': apiKey, 'Content-Type': 'application/json' }); | ||
| const self = this; | ||
| // AI now proxies through the App Builder AI proxy at | ||
| // `{apiUrl}/api/apps/{appId}/ai/*`, authenticated with the SAME per-app | ||
| // end-user JWT that auth/data/storage already hold (via _getAuthHeader). | ||
| // No full-access API key is ever shipped in the app bundle — the proxy | ||
| // resolves the app OWNER server-side and bills the owner's credits. | ||
| const aiBase = `${String(this._apiUrl || '').replace(/\/$/, '')}/api/apps/${this._appId}/ai`; | ||
| // Pre-flight guard. If the sandbox booted without the API key env var | ||
| // (race between .env write and Vite startup — most common when the | ||
| // session was created before ensureKolboApiKey was wired), every | ||
| // AI call would 401 with an opaque "401" string. Surface a specific | ||
| // error the user can actually act on. | ||
| const assertKey = () => { | ||
| if (!apiKey || apiKey.length < 10) { | ||
| throw new Error( | ||
| 'Kolbo AI key missing — this preview was built before the API key was injected. ' + | ||
| 'Click "Reload preview" in the top bar (or regenerate) to rebuild with the key.' | ||
| ); | ||
| // Map a proxy HTTP error to an actionable message. | ||
| async function throwForStatus(r, label) { | ||
| if (r.status === 401) { | ||
| throw new Error('Kolbo AI requires the app user to be signed in (401). Ask the user to log in and retry.'); | ||
| } | ||
| }; | ||
| // Translate 401 into the same actionable message, in case the key was | ||
| // revoked / rotated between sandbox boot and this call. | ||
| const assertAuthed = (res) => { | ||
| if (res.status === 401) { | ||
| throw new Error( | ||
| 'Kolbo AI rejected this preview\'s API key (401). The key may have been revoked ' + | ||
| 'or the sandbox env is stale. Reload the preview or regenerate.' | ||
| ); | ||
| if (r.status === 402) { | ||
| throw new Error('Out of Kolbo AI credits — the app owner needs to top up at kolbo.ai/pricing.'); | ||
| } | ||
| }; | ||
| if (r.status === 429) { | ||
| throw new Error('Too many AI requests right now — please wait a moment and try again.'); | ||
| } | ||
| let msg; | ||
| try { msg = (await r.json())?.error; } catch (_) { /* non-JSON body */ } | ||
| throw new Error(msg || `${label} failed: ${r.status}`); | ||
| } | ||
| // POST helper — attaches the app-user JWT, parses JSON, throws on !ok. | ||
| async function post(path, body, signal) { | ||
| const authHeader = await self._getAuthHeader(); | ||
| const r = await fetch(`${aiBase}/${path}`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json', 'ngrok-skip-browser-warning': 'true', ...authHeader }, | ||
| body: JSON.stringify(body), | ||
| signal, | ||
| }); | ||
| if (!r.ok) await throwForStatus(r, path); | ||
| return r.json(); | ||
| } | ||
| async function poll(pollUrl, optsOrTimeout) { | ||
@@ -545,6 +548,11 @@ // Back-compat: existing callers pass a raw number for timeoutMs. | ||
| const signal = opts.signal; | ||
| // The proxy returns a relative pollUrl (`/api/apps/.../status/:id`); | ||
| // resolve it against the adapter's apiUrl. Absolute URLs pass through. | ||
| const base = String(self._apiUrl || '').replace(/\/$/, ''); | ||
| const url = /^https?:/i.test(pollUrl) ? pollUrl : `${base}${pollUrl}`; | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (Date.now() < deadline) { | ||
| if (signal && signal.aborted) throw new Error('Generation cancelled'); | ||
| const r = await fetch(pollUrl, { headers: { 'X-API-Key': apiKey }, signal }); | ||
| const authHeader = await self._getAuthHeader(); | ||
| const r = await fetch(url, { headers: { 'ngrok-skip-browser-warning': 'true', ...authHeader }, signal }); | ||
| if (!r.ok) throw new Error(`Poll ${r.status}`); | ||
@@ -567,18 +575,10 @@ const b = await r.json(); | ||
| // Every method now forwards `model` and any other params the caller | ||
| // supplies. Before this, `model` was stripped and the backend's | ||
| // Smart Select routed every call to a generic default — fine for | ||
| // "just make an image" but wrong for specialized cases (storybook | ||
| // character consistency, talking-head lipsync, cloned voice TTS). | ||
| // The coder agent picks `model` explicitly via the AI Integrator plan. | ||
| // Public method names, signatures and return shapes are UNCHANGED so | ||
| // generated app code keeps working — only the transport (URL + auth) | ||
| // changed under the hood. Each method forwards `model` and any extra | ||
| // params so the coder agent can pick a specific model. | ||
| return { | ||
| async chat({ message, messages, session_id, model, signal, ...rest } = {}) { | ||
| assertKey(); | ||
| const text = message ?? (messages ? messages[messages.length - 1]?.content : '') ?? ''; | ||
| const body = { message: text, session_id, ...(model ? { model } : {}), ...rest }; | ||
| const r = await fetch(`${apiBase}/v1/chat`, { method: 'POST', headers: h(), body: JSON.stringify(body), signal }); | ||
| assertAuthed(r); | ||
| if (r.status === 402) throw new Error('Out of Kolbo AI credits — top up at kolbo.ai/pricing'); | ||
| if (!r.ok) throw new Error(`Chat failed: ${r.status}`); | ||
| const d = await r.json(); | ||
| const d = await post('chat', { message: text, session_id, ...(model ? { model } : {}), ...rest }, signal); | ||
| return { content: d.reply, session_id: d.session_id }; | ||
@@ -588,8 +588,3 @@ }, | ||
| const body = { prompt, aspect_ratio, ...(model ? { model } : {}), ...(visual_dna_id ? { visual_dna_id } : {}), ...(reference_image ? { reference_image } : {}), ...rest }; | ||
| assertKey(); | ||
| const init = await fetch(`${apiBase}/v1/generate/image`, { method: 'POST', headers: h(), body: JSON.stringify(body), signal }); | ||
| assertAuthed(init); | ||
| if (init.status === 402) throw new Error('Out of Kolbo AI credits'); | ||
| if (!init.ok) throw new Error(`Image init failed: ${init.status}`); | ||
| const { pollUrl } = await init.json(); | ||
| const { pollUrl } = await post('generate/image', body, signal); | ||
| const result = await poll(pollUrl, { signal }); | ||
@@ -599,8 +594,4 @@ return { url: result.images?.[0]?.url, images: result.images }; | ||
| async generateImageEdit({ prompt, image, mask, model, signal, ...rest } = {}) { | ||
| assertKey(); | ||
| const body = { prompt, image, ...(mask ? { mask } : {}), ...(model ? { model } : {}), ...rest }; | ||
| const init = await fetch(`${apiBase}/v1/generate/image-edit`, { method: 'POST', headers: h(), body: JSON.stringify(body), signal }); | ||
| assertAuthed(init); | ||
| if (!init.ok) throw new Error(`Image edit failed: ${init.status}`); | ||
| const { pollUrl } = await init.json(); | ||
| const { pollUrl } = await post('generate/image-edit', body, signal); | ||
| const result = await poll(pollUrl, { signal }); | ||
@@ -610,8 +601,4 @@ return { url: result.images?.[0]?.url, images: result.images }; | ||
| async generateVideo({ prompt, duration = 5, aspect_ratio = '16:9', model, signal, ...rest } = {}) { | ||
| assertKey(); | ||
| const body = { prompt, duration, aspect_ratio, ...(model ? { model } : {}), ...rest }; | ||
| const init = await fetch(`${apiBase}/v1/generate/video`, { method: 'POST', headers: h(), body: JSON.stringify(body), signal }); | ||
| assertAuthed(init); | ||
| if (!init.ok) throw new Error(`Video init failed: ${init.status}`); | ||
| const { pollUrl } = await init.json(); | ||
| const { pollUrl } = await post('generate/video', body, signal); | ||
| const result = await poll(pollUrl, { timeoutMs: 10 * 60_000, signal }); | ||
@@ -621,8 +608,4 @@ return { url: result.video?.url }; | ||
| async generateVideoFromImage({ prompt, image, duration = 5, model, signal, ...rest } = {}) { | ||
| assertKey(); | ||
| const body = { prompt, image, duration, ...(model ? { model } : {}), ...rest }; | ||
| const init = await fetch(`${apiBase}/v1/generate/video/from-image`, { method: 'POST', headers: h(), body: JSON.stringify(body), signal }); | ||
| assertAuthed(init); | ||
| if (!init.ok) throw new Error(`Video-from-image init failed: ${init.status}`); | ||
| const { pollUrl } = await init.json(); | ||
| const { pollUrl } = await post('generate/video/from-image', body, signal); | ||
| const result = await poll(pollUrl, { timeoutMs: 10 * 60_000, signal }); | ||
@@ -632,8 +615,4 @@ return { url: result.video?.url }; | ||
| async generateLipsync({ image, video, audio, model, signal, ...rest } = {}) { | ||
| assertKey(); | ||
| const body = { audio, ...(image ? { image } : {}), ...(video ? { video } : {}), ...(model ? { model } : {}), ...rest }; | ||
| const init = await fetch(`${apiBase}/v1/generate/lipsync`, { method: 'POST', headers: h(), body: JSON.stringify(body), signal }); | ||
| assertAuthed(init); | ||
| if (!init.ok) throw new Error(`Lipsync init failed: ${init.status}`); | ||
| const { pollUrl } = await init.json(); | ||
| const { pollUrl } = await post('generate/lipsync', body, signal); | ||
| const result = await poll(pollUrl, { timeoutMs: 10 * 60_000, signal }); | ||
@@ -643,8 +622,4 @@ return { url: result.video?.url }; | ||
| async generateMusic({ prompt, duration, model, signal, ...rest } = {}) { | ||
| assertKey(); | ||
| const body = { prompt, ...(duration ? { duration } : {}), ...(model ? { model } : {}), ...rest }; | ||
| const init = await fetch(`${apiBase}/v1/generate/music`, { method: 'POST', headers: h(), body: JSON.stringify(body), signal }); | ||
| assertAuthed(init); | ||
| if (!init.ok) throw new Error(`Music init failed: ${init.status}`); | ||
| const { pollUrl } = await init.json(); | ||
| const { pollUrl } = await post('generate/music', body, signal); | ||
| const result = await poll(pollUrl, { signal }); | ||
@@ -654,8 +629,4 @@ return { url: result.audio_url }; | ||
| async generateSpeech({ text, voice, model, signal, ...rest } = {}) { | ||
| assertKey(); | ||
| const body = { text, ...(voice ? { voice } : {}), ...(model ? { model } : {}), ...rest }; | ||
| const init = await fetch(`${apiBase}/v1/generate/speech`, { method: 'POST', headers: h(), body: JSON.stringify(body), signal }); | ||
| assertAuthed(init); | ||
| if (!init.ok) throw new Error(`Speech init failed: ${init.status}`); | ||
| const { pollUrl } = await init.json(); | ||
| const { pollUrl } = await post('generate/speech', body, signal); | ||
| const result = await poll(pollUrl, { signal }); | ||
@@ -665,8 +636,4 @@ return { url: result.audio_url }; | ||
| async generateSound({ prompt, duration, model, signal, ...rest } = {}) { | ||
| assertKey(); | ||
| const body = { prompt, ...(duration ? { duration } : {}), ...(model ? { model } : {}), ...rest }; | ||
| const init = await fetch(`${apiBase}/v1/generate/sound`, { method: 'POST', headers: h(), body: JSON.stringify(body), signal }); | ||
| assertAuthed(init); | ||
| if (!init.ok) throw new Error(`Sound init failed: ${init.status}`); | ||
| const { pollUrl } = await init.json(); | ||
| const { pollUrl } = await post('generate/sound', body, signal); | ||
| const result = await poll(pollUrl, { signal }); | ||
@@ -676,8 +643,4 @@ return { url: result.audio_url }; | ||
| async transcribe({ audio, model, signal, ...rest } = {}) { | ||
| assertKey(); | ||
| const body = { audio, ...(model ? { model } : {}), ...rest }; | ||
| const init = await fetch(`${apiBase}/v1/transcribe`, { method: 'POST', headers: h(), body: JSON.stringify(body), signal }); | ||
| assertAuthed(init); | ||
| if (!init.ok) throw new Error(`Transcribe init failed: ${init.status}`); | ||
| const { pollUrl } = await init.json(); | ||
| const { pollUrl } = await post('transcribe', body, signal); | ||
| const result = await poll(pollUrl, { signal }); | ||
@@ -769,4 +732,11 @@ return { text: result.text, srt: result.srt, segments: result.segments }; | ||
| } | ||
| // Default the popup's return target to the CURRENT page, not a bare | ||
| // `/auth/callback`. The SDK consumes hash tokens on ANY page load | ||
| // (see init hash handler), so returning to the exact page that opened | ||
| // the popup is correct for every surface — and critically, on a dev | ||
| // preview snapshot served at /snap/<id>/index.html, `/auth/callback` | ||
| // resolves to the BUILDER/host root where this SDK never runs, so the | ||
| // popup can't hand the tokens back and sign-in stalls. | ||
| const redirectTo = options?.redirectTo | ||
| || (window.location.origin + '/auth/callback'); | ||
| || (window.location.origin + window.location.pathname); | ||
@@ -773,0 +743,0 @@ // Always use popup — both for iframed sandbox previews and top-level |
+11
-1
@@ -35,3 +35,13 @@ import { KolboAdapter } from './adapters/kolbo.js'; | ||
| const apiUrl = config.apiUrl ?? env.VITE_KOLBO_API_URL ?? ''; | ||
| if (!apiUrl) console.warn('[kolbo-sdk] VITE_KOLBO_API_URL is not set — backend unreachable'); | ||
| if (!apiUrl) { | ||
| // Fail loud, not silent. With an empty apiUrl, every adapter call becomes | ||
| // a relative-path fetch resolved against the iframe's host (e.g. | ||
| // http://localhost:8080/api/apps/<id>/storage → 404 → blank screen). | ||
| // Throwing here surfaces the misconfiguration in the error overlay instead. | ||
| throw new Error( | ||
| 'kolbo-sdk: VITE_KOLBO_API_URL is required but was not set when this app was built. ' + | ||
| 'The app cannot reach the backend. Rebuild after setting APP_BUILDER_PUBLIC_API_URL ' + | ||
| '(in kolbo-api .env) to a publicly-reachable HTTPS URL pointing at the API.' | ||
| ); | ||
| } | ||
@@ -38,0 +48,0 @@ const sandboxAccessToken = env.VITE_KOLBO_SANDBOX_ACCESS_TOKEN; |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
12
-42.86%81400
-1.67%1470
-1.41%