diff --git a/.claude/tolgee/.github-username b/.claude/tolgee/.github-username new file mode 100644 index 00000000000..30b02ead3d4 --- /dev/null +++ b/.claude/tolgee/.github-username @@ -0,0 +1 @@ +jancizmar \ No newline at end of file diff --git a/.gitignore b/.gitignore index aa37b6d42b5..259b4b8c408 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,9 @@ ngrok.local.yml # Worktree /worktree-env.sh +# Workspace package node_modules (apps/*, apps/example-apps/*) +**/node_modules/ + # mcp-publisher auth tokens /.mcpregistry_github_token /.mcpregistry_registry_token diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 00000000000..b841e01918d --- /dev/null +++ b/apps/README.md @@ -0,0 +1,15 @@ +# apps/ + +Code related to the Tolgee Apps plugin system lives here. + +## Layout + +- `tolgee-apps-sdk/` — the SDK package that plugin authors install + (`@tolgee/apps-sdk`). Currently a skeleton; will hold the typed + postMessage handshake, manifest schema, webhook signature verifier, + and JWT context helpers. To be published from this directory once + the contract stabilises. +- `example-apps/` — runnable example plugins built against the SDK. + - `dev-plugin/` — the in-tree reference plugin used during PoC + development. Demonstrates every module type and serves as the + template `create-tolgee-app` will copy from. diff --git a/apps/create-tolgee-app/.gitignore b/apps/create-tolgee-app/.gitignore new file mode 100644 index 00000000000..1521c8b7652 --- /dev/null +++ b/apps/create-tolgee-app/.gitignore @@ -0,0 +1 @@ +dist diff --git a/apps/create-tolgee-app/README.md b/apps/create-tolgee-app/README.md new file mode 100644 index 00000000000..a7ef733402a --- /dev/null +++ b/apps/create-tolgee-app/README.md @@ -0,0 +1,77 @@ +# create-tolgee-app + +Scaffolds a new Tolgee Apps plugin with a working dev environment in one +command. + +> **Status: PoC**. Not yet published. The generated project resolves +> `@tolgee/apps-sdk` via npm workspaces, so today the generator only +> works when invoked inside this repo. Run it under `apps/example-apps/` +> and the SDK symlink will pick up automatically. + +## What you get + +A project pre-wired with: + +- **Vite + React + TypeScript** for iframe modules +- **Express + tsx watch** for the webhook + decorator + manifest server +- **Cloudflare quick tunnel** (`scripts/dev.ts`) that boots on `npm run dev`, + captures a `*.trycloudflare.com` URL, and PATCHes your Tolgee install + so iframe loads and webhook deliveries reach the local services +- A `manifest.template.json` whose base URL is substituted at request time — + no manual editing on every restart +- **`@tolgee/apps-sdk`** wired in for the postMessage handshake, REST + client, webhook signature verification, and typed event dispatch + +## Wizard + +`npm run dev` (or once published, `npm create tolgee-app@latest`) walks +you through: + +1. **App identifier** — kebab-case slug used as `manifest.id`, the + target directory name, and the `name` in `package.json`. +2. **Display name** — shown in the Tolgee admin UI. +3. **Modules** — pick any subset of the 10 module types. + Iframe-bearing modules (`project-dashboard-page`, `translation-tools-panel`, + `key-edit-tab`, `modal`) get source-file stubs; the rest get manifest + entries pointing back at those iframes. +4. **Webhook events** — pick from a curated list of common + `ActivityType` values. The webhook route gets a typed `onWebhook(…)` + stub per event. +5. **Tolgee URL** — defaults to `https://apps.preview.tolgee.io`. +6. **Install deps** + **git init** prompts. + +## After generation + +```bash +cd +npm install # if you skipped it +npm run register # one-time: pick org, register the plugin +npm run dev # starts vite + express + tunnel, PATCHes manifest URL +``` + +Subsequent `npm run dev` restarts re-PATCH the install with the new +tunnel URL automatically — no manual reinstall. + +## Layout + +``` +apps/create-tolgee-app/ +├── src/ +│ ├── index.ts — entry, runs wizard, orchestrates copy +│ ├── wizard.ts — @clack/prompts flow +│ ├── copy.ts — recursive copy with mustache substitution +│ ├── manifest.ts — synthesizes manifest.template.json +│ └── registry.ts — module + webhook-event registry +├── template/ +│ ├── base/ — files every project gets +│ └── modules// — iframe stubs per module type +└── package.json +``` + +## Local build + +```bash +npm run --workspace=create-tolgee-app build +``` + +Outputs an ESM shebang script at `dist/index.js`. diff --git a/apps/create-tolgee-app/package.json b/apps/create-tolgee-app/package.json new file mode 100644 index 00000000000..1202ed60b34 --- /dev/null +++ b/apps/create-tolgee-app/package.json @@ -0,0 +1,31 @@ +{ + "name": "create-tolgee-app", + "version": "0.0.1-alpha.7", + "description": "Scaffold a new Tolgee Apps plugin with a working dev environment in one command.", + "type": "module", + "bin": { + "create-tolgee-app": "./dist/index.js" + }, + "files": [ + "dist", + "template" + ], + "scripts": { + "build": "tsup", + "dev": "tsx src/index.ts" + }, + "dependencies": { + "@clack/prompts": "^0.11.0", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@tolgee/apps-sdk": "*", + "@types/node": "^24.12.3", + "tsup": "^8.3.5", + "tsx": "^4.19.2", + "typescript": "~6.0.2" + }, + "engines": { + "node": ">=20" + } +} diff --git a/apps/create-tolgee-app/src/copy.ts b/apps/create-tolgee-app/src/copy.ts new file mode 100644 index 00000000000..a6aa33d32a1 --- /dev/null +++ b/apps/create-tolgee-app/src/copy.ts @@ -0,0 +1,91 @@ +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join, relative } from 'node:path' + +type Vars = Record + +const isProbablyBinary = (filename: string): boolean => { + return /\.(png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|otf|eot|mp4|webm)$/i.test( + filename + ) +} + +/** + * Mustache-style replacement: `{{name}}` → vars.name. Only single-word + * keys are substituted; missing keys leave the placeholder intact (so + * literal `{{` appearances in user-facing copy survive). + */ +const substitute = (text: string, vars: Vars): string => { + return text.replace(/\{\{(\w+)\}\}/g, (match, key: string) => { + return key in vars ? vars[key]! : match + }) +} + +/** + * Renames template-only file conventions to their real names: + * `_package.json` → `package.json` (avoids npm seeing the template as a real package) + * `_X` → `.X` (lets the template ship dotfiles) + */ +const targetFilename = (name: string): string => { + if (name === '_package.json') return 'package.json' + return name.startsWith('_') ? '.' + name.slice(1) : name +} + +export type CopyOptions = { + /** Source directory inside the template. */ + src: string + /** Destination directory for the generated project. */ + dst: string + /** Variables substituted into text files. */ + vars: Vars +} + +/** + * Recursively copies `src` → `dst`, substituting `{{var}}` placeholders + * in text files and renaming `_*` files to `.*`. + */ +export async function copyTree(opts: CopyOptions): Promise { + const entries = await readdir(opts.src, { withFileTypes: true }) + await mkdir(opts.dst, { recursive: true }) + for (const entry of entries) { + const srcPath = join(opts.src, entry.name) + const dstPath = join(opts.dst, targetFilename(entry.name)) + if (entry.isDirectory()) { + await copyTree({ ...opts, src: srcPath, dst: dstPath }) + } else if (entry.isFile()) { + if (isProbablyBinary(entry.name)) { + const buf = await readFile(srcPath) + await mkdir(dirname(dstPath), { recursive: true }) + await writeFile(dstPath, buf) + } else { + const text = await readFile(srcPath, 'utf8') + await mkdir(dirname(dstPath), { recursive: true }) + await writeFile(dstPath, substitute(text, opts.vars), 'utf8') + } + } + } +} + +/** + * Like {@link copyTree} but takes a list of relative source files. + * Useful when only some files from a directory should be copied. + */ +export async function copyFiles( + baseSrc: string, + baseDst: string, + relativePaths: string[], + vars: Vars +): Promise { + for (const relPath of relativePaths) { + const segments = relPath.split('/').map((s) => targetFilename(s)) + const srcPath = join(baseSrc, relPath) + const dstPath = join(baseDst, segments.join('/')) + const text = await readFile(srcPath, 'utf8') + await mkdir(dirname(dstPath), { recursive: true }) + await writeFile(dstPath, substitute(text, vars), 'utf8') + } +} + +/** For diagnostics only — relative path from a template root. */ +export const fromTemplate = (templateRoot: string, abs: string): string => { + return relative(templateRoot, abs) +} diff --git a/apps/create-tolgee-app/src/index.ts b/apps/create-tolgee-app/src/index.ts new file mode 100644 index 00000000000..3afd39e8caf --- /dev/null +++ b/apps/create-tolgee-app/src/index.ts @@ -0,0 +1,281 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { log, note, outro, spinner } from '@clack/prompts' +import pc from 'picocolors' +import { copyTree } from './copy' +import { buildManifest } from './manifest' +import { MODULES, type ModuleKey } from './registry' +import { + detectPackageManager, + findFreePortPair, + normalizeUrl, + packageManagerCommand, + runWizard, + toTitle, + type Answers, +} from './wizard' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +/** + * tsup bundles a single dist/index.js next to the project root. The + * template/ folder ships under the package root, so we walk up one + * level from dist/. + */ +const PACKAGE_ROOT = resolve(__dirname, '..') +const TEMPLATE_ROOT = join(PACKAGE_ROOT, 'template') + +/** + * Version to pin the generated app's `@tolgee/*` deps to. In the monorepo the + * apps-sdk/apps-dev packages sit next to this one, so we link them as workspace + * deps (`*`); when published, those siblings are absent and we pin to this CLI's + * own version (apps-sdk + apps-dev are released in lockstep). Detection is by + * sibling presence, not version number, so it survives release bumps. + */ +const internalDepVersion = (): string => { + const inRepo = existsSync(resolve(PACKAGE_ROOT, '..', 'tolgee-apps-sdk', 'package.json')) + if (inRepo) return '*' + try { + const pkg = JSON.parse( + readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8') + ) as { version?: string } + return pkg.version ?? '*' + } catch { + return '*' + } +} + +const MODULE_ENTRY_PATHS: Record = { + 'project-dashboard-page': { route: '/dashboard', importPath: './modules/dashboard' }, + 'translation-tools-panel': { route: '/tools-panel', importPath: './modules/toolsPanel' }, + 'translation-tools-panel-empty': { route: '/tools-panel-empty', importPath: './modules/toolsPanelEmpty' }, + 'key-edit-tab': { route: '/key-edit-tab', importPath: './modules/keyEditTab' }, + modal: { route: '/modal', importPath: './modules/modal' }, + 'key-action': { route: '', importPath: '' }, + 'translation-action': { route: '', importPath: '' }, + 'bulk-action': { route: '', importPath: '' }, + 'translations-toolbar-action': { route: '', importPath: '' }, + 'project-menu-action': { route: '', importPath: '' }, + shortcut: { route: '', importPath: '' }, +} + +const main = async (): Promise => { + const answers = await resolveAnswers(process.argv.slice(2)) + + if (existsSync(answers.targetDir)) { + log.error(`Target ${pc.bold(answers.targetDir)} already exists.`) + process.exit(1) + } + + await scaffold(answers) + + const pm = detectPackageManager() + const next: string[] = [ + `cd ${answers.id}`, + answers.installDeps ? null : packageManagerCommand(pm, 'install'), + `${packageManagerCommand(pm, 'run', 'dev')} # terminal 1: vite + server + tunnel`, + `${packageManagerCommand(pm, 'run', 'register')} # terminal 2, first run only: browser install`, + ].filter((s): s is string => s !== null) + note(next.join('\n'), 'Next steps') + outro(pc.cyan(`${answers.name} is ready.`)) +} + +/** + * Resolves answers either from the interactive wizard or, when `--yes`/`-y` is + * passed, from CLI flags + defaults (headless mode for CI and scripted scaffolds). + * + * create-tolgee-app my-app --yes \ + * --modules=project-dashboard-page,key-action \ + * --scopes=translations.view,keys.edit --webhooks=SET_TRANSLATIONS + */ +const resolveAnswers = async (argv: string[]): Promise => { + const positional = argv.find((a) => !a.startsWith('-')) + const flags = new Map( + argv + .filter((a) => a.startsWith('--')) + .map((a) => { + const [k, ...v] = a.slice(2).split('=') + return [k, v.join('=')] as const + }) + ) + const nonInteractive = flags.has('yes') || argv.includes('-y') + + if (!nonInteractive) { + return runWizard(positional) + } + + const id = positional ?? flags.get('name') + if (!id || !/^[a-z][a-z0-9-]*$/.test(id)) { + log.error('Headless mode (--yes) needs a kebab-case app name, e.g. `create-tolgee-app my-app --yes`.') + process.exit(1) + } + + const csv = (key: string): string[] | undefined => { + const raw = flags.get(key) + if (raw === undefined) return undefined + return raw.split(',').map((s) => s.trim()).filter(Boolean) + } + + const selectedModules = (csv('modules') ?? ['project-dashboard-page']) as ModuleKey[] + const unknown = selectedModules.filter((m) => !MODULES.some((info) => info.key === m)) + if (unknown.length > 0) { + log.error(`Unknown module(s): ${unknown.join(', ')}`) + process.exit(1) + } + + return { + id, + name: flags.get('name') && flags.get('name') !== id ? (flags.get('name') as string) : toTitle(id), + targetDir: resolve(process.cwd(), id), + selectedModules, + scopes: csv('scopes') ?? ['translations.view', 'keys.view'], + webhookEvents: csv('webhooks') ?? [], + tolgeeUrl: normalizeUrl(flags.get('tolgee-url') ?? 'https://apps.preview.tolgee.io'), + ...(await resolvePorts(flags)), + installDeps: flags.has('install'), + gitInit: flags.has('git'), + } +} + +/** Headless port resolution: explicit flags win, otherwise auto-pick a free pair. */ +const resolvePorts = async ( + flags: Map +): Promise<{ vitePort: number; serverPort: number }> => { + const [freeVite, freeServer] = await findFreePortPair() + const vitePort = flags.has('vite-port') ? Number(flags.get('vite-port')) : freeVite + const serverPort = flags.has('server-port') + ? Number(flags.get('server-port')) + : flags.has('vite-port') + ? vitePort + 1 + : freeServer + if (!Number.isInteger(vitePort) || !Number.isInteger(serverPort)) { + log.error('--vite-port / --server-port must be integers.') + process.exit(1) + } + return { vitePort, serverPort } +} + +/** Copies the template, applies module/manifest/webhook substitutions, optionally installs + inits git. */ +const scaffold = async (answers: Answers): Promise => { + // `routes` and `webhookHandlers` are NOT in vars — they're replaced by + // targeted edits after copyTree's mustache pass so the placeholders survive + // past `substitute()`. + const depVersion = internalDepVersion() + const baseVars = { + id: answers.id, + name: answers.name, + tolgeeUrl: answers.tolgeeUrl, + sdkVersion: depVersion, + devVersion: depVersion, + } + + const s = spinner() + s.start('Copying template') + await copyTree({ src: join(TEMPLATE_ROOT, 'base'), dst: answers.targetDir, vars: baseVars }) + + const iframeModules = answers.selectedModules.filter( + (k) => MODULES.find((m) => m.key === k)?.hasIframe ?? false + ) + for (const moduleKey of iframeModules) { + await copyTree({ + src: join(TEMPLATE_ROOT, 'modules', moduleKey), + dst: answers.targetDir, + vars: baseVars, + }) + } + + const mainTsxPath = join(answers.targetDir, 'src/main.tsx') + const routesBody = iframeModules + .map((k) => { + const r = MODULE_ENTRY_PATHS[k] + return ` '${r.route}': () => import('${r.importPath}'),` + }) + .join('\n') + await replaceInFile(mainTsxPath, ' // {{routes}}', routesBody) + + const webhookPath = join(answers.targetDir, 'server/routes/webhook.ts') + if (answers.webhookEvents.length === 0) { + // No events selected: drop the now-unused `onWebhook` import (the project + // has noUnusedLocals) and keep `payload` referenced. + await replaceInFile( + webhookPath, + "import { onWebhook, receiveWebhook } from '@tolgee/apps-sdk/server'", + "import { receiveWebhook } from '@tolgee/apps-sdk/server'" + ) + await replaceInFile( + webhookPath, + ' // {{webhookHandlers}}', + ' // No webhook events selected — add `onWebhook(payload, ...)` calls here.\n void payload' + ) + } else { + const handlersBody = answers.webhookEvents + .map( + (event) => + ` onWebhook(payload, '${event}', (typed) => {\n console.log('[webhook] ${event}', typed.activityData?.timestamp)\n })` + ) + .join('\n') + await replaceInFile(webhookPath, ' // {{webhookHandlers}}', handlersBody) + } + + const manifest = buildManifest({ + id: answers.id, + name: answers.name, + selectedModules: answers.selectedModules, + scopes: answers.scopes, + webhookEvents: answers.webhookEvents, + }) + const manifestPath = join(answers.targetDir, 'server/manifest.template.json') + await mkdir(dirname(manifestPath), { recursive: true }) + await writeFile(manifestPath, manifest, 'utf8') + + // Write a ready-to-run .env.local. The webhook secret is NOT written here — + // `tolgee-app register` stores it in .tolgee-dev/install.json and the server + // reads it from there. + const envLocal = + `# Local dev config written by create-tolgee-app. Gitignored.\n` + + `TOLGEE_URL=${answers.tolgeeUrl}\n` + + `VITE_PORT=${answers.vitePort}\n` + + `SERVER_PORT=${answers.serverPort}\n` + await writeFile(join(answers.targetDir, '.env.local'), envLocal, 'utf8') + + s.stop(`Project scaffolded (dev ports ${answers.vitePort}/${answers.serverPort})`) + + const pm = detectPackageManager() + if (answers.installDeps) { + const install = spinner() + install.start(`Installing dependencies with ${pm}`) + const result = spawnSync(pm, ['install'], { cwd: answers.targetDir, stdio: 'inherit' }) + if (result.status !== 0) { + install.stop(`${pm} install failed`) + process.exit(result.status ?? 1) + } + install.stop('Dependencies installed') + } + + if (answers.gitInit) { + spawnSync('git', ['init', '-q'], { cwd: answers.targetDir }) + spawnSync('git', ['add', '.'], { cwd: answers.targetDir }) + spawnSync('git', ['commit', '-q', '-m', 'chore: scaffold with create-tolgee-app'], { + cwd: answers.targetDir, + stdio: 'ignore', + }) + } +} + +const replaceInFile = async ( + path: string, + needle: string, + replacement: string +): Promise => { + const current = await readFile(path, 'utf8') + if (!current.includes(needle)) return + await writeFile(path, current.replace(needle, replacement), 'utf8') +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/apps/create-tolgee-app/src/manifest.ts b/apps/create-tolgee-app/src/manifest.ts new file mode 100644 index 00000000000..7066231c063 --- /dev/null +++ b/apps/create-tolgee-app/src/manifest.ts @@ -0,0 +1,88 @@ +import type { + AppManifest, + AppModules, + WebhookType, +} from '@tolgee/apps-sdk' +import { MODULES, type ModuleKey } from './registry' + +export type ManifestInput = { + id: string + name: string + selectedModules: ModuleKey[] + scopes: string[] + webhookEvents: string[] +} + +/** + * Renders the contents of `server/manifest.template.json` from wizard + * answers. The template uses `__BASE_URL__` placeholders that the + * generated server substitutes at request time with the live tunnel + * URL (or `http://localhost:5180` when no tunnel is active). + */ +export const buildManifest = (input: ManifestInput): string => { + const modules: AppModules = {} + + for (const m of input.selectedModules) { + modules[m] = entriesFor(m) as never + } + + const manifest: AppManifest = { + id: input.id, + name: input.name, + version: '0.1.0', + baseUrl: '__BASE_URL__', + decoratorsUrl: '__BASE_URL__/decorators', + scopes: input.scopes, + modules, + } + + if (input.webhookEvents.length > 0) { + manifest.webhooks = { + events: input.webhookEvents as WebhookType[], + url: '__BASE_URL__/webhook', + } + } + + return JSON.stringify(manifest, null, 2) + '\n' +} + +const TAB_KEY = 'tab' +const PANEL_KEY = 'panel' +const MODAL_KEY = 'modal' + +const entriesFor = (key: ModuleKey): unknown[] => { + const info = MODULES.find((m) => m.key === key) + if (!info) return [] + switch (key) { + case 'project-dashboard-page': + return [{ key: info.defaultEntryKey, title: 'Dashboard', icon: 'LayoutAlt04', entry: info.entryPath }] + case 'translation-tools-panel': + return [{ key: PANEL_KEY, title: 'Plugin panel', icon: 'BarChart01', entry: info.entryPath }] + case 'translation-tools-panel-empty': + return [{ key: 'empty-panel', title: 'Languages panel', icon: 'Globe01', entry: info.entryPath }] + case 'key-edit-tab': + return [{ key: TAB_KEY, title: 'Plugin tab', icon: 'Shield01', entry: info.entryPath }] + case 'modal': + return [ + { key: MODAL_KEY, title: 'Plugin modal', icon: 'Lightbulb01', entry: info.entryPath, width: 640, height: 400 }, + ] + case 'key-action': + // Two surfaces: a static one that opens a key-edit tab, and a dynamic + // one whose icon/visibility is driven by the app's decoratorsUrl. + return [ + { key: 'open-tab', type: 'tab', icon: 'ArrowUpRight', tooltip: 'Open in plugin', tabKey: TAB_KEY }, + { key: 'dynamic-flag', type: 'link', dynamic: true, icon: 'Lightbulb01', tooltip: 'Flagged by plugin' }, + ] + case 'translation-action': + return [{ key: info.defaultEntryKey, type: 'panel', icon: 'ArrowUpRight', tooltip: 'Open plugin panel', panelKey: PANEL_KEY }] + case 'bulk-action': + return [{ key: info.defaultEntryKey, title: 'Run on selection', icon: 'Target01', type: 'modal', modalKey: MODAL_KEY }] + case 'translations-toolbar-action': + // Static external link — opens the plugin's own page in a new tab. + return [{ key: info.defaultEntryKey, title: 'Open plugin', icon: 'LinkExternal01', type: 'link', urlTemplate: '__BASE_URL__/' }] + case 'project-menu-action': + return [{ key: info.defaultEntryKey, title: 'Configure plugin', icon: 'Settings01', type: 'modal', modalKey: MODAL_KEY }] + case 'shortcut': + return [{ key: info.defaultEntryKey, combination: 'Mod+Shift+E', type: 'modal', modalKey: MODAL_KEY }] + } +} diff --git a/apps/create-tolgee-app/src/registry.ts b/apps/create-tolgee-app/src/registry.ts new file mode 100644 index 00000000000..361d63adae0 --- /dev/null +++ b/apps/create-tolgee-app/src/registry.ts @@ -0,0 +1,168 @@ +/** + * Single source of truth for the modules and webhook events the wizard + * offers. Add to these to expand what the generator can scaffold. + */ + +export type ModuleKey = + | 'project-dashboard-page' + | 'translation-tools-panel' + | 'translation-tools-panel-empty' + | 'key-edit-tab' + | 'modal' + | 'key-action' + | 'translation-action' + | 'bulk-action' + | 'translations-toolbar-action' + | 'project-menu-action' + | 'shortcut' + +export type ModuleInfo = { + key: ModuleKey + label: string + description: string + /** + * Iframe-bearing modules need a source-file stub under + * `template/modules//`. Other module types only get a manifest + * entry that points at another iframe module or a URL template. + */ + hasIframe: boolean + /** Stable slug used inside the manifest's `key` field. */ + defaultEntryKey: string + /** Path the manifest's `entry` (or `urlTemplate`) field uses. */ + entryPath?: string + /** Required only for `key-action` / `translation-action`. */ + needsTabOrPanelKey?: 'tab' | 'panel' +} + +export const MODULES: ModuleInfo[] = [ + { + key: 'project-dashboard-page', + label: 'Project dashboard page', + description: 'Full-area page in the project sidebar.', + hasIframe: true, + defaultEntryKey: 'dashboard', + entryPath: '/dashboard', + }, + { + key: 'translation-tools-panel', + label: 'Translation tools panel', + description: 'Collapsible right-side panel in the translations view.', + hasIframe: true, + defaultEntryKey: 'panel', + entryPath: '/tools-panel', + }, + { + key: 'translation-tools-panel-empty', + label: 'Translation tools panel (no selection)', + description: + 'Panel shown in the translations tools area when no cell is selected; receives the selected language tags.', + hasIframe: true, + defaultEntryKey: 'empty-panel', + entryPath: '/tools-panel-empty', + }, + { + key: 'key-edit-tab', + label: 'Key-edit dialog tab', + description: 'Tab inside the key-edit modal.', + hasIframe: true, + defaultEntryKey: 'tab', + entryPath: '/key-edit-tab', + }, + { + key: 'modal', + label: 'Modal', + description: 'Iframe modal triggered from any *-action surface.', + hasIframe: true, + defaultEntryKey: 'modal', + entryPath: '/modal', + }, + { + key: 'key-action', + label: 'Key action icon', + description: + 'Icon button on each key row that opens a modal, key-edit tab, or external link.', + hasIframe: false, + defaultEntryKey: 'key-shortcut', + needsTabOrPanelKey: 'tab', + }, + { + key: 'translation-action', + label: 'Translation action icon', + description: + 'Icon button on each translation cell that opens a panel or external link.', + hasIframe: false, + defaultEntryKey: 'translation-shortcut', + needsTabOrPanelKey: 'panel', + }, + { + key: 'bulk-action', + label: 'Bulk action', + description: 'Item in the bulk-action bar when ≥1 keys are selected.', + hasIframe: false, + defaultEntryKey: 'bulk-explain', + }, + { + key: 'translations-toolbar-action', + label: 'Translations toolbar action', + description: 'Button in the translations-view toolbar.', + hasIframe: false, + defaultEntryKey: 'toolbar-explain', + }, + { + key: 'project-menu-action', + label: 'Project menu action', + description: 'Sidebar entry that opens a modal instead of routing.', + hasIframe: false, + defaultEntryKey: 'menu-explain', + }, + { + key: 'shortcut', + label: 'Keyboard shortcut', + description: 'Global keyboard shortcut binding (e.g. Mod+Shift+E).', + hasIframe: false, + defaultEntryKey: 'shortcut-explain', + }, +] + +/** + * Curated subset of Tolgee permission scopes the wizard offers. Each value is a + * valid `Scope` the platform accepts in `manifest.scopes`; the app is granted the + * intersection of these and the installing user's project permissions at runtime. + */ +export const SCOPES: { value: string; label: string }[] = [ + { value: 'translations.view', label: 'Read translations' }, + { value: 'translations.edit', label: 'Edit translations' }, + { value: 'translations.state-edit', label: 'Change translation states' }, + { value: 'translations.suggest', label: 'Suggest translations' }, + { value: 'keys.view', label: 'Read keys' }, + { value: 'keys.create', label: 'Create keys' }, + { value: 'keys.edit', label: 'Edit keys' }, + { value: 'keys.delete', label: 'Delete keys' }, + { value: 'screenshots.view', label: 'View screenshots' }, + { value: 'screenshots.upload', label: 'Upload screenshots' }, + { value: 'activity.view', label: 'View activity' }, + // Organization-level scopes (let the app manage glossary terms in the org it's installed in). + { value: 'glossary.view', label: 'Read glossary terms' }, + { value: 'glossary.edit', label: 'Edit glossary terms' }, +] + +/** + * Curated subset of Tolgee `ActivityType` values most apps care about. + * The full list is large; the wizard surfaces these by default with an + * "I'll edit manifest.template.json myself" escape hatch. + */ +export const WEBHOOK_EVENTS: { value: string; label: string }[] = [ + { value: 'SET_TRANSLATIONS', label: 'Translation text changed' }, + { value: 'SET_TRANSLATION_STATE', label: 'Translation state changed' }, + { value: 'CREATE_KEY', label: 'Key created' }, + { value: 'KEY_DELETE', label: 'Key deleted' }, + { value: 'KEY_NAME_EDIT', label: 'Key renamed' }, + { value: 'KEY_TAGS_EDIT', label: 'Key tags changed' }, + { + value: 'BATCH_SET_TRANSLATION_STATE', + label: 'Translation state changed (batch)', + }, + { value: 'BATCH_KEY_DELETE', label: 'Keys deleted (batch)' }, + { value: 'BATCH_KEY_RESTORE', label: 'Keys restored (batch)' }, + { value: 'IMPORT', label: 'Import finished' }, +] diff --git a/apps/create-tolgee-app/src/wizard.ts b/apps/create-tolgee-app/src/wizard.ts new file mode 100644 index 00000000000..2c9438ccf08 --- /dev/null +++ b/apps/create-tolgee-app/src/wizard.ts @@ -0,0 +1,226 @@ +import { + cancel, + confirm, + intro, + isCancel, + multiselect, + text, +} from '@clack/prompts' +import { existsSync } from 'node:fs' +import { createServer } from 'node:net' +import { resolve } from 'node:path' +import pc from 'picocolors' +import { MODULES, SCOPES, WEBHOOK_EVENTS, type ModuleKey } from './registry' + +export type Answers = { + id: string + name: string + targetDir: string + selectedModules: ModuleKey[] + scopes: string[] + webhookEvents: string[] + tolgeeUrl: string + vitePort: number + serverPort: number + installDeps: boolean + gitInit: boolean +} + +const detectPackageManager = (): 'npm' | 'yarn' | 'pnpm' | 'bun' => { + const ua = process.env.npm_config_user_agent ?? '' + if (ua.startsWith('yarn')) return 'yarn' + if (ua.startsWith('pnpm')) return 'pnpm' + if (ua.startsWith('bun')) return 'bun' + return 'npm' +} + +const validateName = (raw: unknown): string | undefined => { + if (typeof raw !== 'string' || raw.length === 0) return 'Name is required.' + if (!/^[a-z][a-z0-9-]*$/.test(raw)) { + return 'Use kebab-case: lowercase letters, digits, hyphens.' + } + if (existsSync(resolve(process.cwd(), raw))) { + return `Directory "${raw}" already exists.` + } + return undefined +} + +const abortIfCancelled = (value: T | symbol): T => { + if (isCancel(value)) { + cancel('Cancelled.') + process.exit(0) + } + return value as T +} + +export async function runWizard(initialName?: string): Promise { + intro(pc.cyan('create-tolgee-app')) + + const id = abortIfCancelled( + await text({ + message: 'App identifier (kebab-case — used as folder + manifest id):', + initialValue: initialName, + validate: validateName, + }) + ) + + const name = abortIfCancelled( + await text({ + message: 'Display name (shown in Tolgee admin UI):', + initialValue: toTitle(id), + validate: (v) => + typeof v === 'string' && v.length > 0 ? undefined : 'Required.', + }) + ) + + const selectedModules = abortIfCancelled( + await multiselect({ + message: 'Which modules should the plugin contribute?', + required: true, + options: MODULES.map((m) => ({ + value: m.key, + label: m.label, + hint: m.description, + })), + initialValues: ['project-dashboard-page'] satisfies ModuleKey[], + }) + ) as ModuleKey[] + + const scopes = abortIfCancelled( + await multiselect({ + message: 'Which permission scopes does the plugin need?', + required: false, + options: SCOPES.map((s) => ({ value: s.value, label: s.label })), + initialValues: ['translations.view', 'keys.view'], + }) + ) as string[] + + const webhookEvents = abortIfCancelled( + await multiselect({ + message: 'Which webhook events should the plugin subscribe to?', + required: false, + options: WEBHOOK_EVENTS.map((e) => ({ value: e.value, label: e.label })), + initialValues: [], + }) + ) as string[] + + const tolgeeUrl = normalizeUrl( + abortIfCancelled( + await text({ + message: 'Tolgee URL:', + initialValue: 'https://apps.preview.tolgee.io', + validate: validateUrl, + }) + ) + ) + + const [freeVite, freeServer] = await findFreePortPair() + const [vitePort, serverPort] = parsePortPair( + abortIfCancelled( + await text({ + message: 'Dev ports (Vite, Server) — auto-picked free pair, edit if you like:', + initialValue: `${freeVite}, ${freeServer}`, + validate: validatePortPair, + }) + ) + ) + + const pm = detectPackageManager() + const installDeps = abortIfCancelled( + await confirm({ + message: `Install dependencies now with ${pm}?`, + initialValue: true, + }) + ) + + const gitInit = abortIfCancelled( + await confirm({ + message: 'Initialize a git repo and make the initial commit?', + initialValue: true, + }) + ) + + return { + id, + name, + targetDir: resolve(process.cwd(), id), + selectedModules, + scopes, + webhookEvents, + tolgeeUrl, + vitePort, + serverPort, + installDeps, + gitInit, + } +} + +/** True if a TCP port is free to bind on any interface (IPv4 + IPv6). */ +const isPortFree = (port: number): Promise => + new Promise((res) => { + const srv = createServer() + srv.once('error', () => res(false)) + srv.once('listening', () => srv.close(() => res(true))) + srv.listen(port) + }) + +/** First free (vite, server) pair, scanning 5180/5181, 5280/5281, … so plugins don't clash. */ +export const findFreePortPair = async (): Promise<[number, number]> => { + for (let base = 5180; base <= 5980; base += 100) { + if ((await isPortFree(base)) && (await isPortFree(base + 1))) { + return [base, base + 1] + } + } + return [5180, 5181] +} + +export const validatePortPair = (v: unknown): string | undefined => { + if (typeof v !== 'string') return 'Required.' + const parts = v.split(',').map((s) => s.trim()) + if (parts.length !== 2) return 'Give two ports separated by a comma, e.g. `5180, 5181`.' + for (const p of parts) { + const n = Number(p) + if (!Number.isInteger(n) || n < 1 || n > 65535) return `"${p}" is not a valid port.` + } + if (parts[0] === parts[1]) return 'Vite and server ports must differ.' + return undefined +} + +/** Parses a validated `"vite, server"` string into a numeric pair. */ +export const parsePortPair = (v: string): [number, number] => { + const [vite, server] = v.split(',').map((s) => Number(s.trim())) + return [vite, server] +} + +export const validateUrl = (v: unknown): string | undefined => { + if (typeof v !== 'string' || v.length === 0) return 'Required.' + if ((v.match(/:\/\//g) ?? []).length > 1) { + return 'URL has a duplicated scheme (e.g. http://http://…).' + } + let url: URL + try { + url = new URL(v) + } catch { + return 'Not a valid URL.' + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return 'Must start with http:// or https://.' + } + return undefined +} + +export const normalizeUrl = (v: string): string => v.trim().replace(/\/+$/, '') + +export const toTitle = (slug: string): string => { + return slug + .split('-') + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(' ') +} + +export const packageManagerCommand = ( + pm: ReturnType, + ...args: string[] +): string => `${pm} ${args.join(' ')}` + +export { detectPackageManager } diff --git a/apps/create-tolgee-app/template/base/CLAUDE.md b/apps/create-tolgee-app/template/base/CLAUDE.md new file mode 100644 index 00000000000..211c311877b --- /dev/null +++ b/apps/create-tolgee-app/template/base/CLAUDE.md @@ -0,0 +1,63 @@ +# {{name}} — Tolgee App + +This is a **Tolgee App**: a hosted web plugin that extends the Tolgee localization platform with +custom UI (iframe modules), reacts to project events (webhooks), and decorates translation rows. +It was scaffolded by `create-tolgee-app`. + +## How this project is wired + +- `manifest.template.json` — declares what the app contributes (modules, scopes, webhooks, + `decoratorsUrl`). `__BASE_URL__` is substituted with the live URL at request time. Edit this to + add/remove UI surfaces. +- `src/` — the iframe modules (React). They use `@tolgee/apps-sdk/browser`. +- `server/` — an Express backend exposing `/manifest.json`, `/webhook`, and `/decorators`. It uses + `@tolgee/apps-sdk/server`. +- `npm run dev` runs Vite + the server + a dev tunnel; `npm run register` installs the app on a + Tolgee instance (one-time). Run `dev` first, then `register` in a second terminal. + +## Typesafety — verify after every change + +This project is strict TypeScript (`strict`, `noUnusedLocals`, `noUnusedParameters`). The SDK is +fully typed, so **lean on the types and check them after every change**: + +```bash +npm run typecheck # tsc -b — fast, no build output; run this after any edit +``` + +Do not consider a change done until `npm run typecheck` passes. Prefer the typed SDK entry points +over hand-rolled `fetch`/types: + +- **REST calls** — use `createTolgeeAppClient(ctx)` (`@tolgee/apps-sdk/browser`). It's a typed client; + endpoints, params, and responses are checked against Tolgee's OpenAPI schema. +- **Webhooks** — use `receiveWebhook(...)` to verify + parse, then `onWebhook(payload, 'EVENT', cb)` + to narrow the payload to that event's type. Don't cast webhook bodies by hand. +- **Manifest** — the `AppManifest` types from `@tolgee/apps-sdk` describe every field; keep + `manifest.template.json` consistent with them. + +## Where to look when building + +- **Docs**: https://docs.tolgee.io/apps — introduction, setup, full API reference, and tutorials. +- **SDK types**: `node_modules/@tolgee/apps-sdk/dist/*.d.ts` — the authoritative signatures available + in this project right now. +- **Deep context** (optional, recommended for AI-assisted work): run + + ```bash + npm run pull-context + ``` + + This clones the relevant Tolgee sources into `.context/` (gitignored). After it runs, look in: + - `.context/tolgee-platform/apps/tolgee-apps-sdk/src/` — SDK source (exact behavior of every helper). + - `.context/tolgee-platform/apps/example-apps/dev-plugin/` — a full activity-monitoring example + (webhooks, decorators, panel, dashboard). + - `.context/tolgee-platform/apps/example-apps/back-translate/` — a full LLM quality-check example. + - `.context/documentation/tolgee-apps/` — the documentation source. + + When unsure how a feature behaves, read the SDK source and the example apps in `.context/` — they + are the ground truth. + +## Conventions + +- Keep the server's webhook route registered **before** any JSON body parser — webhook signatures are + verified over the raw request bytes. +- Decorator and custom endpoints must return the SDK's CORS headers (`tolgeeAppCorsHeaders()`). +- After backend edits, re-check the manifest and run `npm run typecheck`. diff --git a/apps/create-tolgee-app/template/base/README.md b/apps/create-tolgee-app/template/base/README.md new file mode 100644 index 00000000000..f647c748799 --- /dev/null +++ b/apps/create-tolgee-app/template/base/README.md @@ -0,0 +1,47 @@ +# {{name}} + +A Tolgee Apps plugin scaffolded by `create-tolgee-app`. + +## Quick start + +```bash +npm install # if you skipped it during scaffolding +npm run register # one-time: pick your org, register the plugin +npm run dev # boots Vite (5180) + Express (5181) + Cloudflare tunnel +``` + +The first `npm run dev` after registration PATCHes the install in Tolgee +to point at the fresh tunnel URL. Restart `dev` as many times as you like — +each restart re-PATCHes; no manual reinstall. + +The dev tunnel + the one-time install flow are provided by the `@tolgee/apps-dev` +CLI (`tolgee-app dev` / `tolgee-app register`), which the npm scripts above invoke — +nothing to maintain in this repo. + +## Layout + +``` +server/manifest.template.json ← edit this; baseUrl is __BASE_URL__ at request time +src/ ← iframe modules (one folder per `entry`) +server/ ← webhook + decorator + manifest endpoints +``` + +## What the SDK gives you + +- **`createTolgeeApp()`** in iframes — postMessage handshake, JWT + context, selection updates. +- **`createTolgeeAppClient(ctx)`** — typed REST client to Tolgee with + the install token pre-attached. +- **`verifyWebhookSignature({ header, rawBody, secret })`** — pure + WebCrypto-based HMAC check matching Tolgee's `Tolgee-Signature` header. +- **`onWebhook(payload, 'EVENT_NAME', handler)`** — typed dispatcher + that narrows `payload.activityData` by event type. +- **`decodeContextToken(jwt)`** — extract install/project/user ids + from the JWT on the server. + +## Editing the manifest + +`server/manifest.template.json` is the source of truth. The server reads +it on every `/manifest.json` request and substitutes `__BASE_URL__` with +the live tunnel URL. After saving edits, restart `npm run dev` (which +re-PATCHes the install) to have Tolgee re-fetch. diff --git a/apps/create-tolgee-app/template/base/_env.example b/apps/create-tolgee-app/template/base/_env.example new file mode 100644 index 00000000000..14f79c4c506 --- /dev/null +++ b/apps/create-tolgee-app/template/base/_env.example @@ -0,0 +1,18 @@ +# Tolgee instance the plugin targets. Used by `tolgee-app register` and +# `tolgee-app dev`. When this points at localhost, the dev command skips +# Cloudflare (Tolgee can reach the plugin directly). +TOLGEE_URL={{tolgeeUrl}} + +# Force-disable the tunnel even when Tolgee is remote. Useful with a +# custom proxy / Named Tunnel you boot yourself. +# TOLGEE_DEV_TUNNEL=none + +# Ports the dev servers bind to. Override when running multiple plugins +# side-by-side so each one gets its own pair. +# VITE_PORT=5180 +# SERVER_PORT=5181 + +# Webhook signing secret. In dev you do NOT need to set this — the server reads +# it from .tolgee-dev/install.json (written by `tolgee-app register`). Set it +# only in production deploys, where there is no install file. +# TOLGEE_WEBHOOK_SECRET=tgappw_xxx diff --git a/apps/create-tolgee-app/template/base/_gitignore b/apps/create-tolgee-app/template/base/_gitignore new file mode 100644 index 00000000000..ebcb250e618 --- /dev/null +++ b/apps/create-tolgee-app/template/base/_gitignore @@ -0,0 +1,21 @@ +node_modules +dist +dist-ssr +*.local +*.tsbuildinfo + +# Tunnel + install state written by `npm run dev` / `npm run register`. +.tolgee-dev/ + +# Tolgee sources/docs pulled by `npm run pull-context` for AI-assisted dev. +.context/ + +# Local env overrides. +.env.local + +.DS_Store +.vscode/* +!.vscode/extensions.json +.idea +*.iml +*.log diff --git a/apps/create-tolgee-app/template/base/_package.json b/apps/create-tolgee-app/template/base/_package.json new file mode 100644 index 00000000000..e3c4ed32e58 --- /dev/null +++ b/apps/create-tolgee-app/template/base/_package.json @@ -0,0 +1,36 @@ +{ + "name": "{{id}}", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "concurrently -k -n vite,server,tunnel -c blue,green,yellow \"npm:dev:frontend\" \"npm:dev:server\" \"npm:dev:tunnel\"", + "dev:frontend": "vite", + "dev:server": "tsx watch --env-file-if-exists=.env.local server/index.ts", + "dev:tunnel": "tolgee-app dev", + "register": "tolgee-app register", + "typecheck": "tsc -b", + "build": "tsc -b && vite build", + "preview": "vite preview", + "pull-context": "node scripts/pull-context.mjs" + }, + "dependencies": { + "@tginternal/client": "0.0.0-prerelease.c8329b552", + "@tolgee/apps-sdk": "{{sdkVersion}}", + "express": "^4.21.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@tolgee/apps-dev": "{{devVersion}}", + "@types/express": "^4.17.21", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "concurrently": "^9.1.0", + "tsx": "^4.19.2", + "typescript": "~6.0.2", + "vite": "^8.0.12" + } +} diff --git a/apps/create-tolgee-app/template/base/index.html b/apps/create-tolgee-app/template/base/index.html new file mode 100644 index 00000000000..0437cb9b7e6 --- /dev/null +++ b/apps/create-tolgee-app/template/base/index.html @@ -0,0 +1,12 @@ + + + + + + {{name}} + + +
+ + + diff --git a/apps/create-tolgee-app/template/base/scripts/pull-context.mjs b/apps/create-tolgee-app/template/base/scripts/pull-context.mjs new file mode 100644 index 00000000000..64f9566031b --- /dev/null +++ b/apps/create-tolgee-app/template/base/scripts/pull-context.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * Pulls Tolgee sources + docs into `.context/` (gitignored) so an AI assistant + * has the SDK source, the example apps, and the documentation locally when + * building this Tolgee App. The result is a read-only snapshot (no .git), so + * each run replaces the previous one. + * + * Alpha note: Tolgee Apps is in alpha, so context is pulled from the apps + * feature branches. Update the refs below once Apps ships to `main`. + */ +import { execSync } from 'node:child_process'; +import { mkdirSync, readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +const SOURCES = [ + { + dir: 'tolgee-platform', + repo: 'https://github.com/tolgee/tolgee-platform.git', + branch: 'jancizmar/tolgee-apps-poc', + // SDK source + example apps live under apps/. + sparse: ['apps'], + }, + { + dir: 'documentation', + repo: 'https://github.com/tolgee/documentation.git', + branch: 'jancizmar/tolgee-apps-poc', + sparse: ['tolgee-apps'], + }, +]; + +const contextDir = join(process.cwd(), '.context'); +mkdirSync(contextDir, { recursive: true }); + +const run = (cmd, cwd) => + execSync(cmd, { cwd, stdio: 'inherit', shell: '/bin/sh' }); + +for (const src of SOURCES) { + const dest = join(contextDir, src.dir); + rmSync(dest, { recursive: true, force: true }); + console.log(`Pulling ${src.dir} (${src.branch})…`); + // Shallow + sparse: only the directories we need, not the whole history/tree. + run( + `git clone --quiet --depth 1 --filter=blob:none --sparse --branch ${src.branch} ${src.repo} "${dest}"` + ); + run(`git sparse-checkout set ${src.sparse.join(' ')}`, dest); + // Drop git history — .context is a read-only snapshot, not a working clone. + rmSync(join(dest, '.git'), { recursive: true, force: true }); + // Cone-mode sparse-checkout also materializes root-level files (heavy README + // GIFs etc.); keep only the directories we asked for. + for (const entry of readdirSync(dest)) { + if (!src.sparse.includes(entry)) { + rmSync(join(dest, entry), { recursive: true, force: true }); + } + } +} + +console.log('\n.context/ is ready (gitignored). See CLAUDE.md for what lives where.'); diff --git a/apps/create-tolgee-app/template/base/server/config.ts b/apps/create-tolgee-app/template/base/server/config.ts new file mode 100644 index 00000000000..825577dcb22 --- /dev/null +++ b/apps/create-tolgee-app/template/base/server/config.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { loadTolgeeAppConfig } from '@tolgee/apps-sdk/server' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const appConfig = loadTolgeeAppConfig() + +export const VITE_PORT = appConfig.vitePort +export const SERVER_PORT = appConfig.serverPort +export const TOLGEE_URL = appConfig.tolgeeUrl +export const LOCAL_BASE_URL = `http://localhost:${VITE_PORT}` + +/** + * State dir written by `tolgee-app register` / `tolgee-app dev`: + * - `install.json` holds the credentials from registration (incl. webhookSecret) + * - `tunnel.json` holds the current public base URL (read by the manifest route) + */ +export const TUNNEL_STATE_DIR = join(__dirname, '..', '.tolgee-dev') +export const TUNNEL_STATE_FILE = join(TUNNEL_STATE_DIR, 'tunnel.json') +const INSTALL_FILE = join(TUNNEL_STATE_DIR, 'install.json') + +const installWebhookSecret = (): string | null => { + try { + const parsed = JSON.parse(readFileSync(INSTALL_FILE, 'utf8')) as { + webhookSecret?: string + } + return parsed.webhookSecret ?? null + } catch { + return null + } +} + +/** + * In dev the secret comes from the install record `tolgee-app register` wrote — no + * manual copying. `TOLGEE_WEBHOOK_SECRET` is a production override for deploys where + * there is no install file. + */ +export const WEBHOOK_SECRET = appConfig.webhookSecret ?? installWebhookSecret() diff --git a/apps/create-tolgee-app/template/base/server/cors.ts b/apps/create-tolgee-app/template/base/server/cors.ts new file mode 100644 index 00000000000..990b3960ec9 --- /dev/null +++ b/apps/create-tolgee-app/template/base/server/cors.ts @@ -0,0 +1,13 @@ +import type { RequestHandler } from 'express' +import { tolgeeAppCorsHeaders } from '@tolgee/apps-sdk/server' + +/** + * Applies the SDK's standard Tolgee-app CORS headers so the webapp (a different + * origin than this server) can call /decorators and any custom endpoints. + */ +export const cors: RequestHandler = (_req, res, next) => { + for (const [name, value] of Object.entries(tolgeeAppCorsHeaders())) { + res.setHeader(name, value) + } + next() +} diff --git a/apps/create-tolgee-app/template/base/server/index.ts b/apps/create-tolgee-app/template/base/server/index.ts new file mode 100644 index 00000000000..bfdd6452e27 --- /dev/null +++ b/apps/create-tolgee-app/template/base/server/index.ts @@ -0,0 +1,30 @@ +import express, { json } from 'express' +import { SERVER_PORT, WEBHOOK_SECRET } from './config' +import { cors } from './cors' +import { registerManifestRoute } from './routes/manifest' +import { registerWebhookRoute } from './routes/webhook' +import { registerDecoratorsRoute } from './routes/decorators' + +const app = express() + +// /webhook is registered before the json() middleware: the SDK's HMAC +// verifier needs the raw POST body, not a parsed object. +registerWebhookRoute(app) + +app.use(cors) +app.options('*', (_req, res) => { + res.status(204).end() +}) +app.use(json()) + +registerManifestRoute(app) +registerDecoratorsRoute(app) + +app.listen(SERVER_PORT, () => { + console.log(`{{id}} server listening on http://localhost:${SERVER_PORT}`) + if (!WEBHOOK_SECRET) { + console.warn( + 'TOLGEE_WEBHOOK_SECRET is not set; webhook signatures will not be verified.' + ) + } +}) diff --git a/apps/create-tolgee-app/template/base/server/manifest.ts b/apps/create-tolgee-app/template/base/server/manifest.ts new file mode 100644 index 00000000000..a3441253370 --- /dev/null +++ b/apps/create-tolgee-app/template/base/server/manifest.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { renderManifest } from '@tolgee/apps-sdk/server' +import { LOCAL_BASE_URL, TUNNEL_STATE_FILE } from './config' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const TEMPLATE_PATH = join(__dirname, 'manifest.template.json') + +/** + * Reads the tunnel state written by the dev orchestrator and returns the public + * base URL the plugin is reachable at, or the localhost default when no tunnel + * is active. + */ +const readBaseUrl = (): string => { + try { + const parsed = JSON.parse(readFileSync(TUNNEL_STATE_FILE, 'utf8')) as { + baseUrl?: string + } + if (typeof parsed.baseUrl === 'string' && parsed.baseUrl.length > 0) { + return parsed.baseUrl + } + } catch { + // file missing or unreadable — fall through to localhost default + } + return LOCAL_BASE_URL +} + +export const getManifest = (): string => + renderManifest(readFileSync(TEMPLATE_PATH, 'utf8'), readBaseUrl()) diff --git a/apps/create-tolgee-app/template/base/server/routes/decorators.ts b/apps/create-tolgee-app/template/base/server/routes/decorators.ts new file mode 100644 index 00000000000..dc078079e66 --- /dev/null +++ b/apps/create-tolgee-app/template/base/server/routes/decorators.ts @@ -0,0 +1,24 @@ +import type { Express, Request, Response } from 'express' +import type { DecoratorsRequest, DecoratorsResponse } from '@tolgee/apps-sdk/server' + +/** + * Dynamic decorators endpoint. The Tolgee webapp POSTs the key/language rows + * currently in view; you return icon decorations to render alongside the native + * row icons (and to drive any `dynamic: true` action in the manifest). + * + * This demo flags every key in view with a lightbulb. Replace the body with + * your own logic — e.g. fetch state from your backend and decorate selectively. + */ +export const registerDecoratorsRoute = (app: Express): void => { + app.post('/decorators', (req: Request, res: Response) => { + const body = (req.body ?? {}) as DecoratorsRequest + const response: DecoratorsResponse = { + items: (body.keyIds ?? []).map((keyId) => ({ + keyId, + icon: 'Lightbulb01', + tooltip: 'Flagged by {{name}}', + })), + } + res.json(response) + }) +} diff --git a/apps/create-tolgee-app/template/base/server/routes/manifest.ts b/apps/create-tolgee-app/template/base/server/routes/manifest.ts new file mode 100644 index 00000000000..b71312f2b11 --- /dev/null +++ b/apps/create-tolgee-app/template/base/server/routes/manifest.ts @@ -0,0 +1,8 @@ +import type { Express, Request, Response } from 'express' +import { getManifest } from '../manifest' + +export const registerManifestRoute = (app: Express): void => { + app.get('/manifest.json', (_req: Request, res: Response) => { + res.type('application/json').send(getManifest()) + }) +} diff --git a/apps/create-tolgee-app/template/base/server/routes/webhook.ts b/apps/create-tolgee-app/template/base/server/routes/webhook.ts new file mode 100644 index 00000000000..771ea63f643 --- /dev/null +++ b/apps/create-tolgee-app/template/base/server/routes/webhook.ts @@ -0,0 +1,31 @@ +import express, { type Express, type Request, type Response } from 'express' +import { onWebhook, receiveWebhook } from '@tolgee/apps-sdk/server' +import { WEBHOOK_SECRET } from '../config' + +export const registerWebhookRoute = (app: Express): void => { + // express.text keeps the body verbatim — the SDK verifier needs the raw + // bytes Tolgee signed, not a parsed object. + app.post( + '/webhook', + express.text({ type: 'application/json', limit: '5mb' }), + handleWebhook + ) +} + +const handleWebhook = async (req: Request, res: Response): Promise => { + const raw = typeof req.body === 'string' ? req.body : '' + const result = await receiveWebhook({ + rawBody: raw, + signatureHeader: req.header('Tolgee-Signature'), + secret: WEBHOOK_SECRET, + }) + if (!result.ok) { + res.status(result.status).json({ error: result.error }) + return + } + const payload = result.payload + + // {{webhookHandlers}} + + res.status(204).end() +} diff --git a/apps/create-tolgee-app/template/base/src/App.css b/apps/create-tolgee-app/template/base/src/App.css new file mode 100644 index 00000000000..e78c0f72810 --- /dev/null +++ b/apps/create-tolgee-app/template/base/src/App.css @@ -0,0 +1,41 @@ +html, +body, +#root { + margin: 0; + padding: 0; + font-family: -apple-system, system-ui, sans-serif; + /* Set by applyTolgeeTheme(ctx.theme) so the app follows Tolgee's light/dark. */ + color: var(--tg-color-text, #222); + background: var(--tg-color-background, #fff); +} + +main { + padding: 16px; +} + +main h1, +main h2 { + margin-top: 0; +} + +.kv { + border-collapse: collapse; + font-size: 13px; +} + +.kv th { + text-align: left; + padding: 4px 12px 4px 0; + font-weight: 500; + color: #666; +} + +.kv td { + padding: 4px 0; +} + +.kv code { + background: #f3f3f3; + padding: 1px 6px; + border-radius: 4px; +} diff --git a/apps/create-tolgee-app/template/base/src/main.tsx b/apps/create-tolgee-app/template/base/src/main.tsx new file mode 100644 index 00000000000..2de57d79eaf --- /dev/null +++ b/apps/create-tolgee-app/template/base/src/main.tsx @@ -0,0 +1,42 @@ +import { StrictMode, type ComponentType } from 'react' +import { createRoot } from 'react-dom/client' +import './App.css' + +// Each iframe module is lazy-imported by URL path. The scaffold only +// generates folders for the modules the wizard selected; unselected +// paths show a tiny fallback. +const ROUTES: Record Promise<{ default: ComponentType }>> = { + // {{routes}} +} + +const NotFound = () => ( +
+

{{name}}

+

+ No module matches {location.pathname}. Add a route in{' '} + src/main.tsx and a manifest entry in{' '} + server/manifest.template.json. +

+
+) + +async function mount() { + const root = createRoot(document.getElementById('root')!) + const loader = ROUTES[location.pathname] + if (!loader) { + root.render( + + + + ) + return + } + const { default: Component } = await loader() + root.render( + + + + ) +} + +mount() diff --git a/apps/create-tolgee-app/template/base/tsconfig.app.json b/apps/create-tolgee-app/template/base/tsconfig.app.json new file mode 100644 index 00000000000..c23f9637f3c --- /dev/null +++ b/apps/create-tolgee-app/template/base/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/apps/create-tolgee-app/template/base/tsconfig.json b/apps/create-tolgee-app/template/base/tsconfig.json new file mode 100644 index 00000000000..1ffef600d95 --- /dev/null +++ b/apps/create-tolgee-app/template/base/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/create-tolgee-app/template/base/tsconfig.node.json b/apps/create-tolgee-app/template/base/tsconfig.node.json new file mode 100644 index 00000000000..207edbfdc24 --- /dev/null +++ b/apps/create-tolgee-app/template/base/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts", "server/**/*.ts"] +} diff --git a/apps/create-tolgee-app/template/base/vite.config.ts b/apps/create-tolgee-app/template/base/vite.config.ts new file mode 100644 index 00000000000..d7a67318497 --- /dev/null +++ b/apps/create-tolgee-app/template/base/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, loadEnv } from 'vite' +import react from '@vitejs/plugin-react' + +// Ports are env-overridable so multiple plugins can run side-by-side. +// Defaults match the historical 5180/5181 pair. +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const vitePort = Number(env.VITE_PORT ?? 5180) + const serverPort = Number(env.SERVER_PORT ?? 5181) + const serverTarget = `http://localhost:${serverPort}` + return { + plugins: [react()], + server: { + port: vitePort, + strictPort: true, + // Cloudflare quick tunnels expose a single port. Vite is the + // tunnel target; these proxies forward Tolgee → Express endpoints + // through the one public hostname. + proxy: { + '/manifest.json': serverTarget, + '/webhook': serverTarget, + '/decorators': serverTarget, + }, + // Quick tunnels rewrite the Host header to the trycloudflare.com + // hostname; Vite's default host check would reject those requests. + allowedHosts: true, + }, + } +}) diff --git a/apps/create-tolgee-app/template/modules/key-edit-tab/src/modules/keyEditTab/index.tsx b/apps/create-tolgee-app/template/modules/key-edit-tab/src/modules/keyEditTab/index.tsx new file mode 100644 index 00000000000..c771cdb8375 --- /dev/null +++ b/apps/create-tolgee-app/template/modules/key-edit-tab/src/modules/keyEditTab/index.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react' +import { + applyTolgeeTheme, + createTolgeeApp, + type TolgeeAppSelection, +} from '@tolgee/apps-sdk/browser' + +export default function KeyEditTab() { + const [selection, setSelection] = useState({}) + + useEffect(() => { + const app = createTolgeeApp() + app.context.then((ctx) => setSelection(ctx.selection)) + const off = app.onSelectionChanged(setSelection) + const offTheme = app.onThemeChanged(applyTolgeeTheme) + return () => { + off() + offTheme() + app.dispose() + } + }, []) + + return ( +
+

{{name}} key-edit tab

+ {selection.keyId == null ? ( +

Open a key to see its plugin tab content.

+ ) : ( +

+ Inspecting key {selection.keyId}. +

+ )} +
+ ) +} diff --git a/apps/create-tolgee-app/template/modules/modal/src/modules/modal/index.tsx b/apps/create-tolgee-app/template/modules/modal/src/modules/modal/index.tsx new file mode 100644 index 00000000000..d7e180ea1fa --- /dev/null +++ b/apps/create-tolgee-app/template/modules/modal/src/modules/modal/index.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState } from 'react' +import { + applyTolgeeTheme, + createTolgeeApp, + type TolgeeApp, + type TolgeeAppContext, +} from '@tolgee/apps-sdk/browser' + +export default function Modal() { + const [ctx, setCtx] = useState(null) + const appRef = useRef(null) + + useEffect(() => { + const app = createTolgeeApp() + appRef.current = app + app.context.then(setCtx) + const offTheme = app.onThemeChanged(applyTolgeeTheme) + return () => { + offTheme() + app.dispose() + appRef.current = null + } + }, []) + + const close = (): void => appRef.current?.close() + + return ( +
+

{{name}} modal

+ {!ctx ? ( +

Loading context…

+ ) : ( + + + + + + + + + + + + + + + +
projectId{ctx.projectId}
organizationId{ctx.organizationId}
keyId{ctx.selection.keyId ?? '—'}
+ )} +

+ +

+
+ ) +} diff --git a/apps/create-tolgee-app/template/modules/project-dashboard-page/src/modules/dashboard/index.tsx b/apps/create-tolgee-app/template/modules/project-dashboard-page/src/modules/dashboard/index.tsx new file mode 100644 index 00000000000..cd17f3c6467 --- /dev/null +++ b/apps/create-tolgee-app/template/modules/project-dashboard-page/src/modules/dashboard/index.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react' +import { + applyTolgeeTheme, + createTolgeeApp, + createTolgeeAppClient, + type TolgeeAppContext, +} from '@tolgee/apps-sdk/browser' + +export default function Dashboard() { + const [ctx, setCtx] = useState(null) + const [projectName, setProjectName] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + const app = createTolgeeApp() + app.context.then(setCtx) + const offTheme = app.onThemeChanged(applyTolgeeTheme) + return () => { + offTheme() + app.dispose() + } + }, []) + + useEffect(() => { + if (!ctx) return + const tolgee = createTolgeeAppClient(ctx) + tolgee + .GET('/v2/projects/{projectId}', { + params: { path: { projectId: ctx.projectId } }, + }) + .then(({ data, error }) => { + if (error) { + setError(JSON.stringify(error)) + } else if (data) { + setProjectName(data.name) + } + }) + }, [ctx]) + + if (!ctx) return
Loading…
+ return ( +
+

{{name}}

+

+ Hello from project {projectName ?? ctx.projectId}. +

+ {error &&
{error}
} +
+ ) +} diff --git a/apps/create-tolgee-app/template/modules/translation-tools-panel-empty/src/modules/toolsPanelEmpty/index.tsx b/apps/create-tolgee-app/template/modules/translation-tools-panel-empty/src/modules/toolsPanelEmpty/index.tsx new file mode 100644 index 00000000000..b9cfd20a40a --- /dev/null +++ b/apps/create-tolgee-app/template/modules/translation-tools-panel-empty/src/modules/toolsPanelEmpty/index.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState } from 'react' +import { + applyTolgeeTheme, + createTolgeeApp, + type TolgeeApp, + type TolgeeAppSelection, +} from '@tolgee/apps-sdk/browser' + +export default function ToolsPanelEmpty() { + const [selection, setSelection] = useState({}) + const containerRef = useRef(null) + const appRef = useRef(null) + + useEffect(() => { + const app = createTolgeeApp() + appRef.current = app + app.context.then((ctx) => setSelection(ctx.selection)) + // Fires when the user changes the language selector, too. + const off = app.onSelectionChanged(setSelection) + const offTheme = app.onThemeChanged(applyTolgeeTheme) + return () => { + off() + offTheme() + app.dispose() + appRef.current = null + } + }, []) + + useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver(() => { + appRef.current?.resize(el.scrollHeight) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + const languages = selection.selectedLanguages ?? [] + + return ( +
+

{{name}} — selected languages

+ {languages.length === 0 ? ( +

No languages selected.

+ ) : ( +
    + {languages.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ )} +
+ ) +} diff --git a/apps/create-tolgee-app/template/modules/translation-tools-panel/src/modules/toolsPanel/index.tsx b/apps/create-tolgee-app/template/modules/translation-tools-panel/src/modules/toolsPanel/index.tsx new file mode 100644 index 00000000000..986b74db928 --- /dev/null +++ b/apps/create-tolgee-app/template/modules/translation-tools-panel/src/modules/toolsPanel/index.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from 'react' +import { + applyTolgeeTheme, + createTolgeeApp, + type TolgeeApp, + type TolgeeAppSelection, +} from '@tolgee/apps-sdk/browser' + +export default function ToolsPanel() { + const [selection, setSelection] = useState({}) + const containerRef = useRef(null) + const appRef = useRef(null) + + useEffect(() => { + const app = createTolgeeApp() + appRef.current = app + app.context.then((ctx) => setSelection(ctx.selection)) + const off = app.onSelectionChanged(setSelection) + const offTheme = app.onThemeChanged(applyTolgeeTheme) + return () => { + off() + offTheme() + app.dispose() + appRef.current = null + } + }, []) + + // Tell the host how tall this panel wants to be whenever the content + // resizes. The host sizes the iframe element to match. + useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver(() => { + appRef.current?.resize(el.scrollHeight) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + return ( +
+

{{name}} panel

+ + + + + + + + + + + + + + + +
keyId{selection.keyId ?? '—'}
languageTag{selection.languageTag ?? '—'}
translationId{selection.translationId ?? '—'}
+
+ ) +} diff --git a/apps/create-tolgee-app/tsconfig.json b/apps/create-tolgee-app/tsconfig.json new file mode 100644 index 00000000000..c666648e0c0 --- /dev/null +++ b/apps/create-tolgee-app/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node"], + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "moduleDetection": "force" + }, + "include": ["src/**/*", "tsup.config.ts"], + "exclude": ["template"] +} diff --git a/apps/create-tolgee-app/tsup.config.ts b/apps/create-tolgee-app/tsup.config.ts new file mode 100644 index 00000000000..706e0df859e --- /dev/null +++ b/apps/create-tolgee-app/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + outDir: 'dist', + format: ['esm'], + target: 'node20', + clean: true, + banner: { js: '#!/usr/bin/env node' }, +}) diff --git a/apps/example-apps/back-translate/.env.example b/apps/example-apps/back-translate/.env.example new file mode 100644 index 00000000000..547edb24542 --- /dev/null +++ b/apps/example-apps/back-translate/.env.example @@ -0,0 +1,29 @@ +# Tolgee instance the plugin targets. Used by scripts/register.ts and +# by the dev tunnel orchestrator. When this points at localhost, the +# dev orchestrator skips Cloudflare (Tolgee can reach the plugin +# directly). +TOLGEE_URL=http://localhost:8824 + +# Force-disable the tunnel even when Tolgee is remote. Useful with a +# custom proxy / Named Tunnel you boot yourself. +# TOLGEE_DEV_TUNNEL=none + +# Webhook signing secret — Tolgee gives you this when the plugin is +# registered, and it's also written to .tolgee-dev/install.json. +# Setting it here enables the webhook signature verifier; leaving it +# unset disables verification and prints a warning on each request. +# TOLGEE_WEBHOOK_SECRET=tgappw_xxx + +# Anthropic API key used by /translate-back to back-translate each +# focused translation and produce a semantic-match verdict. Required; +# without it the server returns 503 for that endpoint. +# ANTHROPIC_API_KEY=sk-ant-... + +# Override the model. Defaults to claude-haiku-4-5-20251001 (fast). +# Try claude-sonnet-4-6 for higher-quality verdicts at slightly higher +# latency + cost. +# ANTHROPIC_MODEL=claude-sonnet-4-6 + +# NOTE: the webhook handler authenticates its REST calls back into +# Tolgee using the install's own clientSecret (X-API-Key: tgapps_…), +# read from .tolgee-dev/install.json — no separate API key is needed. diff --git a/apps/example-apps/back-translate/.gitignore b/apps/example-apps/back-translate/.gitignore new file mode 100644 index 00000000000..38adb8f0098 --- /dev/null +++ b/apps/example-apps/back-translate/.gitignore @@ -0,0 +1,21 @@ +node_modules +dist +dist-ssr +*.local +*.tsbuildinfo + +# Tunnel + install state written by `npm run dev` / `npm run register`. +.tolgee-dev/ + +# Local data store (webhook-driven cache). +server/.data/ + +# Local env overrides. +.env.local + +.DS_Store +.vscode/* +!.vscode/extensions.json +.idea +*.iml +*.log diff --git a/apps/example-apps/back-translate/README.md b/apps/example-apps/back-translate/README.md new file mode 100644 index 00000000000..cbbad6bb2fc --- /dev/null +++ b/apps/example-apps/back-translate/README.md @@ -0,0 +1,44 @@ +# Back Translate + +A Tolgee Apps plugin scaffolded by `create-tolgee-app`. + +## Quick start + +```bash +npm install # if you skipped it during scaffolding +npm run register # one-time: pick your org, register the plugin +npm run dev # boots Vite (5180) + Express (5181) + Cloudflare tunnel +``` + +The first `npm run dev` after registration PATCHes the install in Tolgee +to point at the fresh tunnel URL. Restart `dev` as many times as you like — +each restart re-PATCHes; no manual reinstall. + +## Layout + +``` +manifest.template.json ← edit this; baseUrl is __BASE_URL__ at request time +src/ ← iframe modules (one folder per `entry`) +server/ ← webhook + decorator endpoints +scripts/ ← dev orchestrator + one-time register +``` + +## What the SDK gives you + +- **`createTolgeeApp()`** in iframes — postMessage handshake, JWT + context, selection updates. +- **`createTolgeeAppClient(ctx)`** — typed REST client to Tolgee with + the install token pre-attached. +- **`verifyWebhookSignature({ header, rawBody, secret })`** — pure + WebCrypto-based HMAC check matching Tolgee's `Tolgee-Signature` header. +- **`onWebhook(payload, 'EVENT_NAME', handler)`** — typed dispatcher + that narrows `payload.activityData` by event type. +- **`decodeContextToken(jwt)`** — extract install/project/user ids + from the JWT on the server. + +## Editing the manifest + +`server/manifest.template.json` is the source of truth. The server reads +it on every `/manifest.json` request and substitutes `__BASE_URL__` with +the live tunnel URL. After saving edits, run `npx tsx scripts/refresh.ts` +(or restart `npm run dev`) to have Tolgee re-fetch. diff --git a/apps/example-apps/back-translate/index.html b/apps/example-apps/back-translate/index.html new file mode 100644 index 00000000000..9dc90c39600 --- /dev/null +++ b/apps/example-apps/back-translate/index.html @@ -0,0 +1,12 @@ + + + + + + Back Translate + + +
+ + + diff --git a/apps/example-apps/back-translate/package.json b/apps/example-apps/back-translate/package.json new file mode 100644 index 00000000000..2646bfa3b1b --- /dev/null +++ b/apps/example-apps/back-translate/package.json @@ -0,0 +1,36 @@ +{ + "name": "back-translate", + "private": true, + "version": "0.0.1-alpha.7", + "type": "module", + "scripts": { + "dev": "concurrently -k -n vite,server,tunnel -c blue,green,yellow \"npm:dev:frontend\" \"npm:dev:server\" \"npm:dev:tunnel\"", + "dev:frontend": "vite", + "dev:server": "tsx watch --env-file-if-exists=.env.local server/index.ts", + "dev:tunnel": "tsx --env-file-if-exists=.env.local scripts/dev.ts", + "register": "tsx --env-file-if-exists=.env.local scripts/register.ts", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.100.1", + "@tginternal/client": "0.0.0-prerelease.c8329b552", + "@tolgee/apps-sdk": "^0.0.1-alpha.7", + "cloudflared": "^0.7.1", + "express": "^4.21.0", + "open": "^10.1.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "concurrently": "^9.1.0", + "tsx": "^4.19.2", + "typescript": "~6.0.2", + "vite": "^8.0.12" + } +} diff --git a/apps/example-apps/back-translate/scripts/dev.ts b/apps/example-apps/back-translate/scripts/dev.ts new file mode 100644 index 00000000000..18c041edcd8 --- /dev/null +++ b/apps/example-apps/back-translate/scripts/dev.ts @@ -0,0 +1,113 @@ +/** + * Boots a Cloudflare Quick Tunnel pointing at the local Vite dev server, + * writes its public URL to `.tolgee-dev/tunnel.json` so the manifest + * route picks it up, and (if an install was registered) PATCHes the + * Tolgee install's manifest URL so the new tunnel URL takes effect + * immediately. + * + * Skips the tunnel entirely when the registered Tolgee instance lives + * on localhost — Tolgee can reach the dev plugin directly, no public + * URL needed. Force-disable via `TOLGEE_DEV_TUNNEL=none`. + */ +import { existsSync } from 'node:fs' +import { bin, install, Tunnel } from 'cloudflared' +import { + clearTunnelState, + patchManifestUrl, + readInstallState, + writeTunnelState, + type InstallState, +} from './lib' + +const VITE_PORT = Number(process.env.VITE_PORT ?? 5180) +const LOCAL_BASE_URL = `http://localhost:${VITE_PORT}` + +const isLocalHost = (urlString: string): boolean => { + try { + const host = new URL(urlString).hostname + return host === 'localhost' || host === '127.0.0.1' || host === '::1' + } catch { + return false + } +} + +const shouldSkipTunnel = (installState: InstallState | null): boolean => { + if (process.env.TOLGEE_DEV_TUNNEL === 'none') return true + if (installState && isLocalHost(installState.tolgeeUrl)) return true + if (process.env.TOLGEE_URL && isLocalHost(process.env.TOLGEE_URL)) return true + return false +} + +const ensureBinary = async (): Promise => { + if (!existsSync(bin)) { + console.log('[tunnel] installing cloudflared binary…') + await install(bin) + } +} + +const startTunnel = async (): Promise => { + const tunnel = Tunnel.quick(`http://localhost:${VITE_PORT}`) + const url = await new Promise((resolve) => { + tunnel.once('url', resolve) + }) + tunnel.on('exit', (code) => { + console.log(`[tunnel] cloudflared exited with code ${code}`) + }) + process.once('SIGINT', () => tunnel.stop()) + process.once('SIGTERM', () => tunnel.stop()) + return url +} + +const main = async (): Promise => { + clearTunnelState() + const installState = readInstallState() + + if (shouldSkipTunnel(installState)) { + console.log( + `[tunnel] skipping cloudflared — Tolgee reaches the plugin at ${LOCAL_BASE_URL}` + ) + writeTunnelState({ baseUrl: LOCAL_BASE_URL }) + await maybePatchManifestUrl(installState, LOCAL_BASE_URL) + // Park so concurrently -k doesn't tear the other processes down. + // A long-lived interval keeps the event loop alive until SIGINT/SIGTERM. + const heartbeat = setInterval(() => {}, 1 << 30) + await new Promise((resolve) => { + process.once('SIGINT', () => resolve()) + process.once('SIGTERM', () => resolve()) + }) + clearInterval(heartbeat) + return + } + + await ensureBinary() + const baseUrl = await startTunnel() + console.log(`[tunnel] live at ${baseUrl}`) + writeTunnelState({ baseUrl }) + await maybePatchManifestUrl(installState, baseUrl) +} + +const maybePatchManifestUrl = async ( + installState: InstallState | null, + baseUrl: string +): Promise => { + if (!installState) { + console.log( + '[tunnel] no install registered — run `npm run register` to ' + + 'install the dev plugin against a Tolgee organization.' + ) + return + } + try { + await patchManifestUrl(installState, `${baseUrl}/manifest.json`) + console.log( + `[tunnel] PATCHed install ${installState.installId} to use ${baseUrl}/manifest.json` + ) + } catch (err) { + console.error('[tunnel] manifest-url update failed:', err) + } +} + +main().catch((err) => { + console.error('[tunnel] fatal:', err) + process.exit(1) +}) diff --git a/apps/example-apps/back-translate/scripts/lib.ts b/apps/example-apps/back-translate/scripts/lib.ts new file mode 100644 index 00000000000..8c61c6d02e0 --- /dev/null +++ b/apps/example-apps/back-translate/scripts/lib.ts @@ -0,0 +1,75 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export const ROOT = join(__dirname, '..') +export const STATE_DIR = join(ROOT, '.tolgee-dev') +export const INSTALL_FILE = join(STATE_DIR, 'install.json') +export const TUNNEL_FILE = join(STATE_DIR, 'tunnel.json') + +export type InstallState = { + tolgeeUrl: string + organizationId: number + installId: number + /** + * Plugin credentials Tolgee returned when this install was registered. + * `clientSecret` (`X-API-Key: tgapps_…`) authenticates every outbound + * call the plugin makes back to Tolgee — webhook handler base-text + * lookups AND the self-service manifest-url PATCH the dev orchestrator + * fires on every restart. `webhookSecret` signs the inbound webhook + * bodies. + */ + clientId: string + clientSecret: string + webhookSecret: string +} + +export type TunnelState = { + baseUrl: string +} + +export const writeJson = (path: string, value: unknown): void => { + mkdirSync(STATE_DIR, { recursive: true }) + writeFileSync(path, JSON.stringify(value, null, 2) + '\n', 'utf8') +} + +export const readInstallState = (): InstallState | null => { + try { + return JSON.parse(readFileSync(INSTALL_FILE, 'utf8')) as InstallState + } catch { + return null + } +} + +export const writeTunnelState = (state: TunnelState): void => { + writeJson(TUNNEL_FILE, state) +} + +export const clearTunnelState = (): void => { + writeJson(TUNNEL_FILE, { baseUrl: 'http://localhost:5180' }) +} + +export const patchManifestUrl = async ( + install: InstallState, + manifestUrl: string +): Promise => { + // Self-service endpoint — the install authenticates as itself with its + // clientSecret. The earlier org-admin endpoint required a PAT we no + // longer collect; this one only lets an install update its own URL. + const url = `${install.tolgeeUrl}/v2/apps/self/manifest-url` + const res = await fetch(url, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': install.clientSecret, + }, + body: JSON.stringify({ manifestUrl }), + }) + if (!res.ok) { + throw new Error( + `PATCH manifest-url failed (${res.status}): ${await res.text()}` + ) + } +} diff --git a/apps/example-apps/back-translate/scripts/register.ts b/apps/example-apps/back-translate/scripts/register.ts new file mode 100644 index 00000000000..b49b836f8af --- /dev/null +++ b/apps/example-apps/back-translate/scripts/register.ts @@ -0,0 +1,303 @@ +/** + * One-time install bootstrap. + * + * Default flow: open Tolgee in the user's browser, let them pick an org and + * approve the install, then receive the install credentials back on a one-shot + * localhost callback server. + * + * Headless fallback: pass `--pat=tgpat_…` to skip the browser and register + * directly with a personal-access token (useful for CI). + * + * When Tolgee is local the manifest is served straight from Express — no + * Cloudflare tunnel needed. For remote Tolgee a quick tunnel is booted so + * Tolgee can fetch the manifest from outside the user's machine. Either way, + * the local dev server must be running (`npm run dev` in another terminal). + */ +import { createInterface } from 'node:readline/promises' +import { existsSync } from 'node:fs' +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { randomUUID, timingSafeEqual } from 'node:crypto' +import open from 'open' +import { bin, install, Tunnel } from 'cloudflared' +import { + type InstallState, + INSTALL_FILE, + writeJson, + writeTunnelState, +} from './lib' + +type Org = { id: number; name: string; slug: string } + +const VITE_PORT = Number(process.env.VITE_PORT ?? 5180) +const SERVER_PORT = Number(process.env.SERVER_PORT ?? 5181) +const LOCAL_MANIFEST_URL = `http://localhost:${SERVER_PORT}/manifest.json` +const BROWSER_TIMEOUT_MS = 5 * 60_000 + +const args = process.argv.slice(2) +const patFlag = + args.find((a) => a.startsWith('--pat='))?.slice('--pat='.length) ?? null + +const isLocalHost = (urlString: string): boolean => { + try { + const host = new URL(urlString).hostname + return host === 'localhost' || host === '127.0.0.1' || host === '::1' + } catch { + return false + } +} + +const verifyManifestReachable = async (manifestUrl: string): Promise => { + let res: Response + try { + res = await fetch(manifestUrl) + } catch (err) { + throw new Error( + `Could not reach ${manifestUrl}. Is \`npm run dev\` running in another terminal? (${ + err instanceof Error ? err.message : String(err) + })` + ) + } + if (!res.ok) { + throw new Error(`${manifestUrl} returned ${res.status}. Is dev running?`) + } +} + +const startTunnel = async (): Promise => { + if (!existsSync(bin)) { + console.log('Installing cloudflared binary…') + await install(bin) + } + console.log('Starting Cloudflare quick tunnel…') + const tunnel = Tunnel.quick(`http://localhost:${VITE_PORT}`) + const baseUrl = await new Promise((resolve) => + tunnel.once('url', resolve) + ) + console.log(`Tunnel live at ${baseUrl}`) + return baseUrl +} + +/** + * Picks the manifest URL Tolgee will fetch + writes the tunnel state so + * the local server serves the right baseUrl. + * local Tolgee → http://localhost:5181/manifest.json (Express direct) + * remote Tolgee → Cloudflare quick tunnel pointing at Vite + */ +const resolveManifestUrl = async ( + tolgeeUrl: string +): Promise => { + if (isLocalHost(tolgeeUrl)) { + writeTunnelState({ baseUrl: `http://localhost:${VITE_PORT}` }) + console.log(`Skipping tunnel — Tolgee is local; using ${LOCAL_MANIFEST_URL}`) + return LOCAL_MANIFEST_URL + } + const tunnelBaseUrl = await startTunnel() + writeTunnelState({ baseUrl: tunnelBaseUrl }) + return `${tunnelBaseUrl}/manifest.json` +} + +const constantTimeEq = (a: string, b: string): boolean => { + const ab = Buffer.from(a) + const bb = Buffer.from(b) + if (ab.length !== bb.length) return false + return timingSafeEqual(ab, bb) +} + +/** + * Browser flow: spins up a one-shot HTTP server on 127.0.0.1, opens the + * Tolgee install page, waits for the redirect callback. + */ +const browserRegister = async ( + tolgeeUrl: string, + manifestUrl: string +): Promise => { + const state = randomUUID() + return await new Promise((resolve, reject) => { + let settled = false + const finish = ( + kind: 'resolve' | 'reject', + value: InstallState | Error + ) => { + if (settled) return + settled = true + clearTimeout(timeout) + server.close() + if (kind === 'resolve') resolve(value as InstallState) + else reject(value as Error) + } + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '', `http://127.0.0.1`) + if (url.pathname !== '/cb') { + res.statusCode = 404 + res.end('Not found') + return + } + const incomingState = url.searchParams.get('state') ?? '' + if (!constantTimeEq(incomingState, state)) { + res.statusCode = 400 + res.end(htmlPage('State mismatch — refusing this callback.', false)) + finish('reject', new Error('Callback state did not match.')) + return + } + const error = url.searchParams.get('error') + if (error) { + res.end(htmlPage(`Install ${error}. You can close this tab.`, false)) + finish('reject', new Error(`Install ${error}`)) + return + } + const installId = Number(url.searchParams.get('installId')) + const organizationId = Number(url.searchParams.get('organizationId')) + const clientId = url.searchParams.get('clientId') ?? '' + const clientSecret = url.searchParams.get('clientSecret') ?? '' + const webhookSecret = url.searchParams.get('webhookSecret') ?? '' + if (!installId || !organizationId || !clientId || !clientSecret) { + res.statusCode = 400 + res.end(htmlPage('Callback missing required fields.', false)) + finish('reject', new Error('Callback missing required fields.')) + return + } + res.end(htmlPage('Installed. You can close this tab.', true)) + finish('resolve', { + tolgeeUrl, + organizationId, + installId, + clientId, + clientSecret, + webhookSecret, + }) + }) + + const timeout = setTimeout( + () => finish('reject', new Error('Browser flow timed out.')), + BROWSER_TIMEOUT_MS + ) + + server.on('error', (err) => finish('reject', err)) + server.listen(0, '127.0.0.1', async () => { + const address = server.address() + if (!address || typeof address === 'string') { + finish('reject', new Error('Failed to bind localhost port')) + return + } + const callback = `http://127.0.0.1:${address.port}/cb` + const installUrl = + `${tolgeeUrl.replace(/\/$/, '')}/install-app` + + `?manifestUrl=${encodeURIComponent(manifestUrl)}` + + `&callback=${encodeURIComponent(callback)}` + + `&state=${encodeURIComponent(state)}` + console.log(`\nOpening browser to install the plugin…`) + console.log(` ${installUrl}\n`) + console.log( + `If the browser doesn't open automatically, paste that URL into it.\n` + + `Waiting up to ${BROWSER_TIMEOUT_MS / 1000}s for you to approve…` + ) + try { + await open(installUrl) + } catch { + // ignore — user can paste the URL manually + } + }) + }) +} + +const htmlPage = (message: string, ok: boolean): string => { + const color = ok ? '#1b8c43' : '#c44343' + return ` +Tolgee plugin install + +
${ok ? '✓ Done' : '✗ Stopped'}
+

${message}

+` +} + +/** Headless fallback: prompts for org by listing them via PAT. */ +const patRegister = async ( + tolgeeUrl: string, + manifestUrl: string, + pat: string +): Promise => { + if (!pat.startsWith('tgpat_')) { + throw new Error('--pat value must start with tgpat_') + } + const orgs = await (async () => { + const res = await fetch(`${tolgeeUrl}/v2/organizations?size=100`, { + headers: { 'X-API-Key': pat }, + }) + if (!res.ok) { + throw new Error( + `Could not list organizations (${res.status}): ${await res.text()}` + ) + } + const json = (await res.json()) as { _embedded?: { organizations?: Org[] } } + return json._embedded?.organizations ?? [] + })() + if (orgs.length === 0) { + throw new Error('No organizations visible to this PAT.') + } + console.log('\nOrganizations:') + orgs.forEach((o, i) => console.log(` ${i + 1}. ${o.name} (${o.slug})`)) + const rl = createInterface({ input: process.stdin, output: process.stdout }) + const idxStr = (await rl.question(`Pick one [1-${orgs.length}]: `)).trim() + rl.close() + const idx = Number.parseInt(idxStr, 10) - 1 + const org = orgs[idx] + if (!org) throw new Error('Invalid selection') + + const res = await fetch(`${tolgeeUrl}/v2/organizations/${org.id}/apps`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': pat }, + body: JSON.stringify({ manifestUrl }), + }) + if (!res.ok) { + throw new Error(`Register failed (${res.status}): ${await res.text()}`) + } + const data = (await res.json()) as { + id: number + clientId: string + clientSecret: string + webhookSecret: string + } + return { + tolgeeUrl, + organizationId: org.id, + installId: data.id, + clientId: data.clientId, + clientSecret: data.clientSecret, + webhookSecret: data.webhookSecret, + } +} + +const main = async (): Promise => { + if (existsSync(INSTALL_FILE)) { + console.log( + `Install record already exists at ${INSTALL_FILE}. Delete it to re-register, ` + + 'or just run `npm run dev` to keep using the existing install.' + ) + return + } + + const tolgeeUrl = (process.env.TOLGEE_URL ?? 'https://app.tolgee.io').replace( + /\/$/, + '' + ) + const manifestUrl = await resolveManifestUrl(tolgeeUrl) + await verifyManifestReachable(manifestUrl) + + const state = patFlag + ? await patRegister(tolgeeUrl, manifestUrl, patFlag) + : await browserRegister(tolgeeUrl, manifestUrl) + + writeJson(INSTALL_FILE, state) + console.log( + `\nRegistered. Install ${state.installId} for org ${state.organizationId}. ` + + `Saved to ${INSTALL_FILE}.` + ) +} + +main().catch((err) => { + console.error('register failed:', err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/apps/example-apps/back-translate/server/backTranslation.ts b/apps/example-apps/back-translate/server/backTranslation.ts new file mode 100644 index 00000000000..5a7b56dbbf0 --- /dev/null +++ b/apps/example-apps/back-translate/server/backTranslation.ts @@ -0,0 +1,58 @@ +import Anthropic from '@anthropic-ai/sdk' +import { ANTHROPIC_API_KEY, ANTHROPIC_MODEL } from './config' +import type { Analysis } from './store' + +export type AnalyseInput = { + text: string + sourceLang: string + baseText: string + baseLang: string +} + +const client = ANTHROPIC_API_KEY + ? new Anthropic({ apiKey: ANTHROPIC_API_KEY }) + : null + +const prompt = (input: AnalyseInput): string => + `You evaluate a translation by back-translating it and comparing semantically. + +Original (${input.baseLang}): "${input.baseText}" +Translation (${input.sourceLang}): "${input.text}" + +Steps: +1. Translate the ${input.sourceLang} text back to ${input.baseLang}. +2. Compare your back-translation to the original ${input.baseLang} text. + - "match" — identical meaning, no nuance lost + - "close" — same intent, minor word-choice differences + - "mismatch" — different meaning, lost in translation + +Respond ONLY with valid JSON, no preamble, no markdown: +{ + "backTranslation": "...", + "verdict": "match" | "close" | "mismatch", + "explanation": "one short sentence" +}` + +const extractJson = (raw: string): Analysis => { + const cleaned = raw.replace(/```(?:json)?/g, '').trim() + const start = cleaned.indexOf('{') + const end = cleaned.lastIndexOf('}') + if (start < 0 || end < 0) throw new Error('No JSON in model response') + return JSON.parse(cleaned.slice(start, end + 1)) as Analysis +} + +export const isAnthropicConfigured = (): boolean => client !== null + +export async function analyse(input: AnalyseInput): Promise { + if (!client) { + throw new Error('ANTHROPIC_API_KEY is not set in the server environment.') + } + const response = await client.messages.create({ + model: ANTHROPIC_MODEL, + max_tokens: 400, + messages: [{ role: 'user', content: prompt(input) }], + }) + const block = response.content[0] + const rawText = block && block.type === 'text' ? block.text : '' + return extractJson(rawText) +} diff --git a/apps/example-apps/back-translate/server/config.ts b/apps/example-apps/back-translate/server/config.ts new file mode 100644 index 00000000000..eea9ec03855 --- /dev/null +++ b/apps/example-apps/back-translate/server/config.ts @@ -0,0 +1,45 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +/** Vite dev server port. Tunnel + iframe URLs use this. */ +export const VITE_PORT = Number(process.env.VITE_PORT ?? 5180) + +/** Express server port. Manifest/webhook/decorator routes live here. */ +export const SERVER_PORT = Number( + process.env.SERVER_PORT ?? process.env.PORT ?? 5181 +) + +export const LOCAL_BASE_URL = `http://localhost:${VITE_PORT}` + +export const WEBHOOK_SECRET = process.env.TOLGEE_WEBHOOK_SECRET ?? null +export const TOLGEE_URL = + process.env.TOLGEE_URL ?? 'https://app.tolgee.io' + +/** Anthropic API key for back-translation. Set in .env.local. */ +export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? null + +/** Override which Claude model performs the back-translation + verdict. */ +export const ANTHROPIC_MODEL = + process.env.ANTHROPIC_MODEL ?? 'claude-haiku-4-5-20251001' + +/** + * Tunnel state file written by scripts/dev.ts when the Cloudflare quick + * tunnel comes up. The manifest route reads `baseUrl` from this file on + * every request so manifest URLs always reflect the currently-active + * tunnel without restarting the server. + */ +export const TUNNEL_STATE_DIR = join(__dirname, '..', '.tolgee-dev') +export const TUNNEL_STATE_FILE = join(TUNNEL_STATE_DIR, 'tunnel.json') + +/** + * Local install record. Written by `npm run register`; the webhook + * handler reads `clientSecret` from here to authenticate REST calls + * back into Tolgee (via `X-API-Key: tgapps_…`). + */ +export const INSTALL_FILE = join(TUNNEL_STATE_DIR, 'install.json') + +/** Persistent JSON store written by the webhook + read by decoratorsUrl. */ +export const STORE_DIR = join(__dirname, '.data') +export const STORE_FILE = join(STORE_DIR, 'analyses.json') diff --git a/apps/example-apps/back-translate/server/cors.ts b/apps/example-apps/back-translate/server/cors.ts new file mode 100644 index 00000000000..cb09c13837c --- /dev/null +++ b/apps/example-apps/back-translate/server/cors.ts @@ -0,0 +1,16 @@ +import type { RequestHandler } from 'express' + +/** + * Permissive CORS for /decorators and /translate-back. The webapp's + * dev origin (e.g. http://localhost:3824) differs from the Tolgee + * backend origin (http://localhost:8824), so a single allowed origin + * is fragile. The decorator JWT travels in the Authorization header + * (not a credentialed cookie), so wildcard is safe. + */ +export const cors: RequestHandler = (_req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type') + res.setHeader('Access-Control-Max-Age', '600') + next() +} diff --git a/apps/example-apps/back-translate/server/index.ts b/apps/example-apps/back-translate/server/index.ts new file mode 100644 index 00000000000..d08af8b764d --- /dev/null +++ b/apps/example-apps/back-translate/server/index.ts @@ -0,0 +1,37 @@ +import express, { json } from 'express' +import { ANTHROPIC_API_KEY, SERVER_PORT, WEBHOOK_SECRET } from './config' +import { cors } from './cors' +import { registerManifestRoute } from './routes/manifest' +import { registerWebhookRoute } from './routes/webhook' +import { registerDecoratorsRoute } from './routes/decorators' +import { registerTranslateBackRoute } from './routes/translateBack' + +const app = express() + +// /webhook is registered before the json() middleware: the SDK's HMAC +// verifier needs the raw POST body, not a parsed object. +registerWebhookRoute(app) + +app.use(cors) +app.options('*', (_req, res) => { + res.status(204).end() +}) +app.use(json()) + +registerManifestRoute(app) +registerDecoratorsRoute(app) +registerTranslateBackRoute(app) + +app.listen(SERVER_PORT, () => { + console.log(`back-translate server listening on http://localhost:${SERVER_PORT}`) + if (!WEBHOOK_SECRET) { + console.warn( + 'TOLGEE_WEBHOOK_SECRET is not set; webhook signatures will not be verified.' + ) + } + if (!ANTHROPIC_API_KEY) { + console.warn( + 'ANTHROPIC_API_KEY is not set; /translate-back will return 503.' + ) + } +}) diff --git a/apps/example-apps/back-translate/server/manifest.template.json b/apps/example-apps/back-translate/server/manifest.template.json new file mode 100644 index 00000000000..a6441fcb9ab --- /dev/null +++ b/apps/example-apps/back-translate/server/manifest.template.json @@ -0,0 +1,45 @@ +{ + "id": "back-translate", + "name": "Back Translate", + "version": "0.1.0", + "baseUrl": "__BASE_URL__", + "decoratorsUrl": "__BASE_URL__/decorators", + "scopes": [ + "translations.view", + "keys.view" + ], + "webhooks": { + "events": ["SET_TRANSLATIONS"], + "url": "__BASE_URL__/webhook" + }, + "modules": { + "translation-tools-panel": [ + { + "key": "panel", + "title": "Back translate", + "icon": "SwitchHorizontal01", + "entry": "/tools-panel" + } + ], + "translation-action": [ + { + "key": "warning", + "type": "panel", + "icon": "AlertCircle", + "tooltip": "Back-translation drifts from base — review", + "dynamic": true, + "panelKey": "panel", + "visibility": "always" + }, + { + "key": "error", + "type": "panel", + "icon": "AlertOctagon", + "tooltip": "Back-translation differs from base — likely wrong", + "dynamic": true, + "panelKey": "panel", + "visibility": "always" + } + ] + } +} diff --git a/apps/example-apps/back-translate/server/manifest.ts b/apps/example-apps/back-translate/server/manifest.ts new file mode 100644 index 00000000000..6a14191a864 --- /dev/null +++ b/apps/example-apps/back-translate/server/manifest.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { LOCAL_BASE_URL, TUNNEL_STATE_FILE } from './config' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const TEMPLATE_PATH = join(__dirname, 'manifest.template.json') + +const PLACEHOLDER = '__BASE_URL__' + +/** + * Reads the tunnel state written by scripts/dev.ts and returns the + * public base URL the plugin is reachable at, or the localhost default + * when no tunnel is active. + */ +const readBaseUrl = (): string => { + try { + const raw = readFileSync(TUNNEL_STATE_FILE, 'utf8') + const parsed = JSON.parse(raw) as { baseUrl?: string } + if (typeof parsed.baseUrl === 'string' && parsed.baseUrl.length > 0) { + return parsed.baseUrl + } + } catch { + // file missing or unreadable — fall through to localhost default + } + return LOCAL_BASE_URL +} + +export const renderManifest = (): string => { + const template = readFileSync(TEMPLATE_PATH, 'utf8') + const baseUrl = readBaseUrl() + return template.replaceAll(PLACEHOLDER, baseUrl) +} diff --git a/apps/example-apps/back-translate/server/routes/decorators.ts b/apps/example-apps/back-translate/server/routes/decorators.ts new file mode 100644 index 00000000000..3368d53270e --- /dev/null +++ b/apps/example-apps/back-translate/server/routes/decorators.ts @@ -0,0 +1,45 @@ +import type { Express, Request, Response } from 'express' +import { getAnalysis } from '../store' + +type DecoratorsRequest = { + projectId?: number + installId?: number + keyIds?: number[] + languageTags?: string[] +} + +type DecoratorItem = { + keyId: number + languageTag: string + actionKey: 'warning' | 'error' +} + +/** + * Returns one decorator per (keyId, languageTag) we've already analysed + * whose verdict isn't a clean match. `match` produces no decorator — + * the cell is fine. + */ +export const registerDecoratorsRoute = (app: Express): void => { + app.post('/decorators', (req: Request, res: Response) => { + const body = (req.body ?? {}) as DecoratorsRequest + const keyIds = Array.isArray(body.keyIds) ? body.keyIds : [] + const languageTags = Array.isArray(body.languageTags) + ? body.languageTags + : [] + + const items: DecoratorItem[] = [] + for (const keyId of keyIds) { + for (const tag of languageTags) { + const cached = getAnalysis(keyId, tag) + if (!cached) continue + if (cached.verdict === 'match') continue + items.push({ + keyId, + languageTag: tag, + actionKey: cached.verdict === 'close' ? 'warning' : 'error', + }) + } + } + res.json({ items }) + }) +} diff --git a/apps/example-apps/back-translate/server/routes/manifest.ts b/apps/example-apps/back-translate/server/routes/manifest.ts new file mode 100644 index 00000000000..3bbde6ca56d --- /dev/null +++ b/apps/example-apps/back-translate/server/routes/manifest.ts @@ -0,0 +1,8 @@ +import type { Express, Request, Response } from 'express' +import { renderManifest } from '../manifest' + +export const registerManifestRoute = (app: Express): void => { + app.get('/manifest.json', (_req: Request, res: Response) => { + res.type('application/json').send(renderManifest()) + }) +} diff --git a/apps/example-apps/back-translate/server/routes/translateBack.ts b/apps/example-apps/back-translate/server/routes/translateBack.ts new file mode 100644 index 00000000000..5e77d1cbc2b --- /dev/null +++ b/apps/example-apps/back-translate/server/routes/translateBack.ts @@ -0,0 +1,90 @@ +import type { Express, Request, Response } from 'express' +import { analyse, isAnthropicConfigured } from '../backTranslation' +import { getAnalysis, upsertAnalysis } from '../store' + +type RequestBody = { + text: string + sourceLang: string + baseText: string + baseLang: string + keyId?: number + languageTag?: string +} + +const isValidBody = (b: unknown): b is RequestBody => { + if (typeof b !== 'object' || b === null) return false + const o = b as Record + return ( + typeof o.text === 'string' && + typeof o.sourceLang === 'string' && + typeof o.baseText === 'string' && + typeof o.baseLang === 'string' + ) +} + +export const registerTranslateBackRoute = (app: Express): void => { + app.post('/translate-back', async (req: Request, res: Response) => { + if (!isAnthropicConfigured()) { + res + .status(503) + .json({ error: 'ANTHROPIC_API_KEY is not set in the server environment.' }) + return + } + if (!isValidBody(req.body)) { + res.status(400).json({ + error: + 'Expected { text, sourceLang, baseText, baseLang, keyId?, languageTag? } in body.', + }) + return + } + const body = req.body + + // Reuse a stored analysis when text + base are unchanged. Lets the + // panel skip the model call for already-analysed cells (incl. those + // hydrated by the webhook). + if (body.keyId != null && body.languageTag) { + const cached = getAnalysis(body.keyId, body.languageTag) + if ( + cached && + cached.text === body.text && + cached.baseText === body.baseText && + cached.baseLang === body.baseLang + ) { + res.json({ + backTranslation: cached.backTranslation, + verdict: cached.verdict, + explanation: cached.explanation, + cached: true, + }) + return + } + } + + try { + const analysis = await analyse({ + text: body.text, + sourceLang: body.sourceLang, + baseText: body.baseText, + baseLang: body.baseLang, + }) + if (body.keyId != null && body.languageTag) { + upsertAnalysis({ + ...analysis, + keyId: body.keyId, + languageTag: body.languageTag, + baseLang: body.baseLang, + baseText: body.baseText, + text: body.text, + analysedAt: new Date().toISOString(), + }) + } + res.json(analysis) + } catch (err) { + console.error('[translate-back] anthropic error', err) + res.status(502).json({ + error: 'Anthropic call failed', + message: err instanceof Error ? err.message : String(err), + }) + } + }) +} diff --git a/apps/example-apps/back-translate/server/routes/webhook.ts b/apps/example-apps/back-translate/server/routes/webhook.ts new file mode 100644 index 00000000000..f578e1e0249 --- /dev/null +++ b/apps/example-apps/back-translate/server/routes/webhook.ts @@ -0,0 +1,173 @@ +import express, { type Express, type Request, type Response } from 'express' +import { + onWebhook, + verifyWebhookSignature, + type AppWebhookPayload, + type WebhookPayloadFor, +} from '@tolgee/apps-sdk/server' +import { WEBHOOK_SECRET } from '../config' +import { analyse, isAnthropicConfigured } from '../backTranslation' +import { + getAnalysis, + invalidateKey, + upsertAnalysis, +} from '../store' +import { fetchKeyTranslations, getProjectLanguages, tolgeeReady } from '../tolgeeApi' + +export const registerWebhookRoute = (app: Express): void => { + // express.text parses the body verbatim — the SDK verifier needs the + // raw bytes Tolgee signed. + app.post( + '/webhook', + express.text({ type: 'application/json', limit: '5mb' }), + handleWebhook + ) +} + +const handleWebhook = async (req: Request, res: Response): Promise => { + const raw = typeof req.body === 'string' ? req.body : '' + if (!(await verifyOrAllow(req, raw))) { + res.status(401).json({ error: 'invalid signature' }) + return + } + const payload = parsePayload(raw) + if (!payload) { + res.status(400).json({ error: 'invalid json' }) + return + } + + onWebhook(payload, 'SET_TRANSLATIONS', (typed) => { + void handleSetTranslations(typed).catch((err) => { + console.error('[webhook] SET_TRANSLATIONS handler failed', err) + }) + }) + + // Always 204 — Tolgee shouldn't retry while a slow re-analysis runs. + res.status(204).end() +} + +type SetTranslationsPayload = WebhookPayloadFor<'SET_TRANSLATIONS'> + +type ModifiedTranslation = { + id?: number + languageId?: number + keyId?: number + text?: string +} + +const handleSetTranslations = async ( + payload: SetTranslationsPayload +): Promise => { + if (!tolgeeReady()) { + console.warn( + '[webhook] TOLGEE_PAT not set — skipping back-translation refresh.' + ) + return + } + if (!isAnthropicConfigured()) { + console.warn( + '[webhook] ANTHROPIC_API_KEY not set — skipping back-translation refresh.' + ) + return + } + const data = payload.activityData + if (!data) return + // Activity payloads carry projectId on the wrapper, not on each entity. + const projectId = (data as { projectId?: number }).projectId + if (typeof projectId !== 'number') { + console.warn('[webhook] SET_TRANSLATIONS missing projectId') + return + } + const mods = (data.modifiedEntities?.Translation ?? + []) as ModifiedTranslation[] + if (mods.length === 0) return + + const langs = await getProjectLanguages(projectId) + + // Bucket the changed entries by keyId, separating base-language + // edits (which invalidate every analysis for the key) from non-base + // edits (which trigger a fresh re-analysis for that specific cell). + const baseEdits = new Set() + const reanalyse: Array<{ + keyId: number + languageTag: string + text: string + }> = [] + + for (const m of mods) { + if (typeof m.keyId !== 'number' || typeof m.languageId !== 'number') continue + const tag = langs.byId.get(m.languageId) + if (!tag) continue + if (tag === langs.baseLang) { + baseEdits.add(m.keyId) + continue + } + if (typeof m.text !== 'string' || m.text.length === 0) continue + reanalyse.push({ keyId: m.keyId, languageTag: tag, text: m.text }) + } + + // Apply base-language invalidations first so re-analyses overwrite + // cleanly. + for (const keyId of baseEdits) invalidateKey(keyId) + + for (const job of reanalyse) { + // Skip if the stored text already matches — Tolgee sometimes re-fires + // SET_TRANSLATIONS for no-op saves. + const existing = getAnalysis(job.keyId, job.languageTag) + if (existing && existing.text === job.text) continue + + try { + const fetched = await fetchKeyTranslations( + projectId, + job.keyId, + langs.baseLang, + [job.languageTag] + ) + const baseText = fetched.baseText + if (!baseText) continue + + const analysis = await analyse({ + text: job.text, + sourceLang: job.languageTag, + baseText, + baseLang: langs.baseLang, + }) + upsertAnalysis({ + ...analysis, + keyId: job.keyId, + languageTag: job.languageTag, + baseLang: langs.baseLang, + baseText, + text: job.text, + analysedAt: new Date().toISOString(), + }) + } catch (err) { + console.error( + `[webhook] back-translation failed for key ${job.keyId}/${job.languageTag}`, + err + ) + } + } +} + +const verifyOrAllow = async (req: Request, raw: string): Promise => { + if (!WEBHOOK_SECRET) { + console.warn( + 'TOLGEE_WEBHOOK_SECRET is not set — accepting webhook without verification.' + ) + return true + } + return verifyWebhookSignature({ + header: req.header('Tolgee-Signature'), + rawBody: raw, + secret: WEBHOOK_SECRET, + }) +} + +const parsePayload = (raw: string): AppWebhookPayload | null => { + try { + return JSON.parse(raw) as AppWebhookPayload + } catch { + return null + } +} diff --git a/apps/example-apps/back-translate/server/store.ts b/apps/example-apps/back-translate/server/store.ts new file mode 100644 index 00000000000..5692118d02f --- /dev/null +++ b/apps/example-apps/back-translate/server/store.ts @@ -0,0 +1,86 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { STORE_DIR, STORE_FILE } from './config' + +export type Verdict = 'match' | 'close' | 'mismatch' + +export type Analysis = { + backTranslation: string + verdict: Verdict + explanation: string +} + +export type StoreEntry = Analysis & { + keyId: number + /** Language tag of the focused (non-base) cell, e.g. "de". */ + languageTag: string + baseLang: string + baseText: string + /** The translated text that was analysed. Used as cache key. */ + text: string + analysedAt: string +} + +type Persisted = { + entries: StoreEntry[] +} + +const entryKey = (keyId: number, languageTag: string): string => + `${keyId}:${languageTag}` + +const ensureDir = (): void => { + try { + mkdirSync(STORE_DIR, { recursive: true }) + } catch { + /* directory may already exist */ + } +} + +const readPersisted = (): Persisted => { + try { + const raw = readFileSync(STORE_FILE, 'utf8') + const parsed = JSON.parse(raw) as Persisted + return parsed.entries ? parsed : { entries: [] } + } catch { + return { entries: [] } + } +} + +const writePersisted = (data: Persisted): void => { + ensureDir() + writeFileSync(STORE_FILE, JSON.stringify(data, null, 2) + '\n', 'utf8') +} + +const map = new Map( + readPersisted().entries.map((e) => [entryKey(e.keyId, e.languageTag), e]) +) + +const flush = (): void => { + writePersisted({ entries: Array.from(map.values()) }) +} + +export const getAnalysis = ( + keyId: number, + languageTag: string +): StoreEntry | undefined => map.get(entryKey(keyId, languageTag)) + +export const upsertAnalysis = (entry: StoreEntry): void => { + map.set(entryKey(entry.keyId, entry.languageTag), entry) + flush() +} + +/** + * Drop every cached entry for a key. Useful when the base text changes + * — all per-language analyses for the same key become stale. + */ +export const invalidateKey = (keyId: number): void => { + let changed = false + for (const [k, v] of map) { + if (v.keyId === keyId) { + map.delete(k) + changed = true + } + } + if (changed) flush() +} + +export const allEntries = (): StoreEntry[] => Array.from(map.values()) diff --git a/apps/example-apps/back-translate/server/tolgeeApi.ts b/apps/example-apps/back-translate/server/tolgeeApi.ts new file mode 100644 index 00000000000..768fd58e64c --- /dev/null +++ b/apps/example-apps/back-translate/server/tolgeeApi.ts @@ -0,0 +1,119 @@ +import { readFileSync } from 'node:fs' +import { INSTALL_FILE, TOLGEE_URL } from './config' + +type InstallRecord = { + tolgeeUrl?: string + clientSecret?: string +} + +let cachedSecret: string | null = null +let cachedTolgeeUrl: string | null = null + +const readInstallSecret = (): { clientSecret: string; tolgeeUrl: string } | null => { + if (cachedSecret && cachedTolgeeUrl) { + return { clientSecret: cachedSecret, tolgeeUrl: cachedTolgeeUrl } + } + try { + const raw = readFileSync(INSTALL_FILE, 'utf8') + const parsed = JSON.parse(raw) as InstallRecord + if (!parsed.clientSecret) return null + cachedSecret = parsed.clientSecret + cachedTolgeeUrl = parsed.tolgeeUrl ?? TOLGEE_URL + return { clientSecret: cachedSecret, tolgeeUrl: cachedTolgeeUrl } + } catch { + return null + } +} + +const authHeaders = (): Record => { + const cred = readInstallSecret() + if (!cred) throw new Error('install record missing — run `npm run register`') + return { 'X-API-Key': cred.clientSecret } +} + +const tolgeeBase = (): string => readInstallSecret()?.tolgeeUrl ?? TOLGEE_URL + +export const tolgeeReady = (): boolean => readInstallSecret() !== null + +type Language = { id: number; tag: string; base: boolean } + +type ProjectLanguages = { + baseLang: string + byId: Map +} + +const projectLanguagesCache = new Map>() + +/** + * Cached for the lifetime of the process. Restart `npm run dev` after + * adding or renaming languages in Tolgee. + */ +export const getProjectLanguages = async ( + projectId: number +): Promise => { + const cached = projectLanguagesCache.get(projectId) + if (cached) return cached + const pending = (async (): Promise => { + const res = await fetch( + `${tolgeeBase()}/v2/projects/${projectId}/languages?size=100`, + { headers: authHeaders() } + ) + if (!res.ok) { + throw new Error( + `languages lookup failed: ${res.status} ${await res.text()}` + ) + } + const json = (await res.json()) as { + _embedded?: { languages?: Language[] } + } + const languages = json._embedded?.languages ?? [] + const base = languages.find((l) => l.base) + if (!base) throw new Error('No base language reported for project') + return { + baseLang: base.tag, + byId: new Map(languages.map((l) => [l.id, l.tag] as const)), + } + })() + projectLanguagesCache.set(projectId, pending) + return pending.catch((err) => { + projectLanguagesCache.delete(projectId) + throw err + }) +} + +type KeyTranslations = { + baseText: string | null + translationsByTag: Record +} + +export const fetchKeyTranslations = async ( + projectId: number, + keyId: number, + baseLang: string, + languageTags: string[] +): Promise => { + const langs = Array.from(new Set([baseLang, ...languageTags])) + const qs = new URLSearchParams({ size: '1' }) + qs.set('filterKeyId', String(keyId)) + for (const tag of langs) qs.append('languages', tag) + const res = await fetch( + `${tolgeeBase()}/v2/projects/${projectId}/translations?${qs.toString()}`, + { headers: authHeaders() } + ) + if (!res.ok) { + throw new Error( + `translation lookup failed: ${res.status} ${await res.text()}` + ) + } + type Row = { + translations: Record + } + const json = (await res.json()) as { _embedded?: { keys?: Row[] } } + const row = json._embedded?.keys?.[0] + const baseText = row?.translations?.[baseLang]?.text ?? null + const translationsByTag: Record = {} + for (const tag of languageTags) { + translationsByTag[tag] = row?.translations?.[tag]?.text ?? null + } + return { baseText, translationsByTag } +} diff --git a/apps/example-apps/back-translate/src/App.css b/apps/example-apps/back-translate/src/App.css new file mode 100644 index 00000000000..a795659cf81 --- /dev/null +++ b/apps/example-apps/back-translate/src/App.css @@ -0,0 +1,108 @@ +html, +body, +#root { + margin: 0; + padding: 0; + font-family: -apple-system, system-ui, sans-serif; + /* Driven by applyTolgeeTheme(ctx.theme); hex values are fallbacks. */ + color: var(--tg-color-text, #222); + background: var(--tg-color-background, #fff); +} + +main { + padding: 16px; +} + +main h1, +main h2 { + margin-top: 0; +} + +.kv { + border-collapse: collapse; + font-size: 13px; +} + +.kv th { + text-align: left; + padding: 4px 12px 4px 0; + font-weight: 500; + color: var(--tg-color-text-secondary, #666); +} + +.kv td { + padding: 4px 0; +} + +.kv code { + background: var(--tg-color-background-paper, #f3f3f3); + padding: 1px 6px; + border-radius: 4px; +} + +.back-translate-panel { + font-size: 13px; + line-height: 1.4; + color: var(--tg-color-text, #222); +} + +.bt-status { + color: var(--tg-color-text-secondary, #666); + font-size: 12px; +} + +.bt-error { + color: var(--tg-color-error, #c44343); + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; +} + +.bt-row { + margin: 8px 0; +} + +.bt-row-muted { + opacity: 0.65; +} + +.bt-row-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--tg-color-text-secondary, #888); + margin-bottom: 2px; +} + +.bt-row-value { + font-size: 13px; + color: var(--tg-color-text, #222); + word-break: break-word; +} + +.bt-verdict { + display: flex; + gap: 10px; + align-items: flex-start; + border: 1px solid var(--tg-color-divider, #ddd); + border-radius: 6px; + padding: 10px 12px; + margin-top: 12px; + background: var(--tg-color-background-paper, #fafafa); +} + +.bt-verdict-glyph { + font-size: 1.6em; + line-height: 1; +} + +.bt-verdict-explanation { + font-size: 12px; + color: var(--tg-color-text-secondary, #444); + margin-top: 2px; +} + +.bt-cached { + font-size: 11px; + color: var(--tg-color-text-secondary, #888); +} diff --git a/apps/example-apps/back-translate/src/main.tsx b/apps/example-apps/back-translate/src/main.tsx new file mode 100644 index 00000000000..a2b94b1b2a6 --- /dev/null +++ b/apps/example-apps/back-translate/src/main.tsx @@ -0,0 +1,42 @@ +import { StrictMode, type ComponentType } from 'react' +import { createRoot } from 'react-dom/client' +import './App.css' + +// Each iframe module is lazy-imported by URL path. The scaffold only +// generates folders for the modules the wizard selected; unselected +// paths show a tiny fallback. +const ROUTES: Record Promise<{ default: ComponentType }>> = { + '/tools-panel': () => import('./modules/toolsPanel'), +} + +const NotFound = () => ( +
+

Back Translate

+

+ No module matches {location.pathname}. Add a route in{' '} + src/main.tsx and a manifest entry in{' '} + server/manifest.template.json. +

+
+) + +async function mount() { + const root = createRoot(document.getElementById('root')!) + const loader = ROUTES[location.pathname] + if (!loader) { + root.render( + + + + ) + return + } + const { default: Component } = await loader() + root.render( + + + + ) +} + +mount() diff --git a/apps/example-apps/back-translate/src/modules/toolsPanel/index.tsx b/apps/example-apps/back-translate/src/modules/toolsPanel/index.tsx new file mode 100644 index 00000000000..414cf0baba5 --- /dev/null +++ b/apps/example-apps/back-translate/src/modules/toolsPanel/index.tsx @@ -0,0 +1,261 @@ +import { useEffect, useRef, useState } from 'react' +import { + applyTolgeeTheme, + createTolgeeApp, + createTolgeeAppClient, + type TolgeeApp, + type TolgeeAppContext, + type TolgeeAppSelection, +} from '@tolgee/apps-sdk/browser' + +type Verdict = 'match' | 'close' | 'mismatch' + +type Analysis = { + backTranslation: string + verdict: Verdict + explanation: string + cached?: boolean +} + +type Pair = { + baseText: string + text: string + sourceLang: string + keyId: number + languageTag: string +} + +const VERDICT: Record = { + match: { glyph: '✓', color: '#1b8c43', label: 'Semantic match' }, + close: { glyph: '~', color: '#c98a14', label: 'Close, minor drift' }, + mismatch: { glyph: '✗', color: '#c44343', label: 'Semantic mismatch' }, +} + +export default function ToolsPanel() { + const [ctx, setCtx] = useState(null) + const [selection, setSelection] = useState({}) + const [baseLang, setBaseLang] = useState(null) + const [pair, setPair] = useState(null) + const [analysis, setAnalysis] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const containerRef = useRef(null) + const appRef = useRef(null) + + // Boot the SDK handshake. + useEffect(() => { + const app = createTolgeeApp() + appRef.current = app + app.context.then((c) => { + setCtx(c) + setSelection(c.selection) + }) + const off = app.onSelectionChanged(setSelection) + const offTheme = app.onThemeChanged(applyTolgeeTheme) + return () => { + off() + offTheme() + app.dispose() + appRef.current = null + } + }, []) + + // Resize the iframe to fit its content. + useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver(() => { + appRef.current?.resize(el.scrollHeight) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + // Discover the project's base language once. + useEffect(() => { + if (!ctx) return + const client = createTolgeeAppClient(ctx) + let cancelled = false + client + .GET('/v2/projects/{projectId}', { + params: { path: { projectId: ctx.projectId } }, + }) + .then(({ data, error }) => { + if (cancelled) return + if (error) setError(`Project lookup failed: ${JSON.stringify(error)}`) + else if (data?.baseLanguage?.tag) setBaseLang(data.baseLanguage.tag) + }) + return () => { + cancelled = true + } + }, [ctx]) + + // Fetch the base + focused texts whenever the selection changes. + useEffect(() => { + if (!ctx || !baseLang) return + const tag = selection.languageTag + const keyId = selection.keyId + if (!keyId || !tag || tag === baseLang) { + setPair(null) + setAnalysis(null) + setError(null) + return + } + const client = createTolgeeAppClient(ctx) + let cancelled = false + setError(null) + client + .GET('/v2/projects/{projectId}/translations', { + params: { + path: { projectId: ctx.projectId }, + query: { + filterKeyId: [keyId], + languages: [baseLang, tag], + size: 1, + }, + }, + }) + .then(({ data, error }) => { + if (cancelled) return + if (error) { + setError(`Translation lookup failed: ${JSON.stringify(error)}`) + return + } + const row = data?._embedded?.keys?.[0] + const baseText = row?.translations?.[baseLang]?.text + const text = row?.translations?.[tag]?.text + if (!baseText || !text) { + setPair(null) + return + } + setPair({ baseText, text, sourceLang: tag, keyId, languageTag: tag }) + }) + return () => { + cancelled = true + } + }, [ctx, baseLang, selection.keyId, selection.languageTag]) + + // Whenever we have a fresh pair, ask Claude. + useEffect(() => { + if (!pair || !baseLang) return + let cancelled = false + setLoading(true) + setError(null) + setAnalysis(null) + fetch('/translate-back', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...pair, baseLang }), + }) + .then(async (r) => { + const body = await r.text() + if (!r.ok) throw new Error(`${r.status}: ${body}`) + return JSON.parse(body) as Analysis + }) + .then((result) => { + if (cancelled) return + setAnalysis(result) + setLoading(false) + }) + .catch((e) => { + if (cancelled) return + setError(e instanceof Error ? e.message : String(e)) + setLoading(false) + }) + return () => { + cancelled = true + } + }, [pair, baseLang]) + + return ( +
+ {!ctx &&

Loading context…

} + {ctx && !baseLang && !error && ( +

Loading project metadata…

+ )} + {ctx && baseLang && ( + <> + {!selection.keyId && ( +

Focus a translation cell to analyse it.

+ )} + {selection.keyId && selection.languageTag === baseLang && ( +

+ Focused cell is in the base language ({baseLang}); nothing to + back-translate. +

+ )} + {selection.keyId && + selection.languageTag && + selection.languageTag !== baseLang && + !pair && + !error &&

Loading translation…

} + {pair && ( + <> + {loading && ( +

Back-translating with Claude…

+ )} + {analysis && ( + <> + + + + )} + + )} + + )} + {error &&

{error}

} +
+ ) +} + +const Row = ({ + label, + value, + muted, +}: { + label: string + value: string + muted?: boolean +}) => ( +
+
{label}
+
{value}
+
+) + +const VerdictBadge = ({ + verdict, + explanation, + cached, +}: { + verdict: Verdict + explanation: string + cached?: boolean +}) => { + const v = VERDICT[verdict] ?? { + glyph: '?', + color: '#888', + label: 'Unknown', + } + return ( +
+ + {v.glyph} + +
+ {v.label} + {cached && · cached} +
{explanation}
+
+
+ ) +} diff --git a/apps/example-apps/back-translate/tsconfig.app.json b/apps/example-apps/back-translate/tsconfig.app.json new file mode 100644 index 00000000000..c23f9637f3c --- /dev/null +++ b/apps/example-apps/back-translate/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/apps/example-apps/back-translate/tsconfig.json b/apps/example-apps/back-translate/tsconfig.json new file mode 100644 index 00000000000..1ffef600d95 --- /dev/null +++ b/apps/example-apps/back-translate/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/example-apps/back-translate/tsconfig.node.json b/apps/example-apps/back-translate/tsconfig.node.json new file mode 100644 index 00000000000..44af4f212cd --- /dev/null +++ b/apps/example-apps/back-translate/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts", "server/**/*.ts", "scripts/**/*.ts"] +} diff --git a/apps/example-apps/back-translate/vite.config.ts b/apps/example-apps/back-translate/vite.config.ts new file mode 100644 index 00000000000..b0360571e2c --- /dev/null +++ b/apps/example-apps/back-translate/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, loadEnv } from 'vite' +import react from '@vitejs/plugin-react' + +// Ports are env-overridable so multiple plugins can run side-by-side. +// Defaults match the historical 5180/5181 pair. +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const vitePort = Number(env.VITE_PORT ?? 5180) + const serverPort = Number(env.SERVER_PORT ?? 5181) + const serverTarget = `http://localhost:${serverPort}` + return { + plugins: [react()], + server: { + port: vitePort, + strictPort: true, + // Cloudflare quick tunnels expose a single port. Vite is the + // tunnel target; these proxies forward Tolgee → Express endpoints + // through the one public hostname. + proxy: { + '/manifest.json': serverTarget, + '/webhook': serverTarget, + '/decorators': serverTarget, + '/translate-back': serverTarget, + }, + // Quick tunnels rewrite the Host header to the trycloudflare.com + // hostname; Vite's default host check would reject those requests. + allowedHosts: true, + }, + } +}) diff --git a/apps/example-apps/dev-plugin/.gitignore b/apps/example-apps/dev-plugin/.gitignore new file mode 100644 index 00000000000..1e810130657 --- /dev/null +++ b/apps/example-apps/dev-plugin/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +server/.data/ +.tolgee-dev/ diff --git a/apps/example-apps/dev-plugin/README.md b/apps/example-apps/dev-plugin/README.md new file mode 100644 index 00000000000..3e78eee8371 --- /dev/null +++ b/apps/example-apps/dev-plugin/README.md @@ -0,0 +1,60 @@ +# Tolgee Dev Plugin + +A minimal example of a Tolgee App used for local development of the Tolgee Apps +PoC. Serves a manifest, an iframe page, and a small backend that receives +webhooks and stores per-translation emoji + last-updated-at side data. + +## Running + +```bash +npm install +npm run dev +``` + +`npm run dev` starts two processes side-by-side via `concurrently`: + +- **Vite** on `http://localhost:5180` — serves the manifest at `/manifest.json`, + the iframe page, and proxies `/api/*` to the backend so the iframe can call + the plugin's REST API same-origin. +- **Express** on `http://localhost:5181` — receives webhooks directly from + Tolgee and handles `/api/*` for the iframe (via Vite's proxy). + +The manifest's `baseUrl` is `http://localhost:5180` (Vite, where the iframe is +served), but `webhooks.url` is the **absolute** `http://localhost:5181/webhook` +so Tolgee's server-side dispatcher hits Express directly without depending on +Vite being up. In a real deployment, both would live behind a reverse proxy on +one host, so the webhook URL would normally be relative. + +## Env vars + +| Var | Purpose | +| --- | --- | +| `TOLGEE_WEBHOOK_SECRET` | HMAC key Tolgee signs webhook bodies with. If unset, the server warns and accepts every webhook. Recommended to set in any non-trivial test. | +| `PORT` | Override the backend port (default `5181`). | + +Get the webhook secret from the Tolgee "Custom apps" admin row after registering +this plugin. The same row also shows the `clientId` + `clientSecret`, which a +real plugin would use to call Tolgee's REST API on its own behalf — this +example doesn't make outbound API calls, so the secret only matters for webhook +verification here. + +## Storage + +`server/data.json` (gitignored) holds: + +```json +{ + "emojis": { "": "🎉" }, + "updatedAt": { "": "2026-05-15T12:00:00.000Z" } +} +``` + +Translation IDs are globally unique in Tolgee, so a single shared file works +across projects. + +## Frontend ↔ Backend auth — your problem + +Tolgee doesn't authenticate `plugin-frontend → plugin-backend` calls; that's up +to the plugin author. This example leaves `/api/*` wide open because it runs on +localhost. A real plugin would either validate the iframe's user-context JWT +against Tolgee's `/v2/user` endpoint or use its own auth scheme. diff --git a/apps/example-apps/dev-plugin/eslint.config.js b/apps/example-apps/dev-plugin/eslint.config.js new file mode 100644 index 00000000000..ef614d25c11 --- /dev/null +++ b/apps/example-apps/dev-plugin/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/apps/example-apps/dev-plugin/index.html b/apps/example-apps/dev-plugin/index.html new file mode 100644 index 00000000000..5ef0daa4929 --- /dev/null +++ b/apps/example-apps/dev-plugin/index.html @@ -0,0 +1,13 @@ + + + + + + + Tolgee Dev Plugin + + +
+ + + diff --git a/apps/example-apps/dev-plugin/package-lock.json b/apps/example-apps/dev-plugin/package-lock.json new file mode 100644 index 00000000000..422b9678def --- /dev/null +++ b/apps/example-apps/dev-plugin/package-lock.json @@ -0,0 +1,4546 @@ +{ + "name": "dev-plugin", + "version": "0.0.1-alpha.7", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dev-plugin", + "version": "0.0.1-alpha.7", + "dependencies": { + "@tginternal/client": "0.0.0-prerelease.c8329b552", + "cloudflared": "^0.7.1", + "express": "^4.21.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/express": "^4.17.21", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "concurrently": "^9.1.0", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "tsx": "^4.19.2", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", + "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", + "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", + "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", + "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", + "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", + "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", + "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", + "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", + "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", + "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", + "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", + "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", + "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", + "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", + "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tginternal/client": { + "version": "0.0.0-prerelease.c8329b552", + "resolved": "https://registry.npmjs.org/@tginternal/client/-/client-0.0.0-prerelease.c8329b552.tgz", + "integrity": "sha512-TaNYadjZ86m1vuR+R9v1OtGmIL0XpDTf/Nr2oc60gOwgrKx6VjeG31aJiz9Xm0i/xS+doPqZHcOBgJU9J2706Q==", + "license": "Apache-2.0", + "dependencies": { + "base32-decode": "^1.0.0", + "openapi-fetch": "^0.14.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.3", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base32-decode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base32-decode/-/base32-decode-1.0.0.tgz", + "integrity": "sha512-KNWUX/R7wKenwE/G/qFMzGScOgVntOmbE27vvc6GrniDGYb6a5+qWcuoXl8WIOQL7q0TpK7nZDm1Y04Yi3Yn5g==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cloudflared": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cloudflared/-/cloudflared-0.7.1.tgz", + "integrity": "sha512-jJn1Gu9Tf4qnIu8tfiHZ25Hs8rNcRYSVf8zAd97wvYdOCzftm1CTs1S/RPhijjGi8gUT1p9yzfDi9zYlU/0RwA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "cloudflared": "lib/cloudflared.js" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.355", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.355.tgz", + "integrity": "sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openapi-fetch": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.14.1.tgz", + "integrity": "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.15" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", + "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", + "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.129.0", + "@rolldown/pluginutils": "1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0", + "@rolldown/binding-darwin-arm64": "1.0.0", + "@rolldown/binding-darwin-x64": "1.0.0", + "@rolldown/binding-freebsd-x64": "1.0.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", + "@rolldown/binding-linux-arm64-gnu": "1.0.0", + "@rolldown/binding-linux-arm64-musl": "1.0.0", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0", + "@rolldown/binding-linux-s390x-gnu": "1.0.0", + "@rolldown/binding-linux-x64-gnu": "1.0.0", + "@rolldown/binding-linux-x64-musl": "1.0.0", + "@rolldown/binding-openharmony-arm64": "1.0.0", + "@rolldown/binding-wasm32-wasi": "1.0.0", + "@rolldown/binding-win32-arm64-msvc": "1.0.0", + "@rolldown/binding-win32-x64-msvc": "1.0.0" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", + "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz", + "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", + "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/apps/example-apps/dev-plugin/package.json b/apps/example-apps/dev-plugin/package.json new file mode 100644 index 00000000000..7cbb3fcb28e --- /dev/null +++ b/apps/example-apps/dev-plugin/package.json @@ -0,0 +1,42 @@ +{ + "name": "dev-plugin", + "private": true, + "version": "0.0.1-alpha.7", + "type": "module", + "scripts": { + "dev": "concurrently -k -n vite,server,tunnel -c blue,green,yellow \"npm:dev:frontend\" \"npm:dev:server\" \"npm:dev:tunnel\"", + "dev:frontend": "vite", + "dev:server": "tsx watch --env-file-if-exists=.env.local server/index.ts", + "dev:tunnel": "tsx --env-file-if-exists=.env.local scripts/dev.ts", + "register": "tsx --env-file-if-exists=.env.local scripts/register.ts", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tginternal/client": "0.0.0-prerelease.c8329b552", + "@tolgee/apps-sdk": "^0.0.1-alpha.7", + "cloudflared": "^0.7.1", + "express": "^4.21.0", + "open": "^10.1.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/express": "^4.17.21", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "concurrently": "^9.1.0", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "tsx": "^4.19.2", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/apps/example-apps/dev-plugin/public/favicon.svg b/apps/example-apps/dev-plugin/public/favicon.svg new file mode 100644 index 00000000000..6893eb13237 --- /dev/null +++ b/apps/example-apps/dev-plugin/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/example-apps/dev-plugin/public/icons.svg b/apps/example-apps/dev-plugin/public/icons.svg new file mode 100644 index 00000000000..e9522193d9f --- /dev/null +++ b/apps/example-apps/dev-plugin/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/example-apps/dev-plugin/scripts/dev.ts b/apps/example-apps/dev-plugin/scripts/dev.ts new file mode 100644 index 00000000000..18c041edcd8 --- /dev/null +++ b/apps/example-apps/dev-plugin/scripts/dev.ts @@ -0,0 +1,113 @@ +/** + * Boots a Cloudflare Quick Tunnel pointing at the local Vite dev server, + * writes its public URL to `.tolgee-dev/tunnel.json` so the manifest + * route picks it up, and (if an install was registered) PATCHes the + * Tolgee install's manifest URL so the new tunnel URL takes effect + * immediately. + * + * Skips the tunnel entirely when the registered Tolgee instance lives + * on localhost — Tolgee can reach the dev plugin directly, no public + * URL needed. Force-disable via `TOLGEE_DEV_TUNNEL=none`. + */ +import { existsSync } from 'node:fs' +import { bin, install, Tunnel } from 'cloudflared' +import { + clearTunnelState, + patchManifestUrl, + readInstallState, + writeTunnelState, + type InstallState, +} from './lib' + +const VITE_PORT = Number(process.env.VITE_PORT ?? 5180) +const LOCAL_BASE_URL = `http://localhost:${VITE_PORT}` + +const isLocalHost = (urlString: string): boolean => { + try { + const host = new URL(urlString).hostname + return host === 'localhost' || host === '127.0.0.1' || host === '::1' + } catch { + return false + } +} + +const shouldSkipTunnel = (installState: InstallState | null): boolean => { + if (process.env.TOLGEE_DEV_TUNNEL === 'none') return true + if (installState && isLocalHost(installState.tolgeeUrl)) return true + if (process.env.TOLGEE_URL && isLocalHost(process.env.TOLGEE_URL)) return true + return false +} + +const ensureBinary = async (): Promise => { + if (!existsSync(bin)) { + console.log('[tunnel] installing cloudflared binary…') + await install(bin) + } +} + +const startTunnel = async (): Promise => { + const tunnel = Tunnel.quick(`http://localhost:${VITE_PORT}`) + const url = await new Promise((resolve) => { + tunnel.once('url', resolve) + }) + tunnel.on('exit', (code) => { + console.log(`[tunnel] cloudflared exited with code ${code}`) + }) + process.once('SIGINT', () => tunnel.stop()) + process.once('SIGTERM', () => tunnel.stop()) + return url +} + +const main = async (): Promise => { + clearTunnelState() + const installState = readInstallState() + + if (shouldSkipTunnel(installState)) { + console.log( + `[tunnel] skipping cloudflared — Tolgee reaches the plugin at ${LOCAL_BASE_URL}` + ) + writeTunnelState({ baseUrl: LOCAL_BASE_URL }) + await maybePatchManifestUrl(installState, LOCAL_BASE_URL) + // Park so concurrently -k doesn't tear the other processes down. + // A long-lived interval keeps the event loop alive until SIGINT/SIGTERM. + const heartbeat = setInterval(() => {}, 1 << 30) + await new Promise((resolve) => { + process.once('SIGINT', () => resolve()) + process.once('SIGTERM', () => resolve()) + }) + clearInterval(heartbeat) + return + } + + await ensureBinary() + const baseUrl = await startTunnel() + console.log(`[tunnel] live at ${baseUrl}`) + writeTunnelState({ baseUrl }) + await maybePatchManifestUrl(installState, baseUrl) +} + +const maybePatchManifestUrl = async ( + installState: InstallState | null, + baseUrl: string +): Promise => { + if (!installState) { + console.log( + '[tunnel] no install registered — run `npm run register` to ' + + 'install the dev plugin against a Tolgee organization.' + ) + return + } + try { + await patchManifestUrl(installState, `${baseUrl}/manifest.json`) + console.log( + `[tunnel] PATCHed install ${installState.installId} to use ${baseUrl}/manifest.json` + ) + } catch (err) { + console.error('[tunnel] manifest-url update failed:', err) + } +} + +main().catch((err) => { + console.error('[tunnel] fatal:', err) + process.exit(1) +}) diff --git a/apps/example-apps/dev-plugin/scripts/lib.ts b/apps/example-apps/dev-plugin/scripts/lib.ts new file mode 100644 index 00000000000..8c61c6d02e0 --- /dev/null +++ b/apps/example-apps/dev-plugin/scripts/lib.ts @@ -0,0 +1,75 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export const ROOT = join(__dirname, '..') +export const STATE_DIR = join(ROOT, '.tolgee-dev') +export const INSTALL_FILE = join(STATE_DIR, 'install.json') +export const TUNNEL_FILE = join(STATE_DIR, 'tunnel.json') + +export type InstallState = { + tolgeeUrl: string + organizationId: number + installId: number + /** + * Plugin credentials Tolgee returned when this install was registered. + * `clientSecret` (`X-API-Key: tgapps_…`) authenticates every outbound + * call the plugin makes back to Tolgee — webhook handler base-text + * lookups AND the self-service manifest-url PATCH the dev orchestrator + * fires on every restart. `webhookSecret` signs the inbound webhook + * bodies. + */ + clientId: string + clientSecret: string + webhookSecret: string +} + +export type TunnelState = { + baseUrl: string +} + +export const writeJson = (path: string, value: unknown): void => { + mkdirSync(STATE_DIR, { recursive: true }) + writeFileSync(path, JSON.stringify(value, null, 2) + '\n', 'utf8') +} + +export const readInstallState = (): InstallState | null => { + try { + return JSON.parse(readFileSync(INSTALL_FILE, 'utf8')) as InstallState + } catch { + return null + } +} + +export const writeTunnelState = (state: TunnelState): void => { + writeJson(TUNNEL_FILE, state) +} + +export const clearTunnelState = (): void => { + writeJson(TUNNEL_FILE, { baseUrl: 'http://localhost:5180' }) +} + +export const patchManifestUrl = async ( + install: InstallState, + manifestUrl: string +): Promise => { + // Self-service endpoint — the install authenticates as itself with its + // clientSecret. The earlier org-admin endpoint required a PAT we no + // longer collect; this one only lets an install update its own URL. + const url = `${install.tolgeeUrl}/v2/apps/self/manifest-url` + const res = await fetch(url, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': install.clientSecret, + }, + body: JSON.stringify({ manifestUrl }), + }) + if (!res.ok) { + throw new Error( + `PATCH manifest-url failed (${res.status}): ${await res.text()}` + ) + } +} diff --git a/apps/example-apps/dev-plugin/scripts/register.ts b/apps/example-apps/dev-plugin/scripts/register.ts new file mode 100644 index 00000000000..b49b836f8af --- /dev/null +++ b/apps/example-apps/dev-plugin/scripts/register.ts @@ -0,0 +1,303 @@ +/** + * One-time install bootstrap. + * + * Default flow: open Tolgee in the user's browser, let them pick an org and + * approve the install, then receive the install credentials back on a one-shot + * localhost callback server. + * + * Headless fallback: pass `--pat=tgpat_…` to skip the browser and register + * directly with a personal-access token (useful for CI). + * + * When Tolgee is local the manifest is served straight from Express — no + * Cloudflare tunnel needed. For remote Tolgee a quick tunnel is booted so + * Tolgee can fetch the manifest from outside the user's machine. Either way, + * the local dev server must be running (`npm run dev` in another terminal). + */ +import { createInterface } from 'node:readline/promises' +import { existsSync } from 'node:fs' +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { randomUUID, timingSafeEqual } from 'node:crypto' +import open from 'open' +import { bin, install, Tunnel } from 'cloudflared' +import { + type InstallState, + INSTALL_FILE, + writeJson, + writeTunnelState, +} from './lib' + +type Org = { id: number; name: string; slug: string } + +const VITE_PORT = Number(process.env.VITE_PORT ?? 5180) +const SERVER_PORT = Number(process.env.SERVER_PORT ?? 5181) +const LOCAL_MANIFEST_URL = `http://localhost:${SERVER_PORT}/manifest.json` +const BROWSER_TIMEOUT_MS = 5 * 60_000 + +const args = process.argv.slice(2) +const patFlag = + args.find((a) => a.startsWith('--pat='))?.slice('--pat='.length) ?? null + +const isLocalHost = (urlString: string): boolean => { + try { + const host = new URL(urlString).hostname + return host === 'localhost' || host === '127.0.0.1' || host === '::1' + } catch { + return false + } +} + +const verifyManifestReachable = async (manifestUrl: string): Promise => { + let res: Response + try { + res = await fetch(manifestUrl) + } catch (err) { + throw new Error( + `Could not reach ${manifestUrl}. Is \`npm run dev\` running in another terminal? (${ + err instanceof Error ? err.message : String(err) + })` + ) + } + if (!res.ok) { + throw new Error(`${manifestUrl} returned ${res.status}. Is dev running?`) + } +} + +const startTunnel = async (): Promise => { + if (!existsSync(bin)) { + console.log('Installing cloudflared binary…') + await install(bin) + } + console.log('Starting Cloudflare quick tunnel…') + const tunnel = Tunnel.quick(`http://localhost:${VITE_PORT}`) + const baseUrl = await new Promise((resolve) => + tunnel.once('url', resolve) + ) + console.log(`Tunnel live at ${baseUrl}`) + return baseUrl +} + +/** + * Picks the manifest URL Tolgee will fetch + writes the tunnel state so + * the local server serves the right baseUrl. + * local Tolgee → http://localhost:5181/manifest.json (Express direct) + * remote Tolgee → Cloudflare quick tunnel pointing at Vite + */ +const resolveManifestUrl = async ( + tolgeeUrl: string +): Promise => { + if (isLocalHost(tolgeeUrl)) { + writeTunnelState({ baseUrl: `http://localhost:${VITE_PORT}` }) + console.log(`Skipping tunnel — Tolgee is local; using ${LOCAL_MANIFEST_URL}`) + return LOCAL_MANIFEST_URL + } + const tunnelBaseUrl = await startTunnel() + writeTunnelState({ baseUrl: tunnelBaseUrl }) + return `${tunnelBaseUrl}/manifest.json` +} + +const constantTimeEq = (a: string, b: string): boolean => { + const ab = Buffer.from(a) + const bb = Buffer.from(b) + if (ab.length !== bb.length) return false + return timingSafeEqual(ab, bb) +} + +/** + * Browser flow: spins up a one-shot HTTP server on 127.0.0.1, opens the + * Tolgee install page, waits for the redirect callback. + */ +const browserRegister = async ( + tolgeeUrl: string, + manifestUrl: string +): Promise => { + const state = randomUUID() + return await new Promise((resolve, reject) => { + let settled = false + const finish = ( + kind: 'resolve' | 'reject', + value: InstallState | Error + ) => { + if (settled) return + settled = true + clearTimeout(timeout) + server.close() + if (kind === 'resolve') resolve(value as InstallState) + else reject(value as Error) + } + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '', `http://127.0.0.1`) + if (url.pathname !== '/cb') { + res.statusCode = 404 + res.end('Not found') + return + } + const incomingState = url.searchParams.get('state') ?? '' + if (!constantTimeEq(incomingState, state)) { + res.statusCode = 400 + res.end(htmlPage('State mismatch — refusing this callback.', false)) + finish('reject', new Error('Callback state did not match.')) + return + } + const error = url.searchParams.get('error') + if (error) { + res.end(htmlPage(`Install ${error}. You can close this tab.`, false)) + finish('reject', new Error(`Install ${error}`)) + return + } + const installId = Number(url.searchParams.get('installId')) + const organizationId = Number(url.searchParams.get('organizationId')) + const clientId = url.searchParams.get('clientId') ?? '' + const clientSecret = url.searchParams.get('clientSecret') ?? '' + const webhookSecret = url.searchParams.get('webhookSecret') ?? '' + if (!installId || !organizationId || !clientId || !clientSecret) { + res.statusCode = 400 + res.end(htmlPage('Callback missing required fields.', false)) + finish('reject', new Error('Callback missing required fields.')) + return + } + res.end(htmlPage('Installed. You can close this tab.', true)) + finish('resolve', { + tolgeeUrl, + organizationId, + installId, + clientId, + clientSecret, + webhookSecret, + }) + }) + + const timeout = setTimeout( + () => finish('reject', new Error('Browser flow timed out.')), + BROWSER_TIMEOUT_MS + ) + + server.on('error', (err) => finish('reject', err)) + server.listen(0, '127.0.0.1', async () => { + const address = server.address() + if (!address || typeof address === 'string') { + finish('reject', new Error('Failed to bind localhost port')) + return + } + const callback = `http://127.0.0.1:${address.port}/cb` + const installUrl = + `${tolgeeUrl.replace(/\/$/, '')}/install-app` + + `?manifestUrl=${encodeURIComponent(manifestUrl)}` + + `&callback=${encodeURIComponent(callback)}` + + `&state=${encodeURIComponent(state)}` + console.log(`\nOpening browser to install the plugin…`) + console.log(` ${installUrl}\n`) + console.log( + `If the browser doesn't open automatically, paste that URL into it.\n` + + `Waiting up to ${BROWSER_TIMEOUT_MS / 1000}s for you to approve…` + ) + try { + await open(installUrl) + } catch { + // ignore — user can paste the URL manually + } + }) + }) +} + +const htmlPage = (message: string, ok: boolean): string => { + const color = ok ? '#1b8c43' : '#c44343' + return ` +Tolgee plugin install + +
${ok ? '✓ Done' : '✗ Stopped'}
+

${message}

+` +} + +/** Headless fallback: prompts for org by listing them via PAT. */ +const patRegister = async ( + tolgeeUrl: string, + manifestUrl: string, + pat: string +): Promise => { + if (!pat.startsWith('tgpat_')) { + throw new Error('--pat value must start with tgpat_') + } + const orgs = await (async () => { + const res = await fetch(`${tolgeeUrl}/v2/organizations?size=100`, { + headers: { 'X-API-Key': pat }, + }) + if (!res.ok) { + throw new Error( + `Could not list organizations (${res.status}): ${await res.text()}` + ) + } + const json = (await res.json()) as { _embedded?: { organizations?: Org[] } } + return json._embedded?.organizations ?? [] + })() + if (orgs.length === 0) { + throw new Error('No organizations visible to this PAT.') + } + console.log('\nOrganizations:') + orgs.forEach((o, i) => console.log(` ${i + 1}. ${o.name} (${o.slug})`)) + const rl = createInterface({ input: process.stdin, output: process.stdout }) + const idxStr = (await rl.question(`Pick one [1-${orgs.length}]: `)).trim() + rl.close() + const idx = Number.parseInt(idxStr, 10) - 1 + const org = orgs[idx] + if (!org) throw new Error('Invalid selection') + + const res = await fetch(`${tolgeeUrl}/v2/organizations/${org.id}/apps`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': pat }, + body: JSON.stringify({ manifestUrl }), + }) + if (!res.ok) { + throw new Error(`Register failed (${res.status}): ${await res.text()}`) + } + const data = (await res.json()) as { + id: number + clientId: string + clientSecret: string + webhookSecret: string + } + return { + tolgeeUrl, + organizationId: org.id, + installId: data.id, + clientId: data.clientId, + clientSecret: data.clientSecret, + webhookSecret: data.webhookSecret, + } +} + +const main = async (): Promise => { + if (existsSync(INSTALL_FILE)) { + console.log( + `Install record already exists at ${INSTALL_FILE}. Delete it to re-register, ` + + 'or just run `npm run dev` to keep using the existing install.' + ) + return + } + + const tolgeeUrl = (process.env.TOLGEE_URL ?? 'https://app.tolgee.io').replace( + /\/$/, + '' + ) + const manifestUrl = await resolveManifestUrl(tolgeeUrl) + await verifyManifestReachable(manifestUrl) + + const state = patFlag + ? await patRegister(tolgeeUrl, manifestUrl, patFlag) + : await browserRegister(tolgeeUrl, manifestUrl) + + writeJson(INSTALL_FILE, state) + console.log( + `\nRegistered. Install ${state.installId} for org ${state.organizationId}. ` + + `Saved to ${INSTALL_FILE}.` + ) +} + +main().catch((err) => { + console.error('register failed:', err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/apps/example-apps/dev-plugin/server/config.ts b/apps/example-apps/dev-plugin/server/config.ts new file mode 100644 index 00000000000..0bdaa0abfb8 --- /dev/null +++ b/apps/example-apps/dev-plugin/server/config.ts @@ -0,0 +1,34 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +/** Vite dev server port. Tunnel + iframe URLs use this. */ +export const VITE_PORT = Number(process.env.VITE_PORT ?? 5180) + +/** Express server port. Manifest/webhook/decorator routes live here. */ +export const SERVER_PORT = Number( + process.env.SERVER_PORT ?? process.env.PORT ?? 5181 +) + +export const LOCAL_BASE_URL = `http://localhost:${VITE_PORT}` + +export const WEBHOOK_SECRET = process.env.TOLGEE_WEBHOOK_SECRET ?? null +export const TOLGEE_URL = + process.env.TOLGEE_URL ?? 'https://app.tolgee.io' + +/** + * Tunnel state file written by scripts/dev.ts when the Cloudflare quick + * tunnel comes up. The manifest route reads `baseUrl` from this file on + * every request so manifest URLs always reflect the currently-active + * tunnel without restarting the server. + */ +export const TUNNEL_STATE_DIR = join(__dirname, '..', '.tolgee-dev') +export const TUNNEL_STATE_FILE = join(TUNNEL_STATE_DIR, 'tunnel.json') + +/** + * dev-plugin-only: local data store for the emoji + state PoC routes. + */ +export const DATA_DIR = join(__dirname, '.data') +export const DATA_FILE = join(DATA_DIR, 'data.json') +export const ACTIVITY_RETENTION_DAYS = 14 diff --git a/apps/example-apps/dev-plugin/server/cors.ts b/apps/example-apps/dev-plugin/server/cors.ts new file mode 100644 index 00000000000..5c966eb6b22 --- /dev/null +++ b/apps/example-apps/dev-plugin/server/cors.ts @@ -0,0 +1,14 @@ +import type { RequestHandler } from 'express' + +/** + * Permissive CORS for the dev plugin: allows any origin to call + * /decorators and /api/* with bearer tokens. Production plugins should + * restrict the allowed origin to the Tolgee webapp. + */ +export const cors: RequestHandler = (_req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type') + res.setHeader('Access-Control-Max-Age', '600') + next() +} diff --git a/apps/example-apps/dev-plugin/server/index.ts b/apps/example-apps/dev-plugin/server/index.ts new file mode 100644 index 00000000000..f3e7eb96915 --- /dev/null +++ b/apps/example-apps/dev-plugin/server/index.ts @@ -0,0 +1,34 @@ +import express, { json } from 'express' +import { SERVER_PORT, WEBHOOK_SECRET } from './config' +import { cors } from './cors' +import { registerWebhookRoute } from './routes/webhook' +import { registerDecoratorsRoute } from './routes/decorators' +import { registerStateRoute } from './routes/state' +import { registerEmojiRoute } from './routes/emoji' +import { registerManifestRoute } from './routes/manifest' + +const app = express() + +// /webhook is registered BEFORE the json() middleware: HMAC signature +// verification needs the raw POST body, not a parsed object. +registerWebhookRoute(app) + +app.use(cors) +app.options('*', (_req, res) => { + res.status(204).end() +}) +app.use(json()) + +registerManifestRoute(app) +registerDecoratorsRoute(app) +registerStateRoute(app) +registerEmojiRoute(app) + +app.listen(SERVER_PORT, () => { + console.log(`dev-plugin server listening on http://localhost:${SERVER_PORT}`) + if (!WEBHOOK_SECRET) { + console.warn( + 'TOLGEE_WEBHOOK_SECRET is not set; webhook signatures will not be verified.' + ) + } +}) diff --git a/apps/example-apps/dev-plugin/server/manifest.template.json b/apps/example-apps/dev-plugin/server/manifest.template.json new file mode 100644 index 00000000000..8ca8f53c33a --- /dev/null +++ b/apps/example-apps/dev-plugin/server/manifest.template.json @@ -0,0 +1,125 @@ +{ + "id": "tolgee-dev-plugin", + "name": "Tolgee Dev Plugin", + "version": "0.1.0", + "baseUrl": "__BASE_URL__", + "decoratorsUrl": "__BASE_URL__/decorators", + "scopes": ["translations.view", "keys.view"], + "webhooks": { + "events": [ + "SET_TRANSLATIONS", + "SET_TRANSLATION_STATE", + "CREATE_KEY", + "KEY_DELETE" + ], + "url": "__BASE_URL__/webhook" + }, + "modules": { + "project-dashboard-page": [ + { + "key": "hello", + "title": "Hello World", + "icon": "👋", + "entry": "/" + } + ], + "translation-tools-panel": [ + { + "key": "activity", + "title": "Activity", + "icon": "BarChart01", + "entry": "/tools-panel" + } + ], + "translation-tools-panel-empty": [ + { + "key": "languages", + "title": "Languages", + "icon": "Globe01", + "entry": "/tools-panel-empty" + } + ], + "key-edit-tab": [ + { + "key": "audit", + "title": "Audit", + "icon": "Shield01", + "entry": "/key-edit-tab/audit" + } + ], + "key-action": [ + { + "key": "view-source", + "type": "link", + "icon": "Link04", + "tooltip": "View source", + "urlTemplate": "https://example.com/source/{keyName}", + "visibility": "on-hover" + }, + { + "key": "open-audit", + "type": "tab", + "icon": "Shield01", + "tooltip": "Audit info", + "dynamic": true, + "tabKey": "audit" + } + ], + "translation-action": [ + { + "key": "show-activity", + "type": "panel", + "icon": "BarChart01", + "tooltip": "Show activity", + "dynamic": true, + "panelKey": "activity", + "visibility": "always" + } + ], + "modal": [ + { + "key": "explain", + "title": "Explain this key", + "icon": "Lightbulb01", + "entry": "/modal/explain", + "width": 600, + "height": 360 + } + ], + "bulk-action": [ + { + "key": "bulk-explain", + "title": "Explain selection", + "icon": "Target01", + "type": "modal", + "modalKey": "explain" + } + ], + "translations-toolbar-action": [ + { + "key": "toolbar-explain", + "title": "Explain view", + "icon": "Download01", + "type": "modal", + "modalKey": "explain" + } + ], + "project-menu-action": [ + { + "key": "menu-explain", + "title": "Configure", + "icon": "Settings01", + "type": "modal", + "modalKey": "explain" + } + ], + "shortcut": [ + { + "key": "shortcut-explain", + "combination": "Mod+Shift+E", + "type": "modal", + "modalKey": "explain" + } + ] + } +} diff --git a/apps/example-apps/dev-plugin/server/manifest.ts b/apps/example-apps/dev-plugin/server/manifest.ts new file mode 100644 index 00000000000..6a14191a864 --- /dev/null +++ b/apps/example-apps/dev-plugin/server/manifest.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { LOCAL_BASE_URL, TUNNEL_STATE_FILE } from './config' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const TEMPLATE_PATH = join(__dirname, 'manifest.template.json') + +const PLACEHOLDER = '__BASE_URL__' + +/** + * Reads the tunnel state written by scripts/dev.ts and returns the + * public base URL the plugin is reachable at, or the localhost default + * when no tunnel is active. + */ +const readBaseUrl = (): string => { + try { + const raw = readFileSync(TUNNEL_STATE_FILE, 'utf8') + const parsed = JSON.parse(raw) as { baseUrl?: string } + if (typeof parsed.baseUrl === 'string' && parsed.baseUrl.length > 0) { + return parsed.baseUrl + } + } catch { + // file missing or unreadable — fall through to localhost default + } + return LOCAL_BASE_URL +} + +export const renderManifest = (): string => { + const template = readFileSync(TEMPLATE_PATH, 'utf8') + const baseUrl = readBaseUrl() + return template.replaceAll(PLACEHOLDER, baseUrl) +} diff --git a/apps/example-apps/dev-plugin/server/routes/decorators.ts b/apps/example-apps/dev-plugin/server/routes/decorators.ts new file mode 100644 index 00000000000..da2162cb9fa --- /dev/null +++ b/apps/example-apps/dev-plugin/server/routes/decorators.ts @@ -0,0 +1,75 @@ +import type { Express, Request, Response } from 'express' + +type DecoratorRequest = { + projectId?: number + keyIds?: number[] + languageTags?: string[] +} + +type DecoratorItem = { + keyId: number + languageTag?: string + actionKey: string + url?: string + count?: number + visibility?: 'always' | 'on-hover' +} + +export const registerDecoratorsRoute = (app: Express): void => { + app.post('/decorators', handleDecorators) +} + +const handleDecorators = (req: Request, res: Response): void => { + const { keyIds, languageTags } = parseRequest(req.body) + res.json({ items: buildItems(keyIds, languageTags) }) +} + +const parseRequest = ( + body: unknown +): { keyIds: number[]; languageTags: string[] } => { + const b = (body ?? {}) as DecoratorRequest + return { + keyIds: Array.isArray(b.keyIds) + ? b.keyIds.filter((id): id is number => typeof id === 'number') + : [], + languageTags: Array.isArray(b.languageTags) + ? b.languageTags.filter((t): t is string => typeof t === 'string') + : [], + } +} + +const buildItems = ( + keyIds: number[], + languageTags: string[] +): DecoratorItem[] => { + const items: DecoratorItem[] = [] + for (const keyId of keyIds) { + items.push(auditItem(keyId)) + for (const tag of languageTags) { + items.push(activityItem(keyId, tag)) + } + } + return items +} + +// Demo: every key gets the audit shortcut. +const auditItem = (keyId: number): DecoratorItem => ({ + keyId, + actionKey: 'open-audit', +}) + +// Demo: show-activity is emitted for every (keyId, languageTag) pair, +// with a synthetic count varying 0..6. count===0 hides the badge but +// keeps the icon; we also dynamically override the visibility to +// 'on-hover' so quiet cells declutter the row. +const activityItem = (keyId: number, languageTag: string): DecoratorItem => { + const count = (keyId * 3 + languageTag.charCodeAt(0)) % 7 + const item: DecoratorItem = { + keyId, + languageTag, + actionKey: 'show-activity', + count, + } + if (count === 0) item.visibility = 'on-hover' + return item +} diff --git a/apps/example-apps/dev-plugin/server/routes/emoji.ts b/apps/example-apps/dev-plugin/server/routes/emoji.ts new file mode 100644 index 00000000000..fea53e6cfb8 --- /dev/null +++ b/apps/example-apps/dev-plugin/server/routes/emoji.ts @@ -0,0 +1,40 @@ +import type { Express, Request, Response } from 'express' +import { store } from '../store' + +type SetEmojiRequest = { + translationId?: unknown + emoji?: unknown +} + +export const registerEmojiRoute = (app: Express): void => { + app.put('/api/emoji', handleSetEmoji) +} + +const handleSetEmoji = (req: Request, res: Response): void => { + const parsed = parseRequest(req.body) + if (!parsed.ok) { + res.status(400).json({ error: parsed.error }) + return + } + store.setEmoji(parsed.translationId, parsed.emoji) + res.json({ ok: true }) +} + +type ParsedSetEmoji = + | { ok: true; translationId: number; emoji: string | null } + | { ok: false; error: string } + +const parseRequest = (body: unknown): ParsedSetEmoji => { + const b = (body ?? {}) as SetEmojiRequest + if (typeof b.translationId !== 'number') { + return { ok: false, error: 'translationId must be a number' } + } + if (b.emoji != null && typeof b.emoji !== 'string') { + return { ok: false, error: 'emoji must be a string or null' } + } + return { + ok: true, + translationId: b.translationId, + emoji: typeof b.emoji === 'string' ? b.emoji : null, + } +} diff --git a/apps/example-apps/dev-plugin/server/routes/manifest.ts b/apps/example-apps/dev-plugin/server/routes/manifest.ts new file mode 100644 index 00000000000..3bbde6ca56d --- /dev/null +++ b/apps/example-apps/dev-plugin/server/routes/manifest.ts @@ -0,0 +1,8 @@ +import type { Express, Request, Response } from 'express' +import { renderManifest } from '../manifest' + +export const registerManifestRoute = (app: Express): void => { + app.get('/manifest.json', (_req: Request, res: Response) => { + res.type('application/json').send(renderManifest()) + }) +} diff --git a/apps/example-apps/dev-plugin/server/routes/state.ts b/apps/example-apps/dev-plugin/server/routes/state.ts new file mode 100644 index 00000000000..9d04ffbbf69 --- /dev/null +++ b/apps/example-apps/dev-plugin/server/routes/state.ts @@ -0,0 +1,17 @@ +import type { Express, Request, Response } from 'express' +import { store } from '../store' + +export const registerStateRoute = (app: Express): void => { + app.get('/api/state', handleGetState) +} + +const handleGetState = (req: Request, res: Response): void => { + const ids = parseIds(req.query.ids) + res.json(store.getState(ids)) +} + +const parseIds = (raw: unknown): string[] => + String(raw ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) diff --git a/apps/example-apps/dev-plugin/server/routes/webhook.ts b/apps/example-apps/dev-plugin/server/routes/webhook.ts new file mode 100644 index 00000000000..110ffaa8b00 --- /dev/null +++ b/apps/example-apps/dev-plugin/server/routes/webhook.ts @@ -0,0 +1,69 @@ +import express, { type Express, type Request, type Response } from 'express' +import { + onWebhook, + verifyWebhookSignature, + type AppWebhookPayload, + type WebhookPayloadFor, +} from '@tolgee/apps-sdk/server' +import { WEBHOOK_SECRET } from '../config' +import { store } from '../store' + +export const registerWebhookRoute = (app: Express): void => { + // express.text parses the body verbatim — needed because the HMAC + // signature is computed over the raw bytes Tolgee sent. + app.post( + '/webhook', + express.text({ type: 'application/json', limit: '5mb' }), + handleWebhook + ) +} + +const handleWebhook = async (req: Request, res: Response): Promise => { + const raw = typeof req.body === 'string' ? req.body : '' + if (!(await verifyOrAllow(req, raw))) { + res.status(401).json({ error: 'invalid signature' }) + return + } + const payload = parsePayload(raw) + if (!payload) { + res.status(400).json({ error: 'invalid json' }) + return + } + onWebhook(payload, 'SET_TRANSLATIONS', recordTranslationActivity) + res.status(204).end() +} + +const verifyOrAllow = async (req: Request, raw: string): Promise => { + if (!WEBHOOK_SECRET) { + console.warn( + 'TOLGEE_WEBHOOK_SECRET is not set — accepting webhook without verification.' + ) + return true + } + return verifyWebhookSignature({ + header: req.header('Tolgee-Signature'), + rawBody: raw, + secret: WEBHOOK_SECRET, + }) +} + +const parsePayload = (raw: string): AppWebhookPayload | null => { + try { + return JSON.parse(raw) as AppWebhookPayload + } catch { + return null + } +} + +const recordTranslationActivity = ( + payload: WebhookPayloadFor<'SET_TRANSLATIONS'> +): void => { + const occurredAt = isoTimestamp(payload.activityData?.timestamp) + const translations = payload.activityData?.modifiedEntities?.Translation ?? [] + store.recordTranslationEdits(translations, occurredAt) +} + +const isoTimestamp = (ts: number | undefined): string => + typeof ts === 'number' + ? new Date(ts).toISOString() + : new Date().toISOString() diff --git a/apps/example-apps/dev-plugin/server/store.ts b/apps/example-apps/dev-plugin/server/store.ts new file mode 100644 index 00000000000..de37fbba567 --- /dev/null +++ b/apps/example-apps/dev-plugin/server/store.ts @@ -0,0 +1,93 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { ACTIVITY_RETENTION_DAYS, DATA_DIR, DATA_FILE } from './config' + +export type Data = { + emojis: Record + updatedAt: Record + /** ISO timestamps of every translation-update webhook, keyed by translation id. */ + events: Record +} + +export type StateSubset = Data + +const EMPTY: Data = { emojis: {}, updatedAt: {}, events: {} } + +const load = (): Data => { + if (!existsSync(DATA_FILE)) return clone(EMPTY) + try { + return { ...clone(EMPTY), ...JSON.parse(readFileSync(DATA_FILE, 'utf8')) } + } catch (err) { + console.warn('Failed to parse data.json — starting empty:', err) + return clone(EMPTY) + } +} + +const clone = (d: Data): Data => ({ + emojis: { ...d.emojis }, + updatedAt: { ...d.updatedAt }, + events: { ...d.events }, +}) + +const data: Data = load() + +const persist = () => { + ensureDataDir() + writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)) +} + +const ensureDataDir = () => { + if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }) +} + +const cutoffMs = (): number => + Date.now() - ACTIVITY_RETENTION_DAYS * 24 * 60 * 60 * 1000 + +const recordOne = (translationId: number, occurredAt: string) => { + const id = String(translationId) + data.updatedAt[id] = occurredAt + const log = data.events[id] ?? [] + log.push(occurredAt) + data.events[id] = log.filter((iso) => Date.parse(iso) >= cutoffMs()) +} + +/** + * Records edits for any translation entity that carries a numeric id. + * Returns the number of recorded entities so callers can short-circuit. + */ +const recordTranslationEdits = ( + translations: ReadonlyArray<{ entityId?: number }>, + occurredAt: string +): number => { + let touched = 0 + for (const entity of translations) { + if (typeof entity.entityId === 'number') { + recordOne(entity.entityId, occurredAt) + touched++ + } + } + if (touched > 0) persist() + return touched +} + +const setEmoji = (translationId: number, emoji: string | null) => { + const id = String(translationId) + if (emoji == null || emoji === '') delete data.emojis[id] + else data.emojis[id] = emoji + persist() +} + +const getState = (ids: ReadonlyArray): Data => { + const out: Data = { emojis: {}, updatedAt: {}, events: {} } + for (const id of ids) { + if (data.emojis[id]) out.emojis[id] = data.emojis[id] + if (data.updatedAt[id]) out.updatedAt[id] = data.updatedAt[id] + if (data.events[id]?.length) out.events[id] = data.events[id] + } + return out +} + +export const store = { + recordTranslationEdits, + setEmoji, + getState, +} diff --git a/apps/example-apps/dev-plugin/src/App.css b/apps/example-apps/dev-plugin/src/App.css new file mode 100644 index 00000000000..f10f86d7485 --- /dev/null +++ b/apps/example-apps/dev-plugin/src/App.css @@ -0,0 +1,201 @@ +/* All colors come from the example's design tokens in index.css, which resolve + * to Tolgee's theme via applyTolgeeTheme(ctx.theme). */ + +.plugin-page { + font-family: system-ui, sans-serif; + padding: 1.5rem; + max-width: 900px; + color: var(--text-h); +} + +.plugin-page h1 { + margin: 0 0 0.5rem; + font-size: 1.75rem; +} + +.plugin-page p { + margin: 0 0 1rem; + color: var(--text); +} + +.plugin-page table.keys { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; +} + +.plugin-page table.keys th, +.plugin-page table.keys td { + padding: 8px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +.plugin-page table.keys th { + font-size: 0.85rem; + text-transform: uppercase; + color: var(--text); + background: var(--code-bg); +} + +.plugin-page code { + background: var(--code-bg); + padding: 1px 4px; + border-radius: 3px; + font-size: 0.85em; +} + +.plugin-page code.ns { + margin-right: 6px; + color: var(--text); +} + +.plugin-page code.lang { + margin-right: 6px; + color: var(--text); +} + +.plugin-page .emoji-row { + display: flex; + gap: 2px; + align-items: center; +} + +.plugin-page .emoji-btn { + font-size: 1.1em; + line-height: 1; + border: 1px solid transparent; + background: transparent; + border-radius: 4px; + padding: 2px 4px; + cursor: pointer; + transition: + background 0.1s, + border-color 0.1s; +} + +.plugin-page .emoji-btn:hover { + background: var(--code-bg); +} + +.plugin-page .emoji-btn.selected { + background: var(--accent-bg); + border-color: var(--accent-border); +} + +.plugin-page .emoji-btn.clear { + font-size: 0.95em; + color: var(--text); +} + +.plugin-page .emoji-btn[disabled] { + opacity: 0.4; + cursor: not-allowed; +} + +.plugin-page .updated { + font-size: 0.7rem; + color: var(--text); + margin-top: 2px; +} + +.plugin-page th .th-hint { + display: block; + font-size: 0.65rem; + font-weight: 400; + color: var(--text); + text-transform: none; + letter-spacing: 0; +} + +.plugin-page .activity-cell { + vertical-align: middle; +} + +.plugin-page .activity-count { + font-size: 0.7rem; + color: var(--text); + margin-top: 2px; +} + +.tools-panel { + font-family: system-ui, sans-serif; + padding: 12px 14px; + display: grid; + gap: 8px; + background: var(--tg-color-background-paper, transparent); + color: var(--text-h); +} + +.tools-panel-row { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 10px; + align-items: center; +} + +.tools-panel-label { + font-size: 0.85rem; + color: var(--text); +} + +.tools-panel-count { + font-size: 0.75rem; + color: var(--text); +} + +.tools-panel-hint { + font-size: 0.7rem; + color: var(--text); +} + +.modal-explain { + font-family: system-ui, sans-serif; + padding: 16px 20px; + display: grid; + gap: 12px; + background: var(--tg-color-background, transparent); + color: var(--text-h); +} + +.modal-explain h2 { + margin: 0; + font-size: 1.1rem; +} + +.modal-explain table.kv { + border-collapse: collapse; + font-size: 0.85rem; +} + +.modal-explain table.kv th { + text-align: left; + font-weight: 500; + padding: 4px 12px 4px 0; + color: var(--text); + vertical-align: top; +} + +.modal-explain table.kv td { + padding: 4px 0; +} + +.modal-explain .modal-actions { + display: flex; + justify-content: flex-end; +} + +.modal-explain .modal-actions button { + padding: 6px 14px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--code-bg); + color: var(--text-h); + cursor: pointer; + font-size: 0.85rem; +} + +.modal-explain .modal-actions button:hover { + background: var(--accent-bg); +} diff --git a/apps/example-apps/dev-plugin/src/App.tsx b/apps/example-apps/dev-plugin/src/App.tsx new file mode 100644 index 00000000000..ca3bf8beb1b --- /dev/null +++ b/apps/example-apps/dev-plugin/src/App.tsx @@ -0,0 +1,363 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { type components } from '@tginternal/client' +import { + applyTolgeeTheme, + createTolgeeApp, + createTolgeeAppClient, + type TolgeeAppContext, +} from '@tolgee/apps-sdk/browser' +import './App.css' + +type KeyRow = components['schemas']['KeyWithTranslationsModel'] +type LanguageModel = components['schemas']['LanguageModel'] + +type PluginState = { + emojis: Record + updatedAt: Record + events: Record +} + +const EMOJI_PALETTE = ['🎉', '🔥', '❓', '✅', '❌'] as const +const SPARKLINE_DAYS = 14 +const SPARKLINE_WIDTH = 84 +const SPARKLINE_HEIGHT = 18 +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +const bucketEvents = (timestamps: string[], days: number): number[] => { + const now = Date.now() + const startOfTodayUtc = now - (now % MS_PER_DAY) + const buckets = new Array(days).fill(0) + for (const iso of timestamps) { + const t = Date.parse(iso) + if (Number.isNaN(t)) continue + const startOfEventDayUtc = t - (t % MS_PER_DAY) + const dayOffset = Math.round( + (startOfTodayUtc - startOfEventDayUtc) / MS_PER_DAY + ) + if (dayOffset < 0 || dayOffset >= days) continue + buckets[days - 1 - dayOffset]++ + } + return buckets +} + +const Sparkline = ({ buckets }: { buckets: number[] }) => { + const max = Math.max(1, ...buckets) + const barWidth = SPARKLINE_WIDTH / buckets.length + return ( + sum + c, + 0 + )} edits over ${buckets.length} days`} + > + {buckets.map((count, i) => { + const h = count === 0 ? 1 : (count / max) * (SPARKLINE_HEIGHT - 1) + return ( + + ) + })} + + ) +} + +const formatRelative = (iso: string): string => { + const t = new Date(iso).getTime() + if (Number.isNaN(t)) return '' + const diff = Date.now() - t + const sec = Math.round(diff / 1000) + if (sec < 60) return `${sec}s ago` + const min = Math.round(sec / 60) + if (min < 60) return `${min}m ago` + const hr = Math.round(min / 60) + if (hr < 24) return `${hr}h ago` + const day = Math.round(hr / 24) + return `${day}d ago` +} + +function App() { + const [context, setContext] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [keys, setKeys] = useState([]) + const [languages, setLanguages] = useState([]) + const [projectName, setProjectName] = useState(null) + const [pluginState, setPluginState] = useState({ + emojis: {}, + updatedAt: {}, + events: {}, + }) + + useEffect(() => { + const app = createTolgeeApp() + app.context.then(setContext) + const offTheme = app.onThemeChanged(applyTolgeeTheme) + return () => { + offTheme() + app.dispose() + } + }, []) + + useEffect(() => { + if (!context) return + + const client = createTolgeeAppClient(context) + + let cancelled = false + ;(async () => { + setLoading(true) + setError(null) + try { + const projectResp = await client.GET('/v2/projects/{projectId}', { + params: { path: { projectId: context.projectId } }, + }) + if (projectResp.error) { + throw new Error( + `Project lookup failed: ${JSON.stringify(projectResp.error).slice( + 0, + 240 + )}` + ) + } + if (cancelled) return + if (projectResp.data) setProjectName(projectResp.data.name) + + const keysResp = await client.GET( + '/v2/projects/{projectId}/translations', + { + params: { + path: { projectId: context.projectId }, + query: { size: 20 }, + }, + } + ) + if (cancelled) return + if (keysResp.error) { + throw new Error( + `Translations lookup failed: ${JSON.stringify(keysResp.error).slice( + 0, + 240 + )}` + ) + } + const loadedKeys = keysResp.data?._embedded?.keys ?? [] + const loadedLanguages = keysResp.data?.selectedLanguages ?? [] + setKeys(loadedKeys) + setLanguages(loadedLanguages) + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : String(e)) + } + } finally { + if (!cancelled) setLoading(false) + } + })() + + return () => { + cancelled = true + } + }, [context]) + + const noteTranslationIdsKey = useMemo(() => { + const ids: number[] = [] + for (const row of keys) { + for (const t of Object.values(row.translations)) { + if (t?.id != null) ids.push(t.id) + } + } + return ids.join(',') + }, [keys]) + + useEffect(() => { + if (!noteTranslationIdsKey) return + const ctrl = new AbortController() + fetch(`/api/state?ids=${noteTranslationIdsKey}`, { + signal: ctrl.signal, + }) + .then((r) => (r.ok ? r.json() : null)) + .then((data: PluginState | null) => { + if (data) setPluginState(data) + }) + .catch((err) => { + if (err.name !== 'AbortError') console.warn('plugin state fetch:', err) + }) + return () => ctrl.abort() + }, [noteTranslationIdsKey]) + + const noteTranslationIdFor = useCallback( + (row: KeyRow): number | null => { + for (const lang of languages) { + const t = row.translations[lang.tag] + if (t?.id != null) return t.id + } + return null + }, + [languages] + ) + + const setEmoji = useCallback( + async (translationId: number, emoji: string | null) => { + setPluginState((prev) => { + const emojis = { ...prev.emojis } + if (emoji) emojis[translationId] = emoji + else delete emojis[translationId] + return { ...prev, emojis } + }) + try { + await fetch('/api/emoji', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ translationId, emoji }), + }) + } catch (err) { + console.warn('save emoji:', err) + } + }, + [] + ) + + return ( +
+

+ Hello World 👋 + {projectName && ( + + from {projectName} + + )} +

+ + {!context &&

Waiting for context from the host…

} + {context && loading &&

Loading…

} + {error && ( +

+ {error} +

+ )} + + {context && !loading && !error && ( + <> +

+ This is the Tolgee dev plugin. It used a signed app token to fetch + the first {keys.length} keys in this project via the REST API. +

+ + {keys.length === 0 ? ( +

No keys in this project yet.

+ ) : ( + + + + + + + {languages.map((lang) => ( + + ))} + + + + {keys.map((row) => { + const noteId = noteTranslationIdFor(row) + const currentEmoji = + noteId != null + ? pluginState.emojis[String(noteId)] ?? null + : null + const lastUpdated = + noteId != null + ? pluginState.updatedAt[String(noteId)] ?? null + : null + const rowEvents: string[] = [] + for (const t of Object.values(row.translations)) { + if (t?.id != null) { + const log = pluginState.events[String(t.id)] + if (log) rowEvents.push(...log) + } + } + const buckets = bucketEvents(rowEvents, SPARKLINE_DAYS) + const totalEdits = buckets.reduce((sum, c) => sum + c, 0) + return ( + + + + + {languages.map((lang) => { + const t = row.translations[lang.tag] + return ( + + ) + })} + + ) + })} + +
KeyNote + Activity + last {SPARKLINE_DAYS}d + + {lang.tag} {lang.name} +
+ {row.keyNamespace && ( + {row.keyNamespace} + )} + {row.keyName} + +
+ {EMOJI_PALETTE.map((e) => ( + + ))} + +
+
+ {lastUpdated + ? `Updated ${formatRelative(lastUpdated)}` + : '—'} +
+
+ +
+ {totalEdits} {totalEdits === 1 ? 'edit' : 'edits'} +
+
{t?.text ?? }
+ )} + + )} +
+ ) +} + +export default App diff --git a/apps/example-apps/dev-plugin/src/KeyEditTab.tsx b/apps/example-apps/dev-plugin/src/KeyEditTab.tsx new file mode 100644 index 00000000000..0489627aa65 --- /dev/null +++ b/apps/example-apps/dev-plugin/src/KeyEditTab.tsx @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { type components } from '@tginternal/client' +import { + applyTolgeeTheme, + createTolgeeApp, + createTolgeeAppClient, + type TolgeeAppContext, + type TolgeeAppSelection, +} from '@tolgee/apps-sdk/browser' +import './App.css' + +type KeyRow = components['schemas']['KeyWithTranslationsModel'] +type LanguageModel = components['schemas']['LanguageModel'] + +type PluginState = { + emojis: Record + updatedAt: Record + events: Record +} + +const EMPTY_SELECTION: TolgeeAppSelection = {} + +const formatRelative = (iso: string): string => { + const t = new Date(iso).getTime() + if (Number.isNaN(t)) return '' + const diff = Date.now() - t + const sec = Math.round(diff / 1000) + if (sec < 60) return `${sec}s ago` + const min = Math.round(sec / 60) + if (min < 60) return `${min}m ago` + const hr = Math.round(min / 60) + if (hr < 24) return `${hr}h ago` + const day = Math.round(hr / 24) + return `${day}d ago` +} + +function KeyEditTab() { + const [context, setContext] = useState(null) + const [selection, setSelection] = useState(EMPTY_SELECTION) + const [row, setRow] = useState(null) + const [languages, setLanguages] = useState([]) + const [pluginState, setPluginState] = useState({ + emojis: {}, + updatedAt: {}, + events: {}, + }) + const [error, setError] = useState(null) + + useEffect(() => { + const app = createTolgeeApp() + app.context.then((ctx) => { + setContext(ctx) + setSelection(ctx.selection) + }) + const off = app.onSelectionChanged(setSelection) + const offTheme = app.onThemeChanged(applyTolgeeTheme) + return () => { + off() + offTheme() + app.dispose() + } + }, []) + + useEffect(() => { + if (!context || selection.keyId == null) return + const client = createTolgeeAppClient(context) + let cancelled = false + ;(async () => { + try { + const resp = await client.GET( + '/v2/projects/{projectId}/translations', + { + params: { + path: { projectId: context.projectId }, + query: { filterKeyId: [selection.keyId!], size: 1 }, + }, + } + ) + if (cancelled) return + if (resp.error) { + setError(`Translations lookup failed: ${JSON.stringify(resp.error).slice(0, 200)}`) + return + } + setRow(resp.data?._embedded?.keys?.[0] ?? null) + setLanguages(resp.data?.selectedLanguages ?? []) + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)) + } + })() + return () => { + cancelled = true + } + }, [context, selection.keyId]) + + const translationIds = useMemo(() => { + if (!row) return [] as number[] + const ids: number[] = [] + for (const t of Object.values(row.translations)) { + if (t?.id != null) ids.push(t.id) + } + return ids + }, [row]) + + const idsKey = translationIds.join(',') + + useEffect(() => { + if (!idsKey) { + setPluginState({ emojis: {}, updatedAt: {}, events: {} }) + return + } + const ctrl = new AbortController() + fetch(`/api/state?ids=${idsKey}`, { signal: ctrl.signal }) + .then((r) => (r.ok ? r.json() : null)) + .then((data: PluginState | null) => { + if (data) setPluginState(data) + }) + .catch((err) => { + if (err.name !== 'AbortError') console.warn('plugin state fetch:', err) + }) + return () => ctrl.abort() + }, [idsKey]) + + const sorted = useCallback( + (langs: LanguageModel[]): LanguageModel[] => + [...langs].sort((a, b) => a.tag.localeCompare(b.tag)), + [] + ) + + if (!context) { + return ( +
+

Waiting for context from the host…

+
+ ) + } + + if (error) { + return ( +
+

{error}

+
+ ) + } + + if (!row || selection.keyId == null) { + return ( +
+

No key selected.

+
+ ) + } + + return ( +
+

+ 🛡️ Audit for{' '} + {row.keyNamespace && {row.keyNamespace}} + {row.keyName} +

+ + + + + + + + + + {sorted(languages).map((lang) => { + const t = row.translations[lang.tag] + const id = t?.id != null ? String(t.id) : null + const lastEdit = id ? pluginState.updatedAt[id] : null + const events = id ? pluginState.events[id]?.length ?? 0 : 0 + return ( + + + + + + ) + })} + +
LanguageLast editEdits (14d)
+ {lang.tag} {lang.name} + {lastEdit ? formatRelative(lastEdit) : }{events}
+
+ ) +} + +export default KeyEditTab diff --git a/apps/example-apps/dev-plugin/src/ModalExplain.tsx b/apps/example-apps/dev-plugin/src/ModalExplain.tsx new file mode 100644 index 00000000000..48660f86a20 --- /dev/null +++ b/apps/example-apps/dev-plugin/src/ModalExplain.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState } from 'react' +import { + applyTolgeeTheme, + createTolgeeApp, + type TolgeeApp, + type TolgeeAppContext, +} from '@tolgee/apps-sdk/browser' +import './App.css' + +function ModalExplain() { + const [context, setContext] = useState(null) + const appRef = useRef(null) + + useEffect(() => { + const app = createTolgeeApp() + appRef.current = app + app.context.then(setContext) + const offTheme = app.onThemeChanged(applyTolgeeTheme) + return () => { + offTheme() + app.dispose() + appRef.current = null + } + }, []) + + const close = () => appRef.current?.close() + + return ( +
+

💡 Plugin modal — init context

+ {!context ? ( +

Waiting for host context…

+ ) : ( + + + {( + [ + ['projectId', context.projectId], + ['organizationId', context.organizationId], + ['keyId', context.selection.keyId], + ['languageId', context.selection.languageId], + ['languageTag', context.selection.languageTag], + ['translationId', context.selection.translationId], + ['selectedKeyIds', formatList(context.extra.selectedKeyIds)], + ] as Array<[string, unknown]> + ) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => ( + + + + + ))} + +
{key} + {String(value)} +
+ )} +
+ +
+
+ ) +} + +const formatList = (value: unknown): string | undefined => { + return Array.isArray(value) ? value.join(', ') : undefined +} + +export default ModalExplain diff --git a/apps/example-apps/dev-plugin/src/ToolsPanel.tsx b/apps/example-apps/dev-plugin/src/ToolsPanel.tsx new file mode 100644 index 00000000000..4ad547410fc --- /dev/null +++ b/apps/example-apps/dev-plugin/src/ToolsPanel.tsx @@ -0,0 +1,154 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + applyTolgeeTheme, + createTolgeeApp, + type TolgeeApp, + type TolgeeAppSelection, +} from '@tolgee/apps-sdk/browser' +import './App.css' + +type PluginState = { + emojis: Record + updatedAt: Record + events: Record +} + +const EMPTY_SELECTION: TolgeeAppSelection = {} + +const SPARKLINE_DAYS = 14 +const SPARKLINE_WIDTH = 168 +const SPARKLINE_HEIGHT = 26 +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +const bucketEvents = (timestamps: string[], days: number): number[] => { + const now = Date.now() + const startOfTodayUtc = now - (now % MS_PER_DAY) + const buckets = new Array(days).fill(0) + for (const iso of timestamps) { + const t = Date.parse(iso) + if (Number.isNaN(t)) continue + const startOfEventDayUtc = t - (t % MS_PER_DAY) + const dayOffset = Math.round( + (startOfTodayUtc - startOfEventDayUtc) / MS_PER_DAY + ) + if (dayOffset < 0 || dayOffset >= days) continue + buckets[days - 1 - dayOffset]++ + } + return buckets +} + +const Sparkline = ({ buckets }: { buckets: number[] }) => { + const max = Math.max(1, ...buckets) + const barWidth = SPARKLINE_WIDTH / buckets.length + return ( + sum + c, + 0 + )} edits over ${buckets.length} days`} + > + {buckets.map((count, i) => { + const h = count === 0 ? 1 : (count / max) * (SPARKLINE_HEIGHT - 1) + return ( + + ) + })} + + ) +} + +function ToolsPanel() { + const [selection, setSelection] = useState(EMPTY_SELECTION) + const [pluginState, setPluginState] = useState(null) + const containerRef = useRef(null) + const appRef = useRef(null) + + useEffect(() => { + const app = createTolgeeApp() + appRef.current = app + app.context.then((ctx) => setSelection(ctx.selection)) + const off = app.onSelectionChanged(setSelection) + const offTheme = app.onThemeChanged(applyTolgeeTheme) + return () => { + off() + offTheme() + app.dispose() + appRef.current = null + } + }, []) + + useEffect(() => { + if (selection.translationId == null) { + setPluginState(null) + return + } + const ctrl = new AbortController() + fetch(`/api/state?ids=${selection.translationId}`, { + signal: ctrl.signal, + }) + .then((r) => (r.ok ? r.json() : null)) + .then((data: PluginState | null) => { + if (data) setPluginState(data) + }) + .catch((err) => { + if (err.name !== 'AbortError') console.warn('plugin state fetch:', err) + }) + return () => ctrl.abort() + }, [selection.translationId]) + + const buckets = useMemo(() => { + if (selection.translationId == null || !pluginState) { + return new Array(SPARKLINE_DAYS).fill(0) + } + const events = pluginState.events[String(selection.translationId)] ?? [] + return bucketEvents(events, SPARKLINE_DAYS) + }, [selection.translationId, pluginState]) + + const totalEdits = buckets.reduce((sum, c) => sum + c, 0) + + useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver(() => { + appRef.current?.resize(el.scrollHeight) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + return ( +
+
+
Activity (last {SPARKLINE_DAYS}d)
+ +
+ {totalEdits} {totalEdits === 1 ? 'edit' : 'edits'} +
+
+
+ {selection.translationId == null + ? 'Focus a translation cell to see its activity.' + : `translationId=${selection.translationId} · lang=${ + selection.languageTag ?? '—' + }`} +
+
+ ) +} + +export default ToolsPanel diff --git a/apps/example-apps/dev-plugin/src/ToolsPanelEmpty.tsx b/apps/example-apps/dev-plugin/src/ToolsPanelEmpty.tsx new file mode 100644 index 00000000000..a6048ca4d80 --- /dev/null +++ b/apps/example-apps/dev-plugin/src/ToolsPanelEmpty.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, useState } from 'react' +import { type components } from '@tginternal/client' +import { + applyTolgeeTheme, + createTolgeeApp, + createTolgeeAppClient, + type TolgeeApp, + type TolgeeAppContext, + type TolgeeAppTheme, +} from '@tolgee/apps-sdk/browser' +import './App.css' + +type LanguageModel = components['schemas']['LanguageModel'] + +function ToolsPanelEmpty() { + const [context, setContext] = useState(null) + const [selectedLanguages, setSelectedLanguages] = useState([]) + const [theme, setTheme] = useState(null) + const [languages, setLanguages] = useState(null) + const [error, setError] = useState(null) + const containerRef = useRef(null) + const appRef = useRef(null) + + useEffect(() => { + const app = createTolgeeApp() + appRef.current = app + app.context.then((ctx) => { + setContext(ctx) + setSelectedLanguages(ctx.selection.selectedLanguages ?? []) + }) + // Re-fires when the user changes the language selector. + const off = app.onSelectionChanged((s) => + setSelectedLanguages(s.selectedLanguages ?? []) + ) + // Fires once with the initial theme, then on every light/dark toggle. + const offTheme = app.onThemeChanged((t) => { + applyTolgeeTheme(t) + setTheme(t) + }) + return () => { + off() + offTheme() + app.dispose() + appRef.current = null + } + }, []) + + // Fetch the full language list once we have a context, then show the details + // (name, flag, base) for whichever tags are currently selected. + useEffect(() => { + if (!context) return + const client = createTolgeeAppClient(context) + let cancelled = false + ;(async () => { + setError(null) + const resp = await client.GET('/v2/projects/{projectId}/languages', { + params: { + path: { projectId: context.projectId }, + query: { size: 1000 }, + }, + }) + if (cancelled) return + if (resp.error) { + setError(JSON.stringify(resp.error).slice(0, 200)) + return + } + setLanguages(resp.data?._embedded?.languages ?? []) + })() + return () => { + cancelled = true + } + }, [context]) + + useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver(() => { + appRef.current?.resize(el.scrollHeight) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + const selectedSet = new Set(selectedLanguages) + const shown = (languages ?? []).filter((l) => selectedSet.has(l.tag)) + + const renderBody = () => { + if (error) return
Error: {error}
+ if (selectedLanguages.length === 0) + return
No languages selected.
+ if (languages == null) + return
Loading languages…
+ return ( +
    + {shown.map((l) => ( +
  • + {l.flagEmoji ?? '🏳️'} {l.name}{' '} + {l.tag} + {l.base ? ' · base' : ''} +
  • + ))} +
+ ) + } + + return ( +
+
Theme: {theme?.mode ?? '…'}
+ {theme && ( +
+ {Object.entries(theme.colors).map(([name, color]) => ( + + ))} +
+ )} +
+ Selected languages ({selectedLanguages.length}) +
+ {renderBody()} +
+ ) +} + +export default ToolsPanelEmpty diff --git a/apps/example-apps/dev-plugin/src/assets/hero.png b/apps/example-apps/dev-plugin/src/assets/hero.png new file mode 100644 index 00000000000..02251f4b956 Binary files /dev/null and b/apps/example-apps/dev-plugin/src/assets/hero.png differ diff --git a/apps/example-apps/dev-plugin/src/assets/react.svg b/apps/example-apps/dev-plugin/src/assets/react.svg new file mode 100644 index 00000000000..6c87de9bb33 --- /dev/null +++ b/apps/example-apps/dev-plugin/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/example-apps/dev-plugin/src/assets/vite.svg b/apps/example-apps/dev-plugin/src/assets/vite.svg new file mode 100644 index 00000000000..5101b674df3 --- /dev/null +++ b/apps/example-apps/dev-plugin/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/apps/example-apps/dev-plugin/src/index.css b/apps/example-apps/dev-plugin/src/index.css new file mode 100644 index 00000000000..53f3b67e8b5 --- /dev/null +++ b/apps/example-apps/dev-plugin/src/index.css @@ -0,0 +1,101 @@ +:root { + /* Driven by applyTolgeeTheme(ctx.theme) → follows Tolgee's light/dark. The + * hex values are only fallbacks for when the theme hasn't been applied. */ + --text: var(--tg-color-text-secondary, #6b6375); + --text-h: var(--tg-color-text, #08060d); + --bg: var(--tg-color-background, #fff); + --code-bg: var(--tg-color-background-paper, #f4f3ec); + --border: var(--tg-color-divider, #e4e2dd); + --accent: var(--tg-color-primary, #aa3bff); + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: var(--tg-color-divider, rgba(170, 59, 255, 0.5)); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: transparent; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +/* Tolgee's theme (via applyTolgeeTheme) drives dark mode, not the OS — so the + * tokens above already resolve to dark values in dark mode. Only the icon + * inversion needs an explicit dark hook. */ +[data-tg-theme='dark'] #social .button-icon { + filter: invert(1) brightness(2); +} + +#root { + width: 100%; + max-width: 100%; + margin: 0; + display: flex; + flex-direction: column; + box-sizing: border-box; + background: transparent; +} + +html, +body { + margin: 0; + background: var(--tg-color-background, transparent); + color: var(--text); +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} diff --git a/apps/example-apps/dev-plugin/src/main.tsx b/apps/example-apps/dev-plugin/src/main.tsx new file mode 100644 index 00000000000..b0abc0cca97 --- /dev/null +++ b/apps/example-apps/dev-plugin/src/main.tsx @@ -0,0 +1,26 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' +import ToolsPanel from './ToolsPanel.tsx' +import ToolsPanelEmpty from './ToolsPanelEmpty.tsx' +import KeyEditTab from './KeyEditTab.tsx' +import ModalExplain from './ModalExplain.tsx' + +const route = window.location.pathname.replace(/\/+$/, '') || '/' +const Root = + route === '/tools-panel' + ? ToolsPanel + : route === '/tools-panel-empty' + ? ToolsPanelEmpty + : route === '/key-edit-tab/audit' + ? KeyEditTab + : route === '/modal/explain' + ? ModalExplain + : App + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/apps/example-apps/dev-plugin/tsconfig.app.json b/apps/example-apps/dev-plugin/tsconfig.app.json new file mode 100644 index 00000000000..7f42e5f7cd2 --- /dev/null +++ b/apps/example-apps/dev-plugin/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/apps/example-apps/dev-plugin/tsconfig.json b/apps/example-apps/dev-plugin/tsconfig.json new file mode 100644 index 00000000000..1ffef600d95 --- /dev/null +++ b/apps/example-apps/dev-plugin/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/example-apps/dev-plugin/tsconfig.node.json b/apps/example-apps/dev-plugin/tsconfig.node.json new file mode 100644 index 00000000000..590b5c33ae5 --- /dev/null +++ b/apps/example-apps/dev-plugin/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts", "server/**/*.ts", "scripts/**/*.ts"] +} diff --git a/apps/example-apps/dev-plugin/vite.config.ts b/apps/example-apps/dev-plugin/vite.config.ts new file mode 100644 index 00000000000..d7a67318497 --- /dev/null +++ b/apps/example-apps/dev-plugin/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, loadEnv } from 'vite' +import react from '@vitejs/plugin-react' + +// Ports are env-overridable so multiple plugins can run side-by-side. +// Defaults match the historical 5180/5181 pair. +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const vitePort = Number(env.VITE_PORT ?? 5180) + const serverPort = Number(env.SERVER_PORT ?? 5181) + const serverTarget = `http://localhost:${serverPort}` + return { + plugins: [react()], + server: { + port: vitePort, + strictPort: true, + // Cloudflare quick tunnels expose a single port. Vite is the + // tunnel target; these proxies forward Tolgee → Express endpoints + // through the one public hostname. + proxy: { + '/manifest.json': serverTarget, + '/webhook': serverTarget, + '/decorators': serverTarget, + }, + // Quick tunnels rewrite the Host header to the trycloudflare.com + // hostname; Vite's default host check would reject those requests. + allowedHosts: true, + }, + } +}) diff --git a/apps/example-apps/glossary-keeper/.env.example b/apps/example-apps/glossary-keeper/.env.example new file mode 100644 index 00000000000..ba27a93a442 --- /dev/null +++ b/apps/example-apps/glossary-keeper/.env.example @@ -0,0 +1,12 @@ +# Anthropic API key — used for term detection (Haiku) and suggestion drafting (Sonnet). +ANTHROPIC_API_KEY= + +# Optional model overrides. +# ANTHROPIC_MODEL_DETECT=claude-haiku-4-5-20251001 +# ANTHROPIC_MODEL_SUGGEST=claude-sonnet-4-6 + +# Tolgee instance to register the app against (defaults to https://app.tolgee.io). +# TOLGEE_URL= + +# Populated by `npm run register` — you normally don't set these by hand. +# TOLGEE_WEBHOOK_SECRET= diff --git a/apps/example-apps/glossary-keeper/.gitignore b/apps/example-apps/glossary-keeper/.gitignore new file mode 100644 index 00000000000..38adb8f0098 --- /dev/null +++ b/apps/example-apps/glossary-keeper/.gitignore @@ -0,0 +1,21 @@ +node_modules +dist +dist-ssr +*.local +*.tsbuildinfo + +# Tunnel + install state written by `npm run dev` / `npm run register`. +.tolgee-dev/ + +# Local data store (webhook-driven cache). +server/.data/ + +# Local env overrides. +.env.local + +.DS_Store +.vscode/* +!.vscode/extensions.json +.idea +*.iml +*.log diff --git a/apps/example-apps/glossary-keeper/README.md b/apps/example-apps/glossary-keeper/README.md new file mode 100644 index 00000000000..844ed2cbf84 --- /dev/null +++ b/apps/example-apps/glossary-keeper/README.md @@ -0,0 +1,35 @@ +# Glossary Keeper — Tolgee App example + +Watches translation changes and proposes glossary entries, shown on a **project dashboard +page** where you can accept them (per row or all at once) into a glossary. + +How it works: + +1. A `SET_TRANSLATIONS` webhook fires when a translation changes. +2. **Haiku** (`detectSuspicious`) decides whether the change hinges on a domain term that + should be in the glossary. +3. If so, **Sonnet** (`suggestGlossaryEntry`) drafts a glossary entry (term + translation + + description), stored as a pending suggestion. +4. The dashboard lists pending suggestions. **Accept** writes the term + translation into the + chosen glossary via the org-level glossary API — using the app's own install token + (`glossary.edit` scope), no personal access token required. + +If the project has no glossary, the dashboard shows an info block; with one or more, it shows a +target-glossary dropdown. + +## Run + +```bash +npm install +cp .env.example .env.local # set ANTHROPIC_API_KEY +npm run register # install the app into your Tolgee org (grants the glossary scopes) +npm run dev # vite + server + tunnel +``` + +Requires a Tolgee instance with the **Glossary** (Enterprise) feature enabled. + +## Test + +```bash +npm test # vitest — pure server logic (model-output parsing, suggestion store) +``` diff --git a/apps/example-apps/glossary-keeper/index.html b/apps/example-apps/glossary-keeper/index.html new file mode 100644 index 00000000000..ab54fdbc017 --- /dev/null +++ b/apps/example-apps/glossary-keeper/index.html @@ -0,0 +1,12 @@ + + + + + + Glossary Keeper + + +
+ + + diff --git a/apps/example-apps/glossary-keeper/package.json b/apps/example-apps/glossary-keeper/package.json new file mode 100644 index 00000000000..9498c2838f3 --- /dev/null +++ b/apps/example-apps/glossary-keeper/package.json @@ -0,0 +1,38 @@ +{ + "name": "glossary-keeper", + "private": true, + "version": "0.0.1-alpha.7", + "type": "module", + "scripts": { + "dev": "concurrently -k -n vite,server,tunnel -c blue,green,yellow \"npm:dev:frontend\" \"npm:dev:server\" \"npm:dev:tunnel\"", + "dev:frontend": "vite", + "dev:server": "tsx watch --env-file-if-exists=.env.local server/index.ts", + "dev:tunnel": "tsx --env-file-if-exists=.env.local scripts/dev.ts", + "register": "tsx --env-file-if-exists=.env.local scripts/register.ts", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.100.1", + "@tginternal/client": "0.0.0-prerelease.c8329b552", + "@tolgee/apps-sdk": "^0.0.1-alpha.7", + "cloudflared": "^0.7.1", + "express": "^4.21.0", + "open": "^10.1.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "concurrently": "^9.1.0", + "tsx": "^4.19.2", + "typescript": "~6.0.2", + "vite": "^8.0.12", + "vitest": "^4.0.0" + } +} diff --git a/apps/example-apps/glossary-keeper/scripts/dev.ts b/apps/example-apps/glossary-keeper/scripts/dev.ts new file mode 100644 index 00000000000..4c489cfc2d6 --- /dev/null +++ b/apps/example-apps/glossary-keeper/scripts/dev.ts @@ -0,0 +1,113 @@ +/** + * Boots a Cloudflare Quick Tunnel pointing at the local Vite dev server, + * writes its public URL to `.tolgee-dev/tunnel.json` so the manifest + * route picks it up, and (if an install was registered) PATCHes the + * Tolgee install's manifest URL so the new tunnel URL takes effect + * immediately. + * + * Skips the tunnel entirely when the registered Tolgee instance lives + * on localhost — Tolgee can reach the dev plugin directly, no public + * URL needed. Force-disable via `TOLGEE_DEV_TUNNEL=none`. + */ +import { existsSync } from 'node:fs' +import { bin, install, Tunnel } from 'cloudflared' +import { + clearTunnelState, + patchManifestUrl, + readInstallState, + writeTunnelState, + type InstallState, +} from './lib' + +const VITE_PORT = Number(process.env.VITE_PORT ?? 5182) +const LOCAL_BASE_URL = `http://localhost:${VITE_PORT}` + +const isLocalHost = (urlString: string): boolean => { + try { + const host = new URL(urlString).hostname + return host === 'localhost' || host === '127.0.0.1' || host === '::1' + } catch { + return false + } +} + +const shouldSkipTunnel = (installState: InstallState | null): boolean => { + if (process.env.TOLGEE_DEV_TUNNEL === 'none') return true + if (installState && isLocalHost(installState.tolgeeUrl)) return true + if (process.env.TOLGEE_URL && isLocalHost(process.env.TOLGEE_URL)) return true + return false +} + +const ensureBinary = async (): Promise => { + if (!existsSync(bin)) { + console.log('[tunnel] installing cloudflared binary…') + await install(bin) + } +} + +const startTunnel = async (): Promise => { + const tunnel = Tunnel.quick(`http://localhost:${VITE_PORT}`) + const url = await new Promise((resolve) => { + tunnel.once('url', resolve) + }) + tunnel.on('exit', (code) => { + console.log(`[tunnel] cloudflared exited with code ${code}`) + }) + process.once('SIGINT', () => tunnel.stop()) + process.once('SIGTERM', () => tunnel.stop()) + return url +} + +const main = async (): Promise => { + clearTunnelState() + const installState = readInstallState() + + if (shouldSkipTunnel(installState)) { + console.log( + `[tunnel] skipping cloudflared — Tolgee reaches the plugin at ${LOCAL_BASE_URL}` + ) + writeTunnelState({ baseUrl: LOCAL_BASE_URL }) + await maybePatchManifestUrl(installState, LOCAL_BASE_URL) + // Park so concurrently -k doesn't tear the other processes down. + // A long-lived interval keeps the event loop alive until SIGINT/SIGTERM. + const heartbeat = setInterval(() => {}, 1 << 30) + await new Promise((resolve) => { + process.once('SIGINT', () => resolve()) + process.once('SIGTERM', () => resolve()) + }) + clearInterval(heartbeat) + return + } + + await ensureBinary() + const baseUrl = await startTunnel() + console.log(`[tunnel] live at ${baseUrl}`) + writeTunnelState({ baseUrl }) + await maybePatchManifestUrl(installState, baseUrl) +} + +const maybePatchManifestUrl = async ( + installState: InstallState | null, + baseUrl: string +): Promise => { + if (!installState) { + console.log( + '[tunnel] no install registered — run `npm run register` to ' + + 'install the dev plugin against a Tolgee organization.' + ) + return + } + try { + await patchManifestUrl(installState, `${baseUrl}/manifest.json`) + console.log( + `[tunnel] PATCHed install ${installState.installId} to use ${baseUrl}/manifest.json` + ) + } catch (err) { + console.error('[tunnel] manifest-url update failed:', err) + } +} + +main().catch((err) => { + console.error('[tunnel] fatal:', err) + process.exit(1) +}) diff --git a/apps/example-apps/glossary-keeper/scripts/lib.ts b/apps/example-apps/glossary-keeper/scripts/lib.ts new file mode 100644 index 00000000000..baa48271d54 --- /dev/null +++ b/apps/example-apps/glossary-keeper/scripts/lib.ts @@ -0,0 +1,75 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export const ROOT = join(__dirname, '..') +export const STATE_DIR = join(ROOT, '.tolgee-dev') +export const INSTALL_FILE = join(STATE_DIR, 'install.json') +export const TUNNEL_FILE = join(STATE_DIR, 'tunnel.json') + +export type InstallState = { + tolgeeUrl: string + organizationId: number + installId: number + /** + * Plugin credentials Tolgee returned when this install was registered. + * `clientSecret` (`X-API-Key: tgapps_…`) authenticates every outbound + * call the plugin makes back to Tolgee — webhook handler base-text + * lookups AND the self-service manifest-url PATCH the dev orchestrator + * fires on every restart. `webhookSecret` signs the inbound webhook + * bodies. + */ + clientId: string + clientSecret: string + webhookSecret: string +} + +export type TunnelState = { + baseUrl: string +} + +export const writeJson = (path: string, value: unknown): void => { + mkdirSync(STATE_DIR, { recursive: true }) + writeFileSync(path, JSON.stringify(value, null, 2) + '\n', 'utf8') +} + +export const readInstallState = (): InstallState | null => { + try { + return JSON.parse(readFileSync(INSTALL_FILE, 'utf8')) as InstallState + } catch { + return null + } +} + +export const writeTunnelState = (state: TunnelState): void => { + writeJson(TUNNEL_FILE, state) +} + +export const clearTunnelState = (): void => { + writeJson(TUNNEL_FILE, { baseUrl: 'http://localhost:5182' }) +} + +export const patchManifestUrl = async ( + install: InstallState, + manifestUrl: string +): Promise => { + // Self-service endpoint — the install authenticates as itself with its + // clientSecret. The earlier org-admin endpoint required a PAT we no + // longer collect; this one only lets an install update its own URL. + const url = `${install.tolgeeUrl}/v2/apps/self/manifest-url` + const res = await fetch(url, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': install.clientSecret, + }, + body: JSON.stringify({ manifestUrl }), + }) + if (!res.ok) { + throw new Error( + `PATCH manifest-url failed (${res.status}): ${await res.text()}` + ) + } +} diff --git a/apps/example-apps/glossary-keeper/scripts/register.ts b/apps/example-apps/glossary-keeper/scripts/register.ts new file mode 100644 index 00000000000..0d53416a841 --- /dev/null +++ b/apps/example-apps/glossary-keeper/scripts/register.ts @@ -0,0 +1,303 @@ +/** + * One-time install bootstrap. + * + * Default flow: open Tolgee in the user's browser, let them pick an org and + * approve the install, then receive the install credentials back on a one-shot + * localhost callback server. + * + * Headless fallback: pass `--pat=tgpat_…` to skip the browser and register + * directly with a personal-access token (useful for CI). + * + * When Tolgee is local the manifest is served straight from Express — no + * Cloudflare tunnel needed. For remote Tolgee a quick tunnel is booted so + * Tolgee can fetch the manifest from outside the user's machine. Either way, + * the local dev server must be running (`npm run dev` in another terminal). + */ +import { createInterface } from 'node:readline/promises' +import { existsSync } from 'node:fs' +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { randomUUID, timingSafeEqual } from 'node:crypto' +import open from 'open' +import { bin, install, Tunnel } from 'cloudflared' +import { + type InstallState, + INSTALL_FILE, + writeJson, + writeTunnelState, +} from './lib' + +type Org = { id: number; name: string; slug: string } + +const VITE_PORT = Number(process.env.VITE_PORT ?? 5182) +const SERVER_PORT = Number(process.env.SERVER_PORT ?? 5183) +const LOCAL_MANIFEST_URL = `http://localhost:${SERVER_PORT}/manifest.json` +const BROWSER_TIMEOUT_MS = 5 * 60_000 + +const args = process.argv.slice(2) +const patFlag = + args.find((a) => a.startsWith('--pat='))?.slice('--pat='.length) ?? null + +const isLocalHost = (urlString: string): boolean => { + try { + const host = new URL(urlString).hostname + return host === 'localhost' || host === '127.0.0.1' || host === '::1' + } catch { + return false + } +} + +const verifyManifestReachable = async (manifestUrl: string): Promise => { + let res: Response + try { + res = await fetch(manifestUrl) + } catch (err) { + throw new Error( + `Could not reach ${manifestUrl}. Is \`npm run dev\` running in another terminal? (${ + err instanceof Error ? err.message : String(err) + })` + ) + } + if (!res.ok) { + throw new Error(`${manifestUrl} returned ${res.status}. Is dev running?`) + } +} + +const startTunnel = async (): Promise => { + if (!existsSync(bin)) { + console.log('Installing cloudflared binary…') + await install(bin) + } + console.log('Starting Cloudflare quick tunnel…') + const tunnel = Tunnel.quick(`http://localhost:${VITE_PORT}`) + const baseUrl = await new Promise((resolve) => + tunnel.once('url', resolve) + ) + console.log(`Tunnel live at ${baseUrl}`) + return baseUrl +} + +/** + * Picks the manifest URL Tolgee will fetch + writes the tunnel state so + * the local server serves the right baseUrl. + * local Tolgee → http://localhost:5183/manifest.json (Express direct) + * remote Tolgee → Cloudflare quick tunnel pointing at Vite + */ +const resolveManifestUrl = async ( + tolgeeUrl: string +): Promise => { + if (isLocalHost(tolgeeUrl)) { + writeTunnelState({ baseUrl: `http://localhost:${VITE_PORT}` }) + console.log(`Skipping tunnel — Tolgee is local; using ${LOCAL_MANIFEST_URL}`) + return LOCAL_MANIFEST_URL + } + const tunnelBaseUrl = await startTunnel() + writeTunnelState({ baseUrl: tunnelBaseUrl }) + return `${tunnelBaseUrl}/manifest.json` +} + +const constantTimeEq = (a: string, b: string): boolean => { + const ab = Buffer.from(a) + const bb = Buffer.from(b) + if (ab.length !== bb.length) return false + return timingSafeEqual(ab, bb) +} + +/** + * Browser flow: spins up a one-shot HTTP server on 127.0.0.1, opens the + * Tolgee install page, waits for the redirect callback. + */ +const browserRegister = async ( + tolgeeUrl: string, + manifestUrl: string +): Promise => { + const state = randomUUID() + return await new Promise((resolve, reject) => { + let settled = false + const finish = ( + kind: 'resolve' | 'reject', + value: InstallState | Error + ) => { + if (settled) return + settled = true + clearTimeout(timeout) + server.close() + if (kind === 'resolve') resolve(value as InstallState) + else reject(value as Error) + } + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '', `http://127.0.0.1`) + if (url.pathname !== '/cb') { + res.statusCode = 404 + res.end('Not found') + return + } + const incomingState = url.searchParams.get('state') ?? '' + if (!constantTimeEq(incomingState, state)) { + res.statusCode = 400 + res.end(htmlPage('State mismatch — refusing this callback.', false)) + finish('reject', new Error('Callback state did not match.')) + return + } + const error = url.searchParams.get('error') + if (error) { + res.end(htmlPage(`Install ${error}. You can close this tab.`, false)) + finish('reject', new Error(`Install ${error}`)) + return + } + const installId = Number(url.searchParams.get('installId')) + const organizationId = Number(url.searchParams.get('organizationId')) + const clientId = url.searchParams.get('clientId') ?? '' + const clientSecret = url.searchParams.get('clientSecret') ?? '' + const webhookSecret = url.searchParams.get('webhookSecret') ?? '' + if (!installId || !organizationId || !clientId || !clientSecret) { + res.statusCode = 400 + res.end(htmlPage('Callback missing required fields.', false)) + finish('reject', new Error('Callback missing required fields.')) + return + } + res.end(htmlPage('Installed. You can close this tab.', true)) + finish('resolve', { + tolgeeUrl, + organizationId, + installId, + clientId, + clientSecret, + webhookSecret, + }) + }) + + const timeout = setTimeout( + () => finish('reject', new Error('Browser flow timed out.')), + BROWSER_TIMEOUT_MS + ) + + server.on('error', (err) => finish('reject', err)) + server.listen(0, '127.0.0.1', async () => { + const address = server.address() + if (!address || typeof address === 'string') { + finish('reject', new Error('Failed to bind localhost port')) + return + } + const callback = `http://127.0.0.1:${address.port}/cb` + const installUrl = + `${tolgeeUrl.replace(/\/$/, '')}/install-app` + + `?manifestUrl=${encodeURIComponent(manifestUrl)}` + + `&callback=${encodeURIComponent(callback)}` + + `&state=${encodeURIComponent(state)}` + console.log(`\nOpening browser to install the plugin…`) + console.log(` ${installUrl}\n`) + console.log( + `If the browser doesn't open automatically, paste that URL into it.\n` + + `Waiting up to ${BROWSER_TIMEOUT_MS / 1000}s for you to approve…` + ) + try { + await open(installUrl) + } catch { + // ignore — user can paste the URL manually + } + }) + }) +} + +const htmlPage = (message: string, ok: boolean): string => { + const color = ok ? '#1b8c43' : '#c44343' + return ` +Tolgee plugin install + +
${ok ? '✓ Done' : '✗ Stopped'}
+

${message}

+` +} + +/** Headless fallback: prompts for org by listing them via PAT. */ +const patRegister = async ( + tolgeeUrl: string, + manifestUrl: string, + pat: string +): Promise => { + if (!pat.startsWith('tgpat_')) { + throw new Error('--pat value must start with tgpat_') + } + const orgs = await (async () => { + const res = await fetch(`${tolgeeUrl}/v2/organizations?size=100`, { + headers: { 'X-API-Key': pat }, + }) + if (!res.ok) { + throw new Error( + `Could not list organizations (${res.status}): ${await res.text()}` + ) + } + const json = (await res.json()) as { _embedded?: { organizations?: Org[] } } + return json._embedded?.organizations ?? [] + })() + if (orgs.length === 0) { + throw new Error('No organizations visible to this PAT.') + } + console.log('\nOrganizations:') + orgs.forEach((o, i) => console.log(` ${i + 1}. ${o.name} (${o.slug})`)) + const rl = createInterface({ input: process.stdin, output: process.stdout }) + const idxStr = (await rl.question(`Pick one [1-${orgs.length}]: `)).trim() + rl.close() + const idx = Number.parseInt(idxStr, 10) - 1 + const org = orgs[idx] + if (!org) throw new Error('Invalid selection') + + const res = await fetch(`${tolgeeUrl}/v2/organizations/${org.id}/apps`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': pat }, + body: JSON.stringify({ manifestUrl }), + }) + if (!res.ok) { + throw new Error(`Register failed (${res.status}): ${await res.text()}`) + } + const data = (await res.json()) as { + id: number + clientId: string + clientSecret: string + webhookSecret: string + } + return { + tolgeeUrl, + organizationId: org.id, + installId: data.id, + clientId: data.clientId, + clientSecret: data.clientSecret, + webhookSecret: data.webhookSecret, + } +} + +const main = async (): Promise => { + if (existsSync(INSTALL_FILE)) { + console.log( + `Install record already exists at ${INSTALL_FILE}. Delete it to re-register, ` + + 'or just run `npm run dev` to keep using the existing install.' + ) + return + } + + const tolgeeUrl = (process.env.TOLGEE_URL ?? 'https://app.tolgee.io').replace( + /\/$/, + '' + ) + const manifestUrl = await resolveManifestUrl(tolgeeUrl) + await verifyManifestReachable(manifestUrl) + + const state = patFlag + ? await patRegister(tolgeeUrl, manifestUrl, patFlag) + : await browserRegister(tolgeeUrl, manifestUrl) + + writeJson(INSTALL_FILE, state) + console.log( + `\nRegistered. Install ${state.installId} for org ${state.organizationId}. ` + + `Saved to ${INSTALL_FILE}.` + ) +} + +main().catch((err) => { + console.error('register failed:', err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/apps/example-apps/glossary-keeper/server/anthropic.test.ts b/apps/example-apps/glossary-keeper/server/anthropic.test.ts new file mode 100644 index 00000000000..521b4aeb209 --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/anthropic.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { extractJson } from './anthropic' + +describe('extractJson', () => { + it('parses a plain JSON object', () => { + expect(extractJson<{ a: number }>('{"a": 1}')).toEqual({ a: 1 }) + }) + + it('strips markdown ```json fences', () => { + const raw = + '```json\n{"suspicious": true, "candidateTerm": "Cobalt", "reason": "term"}\n```' + expect(extractJson(raw)).toEqual({ + suspicious: true, + candidateTerm: 'Cobalt', + reason: 'term', + }) + }) + + it('ignores prose around the object', () => { + const raw = + 'Sure! Here you go:\n{"term":"Cobalt","translation":"Kobalt","description":"a metal"} — hope that helps' + expect(extractJson(raw)).toEqual({ + term: 'Cobalt', + translation: 'Kobalt', + description: 'a metal', + }) + }) + + it('throws when there is no JSON object', () => { + expect(() => extractJson('no json here')).toThrow() + }) +}) diff --git a/apps/example-apps/glossary-keeper/server/anthropic.ts b/apps/example-apps/glossary-keeper/server/anthropic.ts new file mode 100644 index 00000000000..48f8a5c83a2 --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/anthropic.ts @@ -0,0 +1,105 @@ +import Anthropic from '@anthropic-ai/sdk' +import { + ANTHROPIC_API_KEY, + ANTHROPIC_MODEL_DETECT, + ANTHROPIC_MODEL_SUGGEST, +} from './config' + +export type DetectInput = { + baseLang: string + baseText: string + languageTag: string + text: string +} + +export type Detection = { + /** Whether this change looks like it uses a term that should be in the glossary. */ + suspicious: boolean + /** The term in question, in the base language (empty when not suspicious). */ + candidateTerm: string + reason: string +} + +export type SuggestInput = DetectInput & { candidateTerm: string } + +export type GlossarySuggestion = { + /** Canonical term, in the base language. */ + term: string + /** Suggested translation of the term in the changed language. */ + translation: string + description: string +} + +const client = ANTHROPIC_API_KEY + ? new Anthropic({ apiKey: ANTHROPIC_API_KEY }) + : null + +export const isAnthropicConfigured = (): boolean => client !== null + +/** Pulls the first JSON object out of a model response, tolerating markdown fences. */ +export const extractJson = (raw: string): T => { + const cleaned = raw.replace(/```(?:json)?/g, '').trim() + const start = cleaned.indexOf('{') + const end = cleaned.lastIndexOf('}') + if (start < 0 || end < 0) throw new Error('No JSON in model response') + return JSON.parse(cleaned.slice(start, end + 1)) as T +} + +const firstText = (content: Anthropic.Messages.ContentBlock[]): string => { + const block = content[0] + return block && block.type === 'text' ? block.text : '' +} + +const detectPrompt = (input: DetectInput): string => + `You maintain a translation glossary. A translator just changed a translation. + +Base (${input.baseLang}): "${input.baseText}" +Changed (${input.languageTag}): "${input.text}" + +Decide whether this change hinges on a domain/product/brand TERM that should be +defined in the glossary so it is translated consistently — e.g. a feature name, +technical term, or term of art. Ignore ordinary words and pure rephrasing. + +Respond ONLY with valid JSON, no preamble, no markdown: +{ + "suspicious": true | false, + "candidateTerm": "the term in ${input.baseLang}, or empty string", + "reason": "one short sentence" +}` + +const suggestPrompt = (input: SuggestInput): string => + `Draft a glossary entry for the term "${input.candidateTerm}". + +Context — base (${input.baseLang}): "${input.baseText}" +Context — ${input.languageTag}: "${input.text}" + +Respond ONLY with valid JSON, no preamble, no markdown: +{ + "term": "canonical term in ${input.baseLang}", + "translation": "recommended ${input.languageTag} translation of the term", + "description": "one short sentence explaining the term" +}` + +/** Cheap first pass (Haiku): is this change worth a glossary entry? */ +export async function detectSuspicious(input: DetectInput): Promise { + if (!client) throw new Error('ANTHROPIC_API_KEY is not set') + const response = await client.messages.create({ + model: ANTHROPIC_MODEL_DETECT, + max_tokens: 300, + messages: [{ role: 'user', content: detectPrompt(input) }], + }) + return extractJson(firstText(response.content)) +} + +/** Second pass (Sonnet): draft the actual glossary entry. */ +export async function suggestGlossaryEntry( + input: SuggestInput +): Promise { + if (!client) throw new Error('ANTHROPIC_API_KEY is not set') + const response = await client.messages.create({ + model: ANTHROPIC_MODEL_SUGGEST, + max_tokens: 400, + messages: [{ role: 'user', content: suggestPrompt(input) }], + }) + return extractJson(firstText(response.content)) +} diff --git a/apps/example-apps/glossary-keeper/server/config.ts b/apps/example-apps/glossary-keeper/server/config.ts new file mode 100644 index 00000000000..bb6593851f0 --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/config.ts @@ -0,0 +1,47 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +/** Vite dev server port. Tunnel + iframe URLs use this. */ +export const VITE_PORT = Number(process.env.VITE_PORT ?? 5182) + +/** Express server port. Manifest/webhook/custom routes live here. */ +export const SERVER_PORT = Number( + process.env.SERVER_PORT ?? process.env.PORT ?? 5183 +) + +export const LOCAL_BASE_URL = `http://localhost:${VITE_PORT}` + +export const WEBHOOK_SECRET = process.env.TOLGEE_WEBHOOK_SECRET ?? null +export const TOLGEE_URL = process.env.TOLGEE_URL ?? 'https://app.tolgee.io' + +/** Anthropic API key. Set in .env.local. */ +export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? null + +/** Fast model that flags whether a change is suspicious for a missing glossary term. */ +export const ANTHROPIC_MODEL_DETECT = + process.env.ANTHROPIC_MODEL_DETECT ?? 'claude-haiku-4-5-20251001' + +/** Stronger model that drafts the actual glossary entry suggestion. */ +export const ANTHROPIC_MODEL_SUGGEST = + process.env.ANTHROPIC_MODEL_SUGGEST ?? 'claude-sonnet-4-6' + +/** + * Tunnel state file written by scripts/dev.ts. The manifest route reads + * `baseUrl` from it on every request so manifest URLs always reflect the + * active tunnel without restarting the server. + */ +export const TUNNEL_STATE_DIR = join(__dirname, '..', '.tolgee-dev') +export const TUNNEL_STATE_FILE = join(TUNNEL_STATE_DIR, 'tunnel.json') + +/** + * Local install record written by `npm run register`. The server reads + * `clientSecret` (for `X-API-Key: tgapps_…`) and `organizationId` (the org + * whose glossaries this install may write) from here. + */ +export const INSTALL_FILE = join(TUNNEL_STATE_DIR, 'install.json') + +/** Persistent JSON store of pending glossary suggestions. */ +export const STORE_DIR = join(__dirname, '.data') +export const STORE_FILE = join(STORE_DIR, 'suggestions.json') diff --git a/apps/example-apps/glossary-keeper/server/cors.ts b/apps/example-apps/glossary-keeper/server/cors.ts new file mode 100644 index 00000000000..cb09c13837c --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/cors.ts @@ -0,0 +1,16 @@ +import type { RequestHandler } from 'express' + +/** + * Permissive CORS for /decorators and /translate-back. The webapp's + * dev origin (e.g. http://localhost:3824) differs from the Tolgee + * backend origin (http://localhost:8824), so a single allowed origin + * is fragile. The decorator JWT travels in the Authorization header + * (not a credentialed cookie), so wildcard is safe. + */ +export const cors: RequestHandler = (_req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type') + res.setHeader('Access-Control-Max-Age', '600') + next() +} diff --git a/apps/example-apps/glossary-keeper/server/index.ts b/apps/example-apps/glossary-keeper/server/index.ts new file mode 100644 index 00000000000..1841f3472f6 --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/index.ts @@ -0,0 +1,34 @@ +import express, { json } from 'express' +import { ANTHROPIC_API_KEY, SERVER_PORT, WEBHOOK_SECRET } from './config' +import { cors } from './cors' +import { registerManifestRoute } from './routes/manifest' +import { registerWebhookRoute } from './routes/webhook' +import { registerSuggestionsRoute } from './routes/suggestions' +import { registerAcceptRoutes } from './routes/accept' + +const app = express() + +// /webhook is registered before json(): the SDK's HMAC verifier needs the raw POST body. +registerWebhookRoute(app) + +app.use(cors) +app.options('*', (_req, res) => { + res.status(204).end() +}) +app.use(json()) + +registerManifestRoute(app) +registerSuggestionsRoute(app) +registerAcceptRoutes(app) + +app.listen(SERVER_PORT, () => { + console.log(`glossary-keeper server listening on http://localhost:${SERVER_PORT}`) + if (!WEBHOOK_SECRET) { + console.warn( + 'TOLGEE_WEBHOOK_SECRET is not set; webhook signatures will not be verified.' + ) + } + if (!ANTHROPIC_API_KEY) { + console.warn('ANTHROPIC_API_KEY is not set; the webhook will not produce suggestions.') + } +}) diff --git a/apps/example-apps/glossary-keeper/server/manifest.template.json b/apps/example-apps/glossary-keeper/server/manifest.template.json new file mode 100644 index 00000000000..f875693ff65 --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/manifest.template.json @@ -0,0 +1,21 @@ +{ + "id": "glossary-keeper", + "name": "Glossary Keeper", + "version": "0.1.0", + "baseUrl": "__BASE_URL__", + "scopes": ["translations.view", "keys.view", "glossary.view", "glossary.edit"], + "webhooks": { + "events": ["SET_TRANSLATIONS"], + "url": "__BASE_URL__/webhook" + }, + "modules": { + "project-dashboard-page": [ + { + "key": "updates", + "title": "Glossary updates", + "icon": "📖", + "entry": "/" + } + ] + } +} diff --git a/apps/example-apps/glossary-keeper/server/manifest.ts b/apps/example-apps/glossary-keeper/server/manifest.ts new file mode 100644 index 00000000000..6a14191a864 --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/manifest.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { LOCAL_BASE_URL, TUNNEL_STATE_FILE } from './config' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const TEMPLATE_PATH = join(__dirname, 'manifest.template.json') + +const PLACEHOLDER = '__BASE_URL__' + +/** + * Reads the tunnel state written by scripts/dev.ts and returns the + * public base URL the plugin is reachable at, or the localhost default + * when no tunnel is active. + */ +const readBaseUrl = (): string => { + try { + const raw = readFileSync(TUNNEL_STATE_FILE, 'utf8') + const parsed = JSON.parse(raw) as { baseUrl?: string } + if (typeof parsed.baseUrl === 'string' && parsed.baseUrl.length > 0) { + return parsed.baseUrl + } + } catch { + // file missing or unreadable — fall through to localhost default + } + return LOCAL_BASE_URL +} + +export const renderManifest = (): string => { + const template = readFileSync(TEMPLATE_PATH, 'utf8') + const baseUrl = readBaseUrl() + return template.replaceAll(PLACEHOLDER, baseUrl) +} diff --git a/apps/example-apps/glossary-keeper/server/routes/accept.ts b/apps/example-apps/glossary-keeper/server/routes/accept.ts new file mode 100644 index 00000000000..1ff739e677e --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/routes/accept.ts @@ -0,0 +1,71 @@ +import type { Express } from 'express' +import { + getSuggestion, + listByProject, + removeSuggestion, + type Suggestion, +} from '../store' +import { createGlossaryTerm, setGlossaryTranslation } from '../tolgeeApi' + +/** + * Writes a suggestion into the chosen glossary: creates the term (base-language text) and + * sets its translation in the changed language, then drops it from the pending store. Uses + * the install's `glossary.edit` org scope (org-level app authorization). + */ +const acceptOne = async ( + suggestion: Suggestion, + glossaryId: number +): Promise => { + const termId = await createGlossaryTerm( + glossaryId, + suggestion.term, + suggestion.description + ) + await setGlossaryTranslation( + glossaryId, + termId, + suggestion.languageTag, + suggestion.translation + ) + removeSuggestion(suggestion.id) +} + +export const registerAcceptRoutes = (app: Express): void => { + app.post('/accept', async (req, res) => { + const body = req.body as { suggestionId?: unknown; glossaryId?: unknown } + if (typeof body.suggestionId !== 'string' || typeof body.glossaryId !== 'number') { + res.status(400).json({ error: 'suggestionId (string) and glossaryId (number) required' }) + return + } + const suggestion = getSuggestion(body.suggestionId) + if (!suggestion) { + res.status(404).json({ error: 'suggestion not found' }) + return + } + try { + await acceptOne(suggestion, body.glossaryId) + res.json({ accepted: suggestion.id }) + } catch (err) { + res.status(502).json({ error: 'glossary write failed', message: String(err) }) + } + }) + + app.post('/accept-all', async (req, res) => { + const body = req.body as { projectId?: unknown; glossaryId?: unknown } + if (typeof body.projectId !== 'number' || typeof body.glossaryId !== 'number') { + res.status(400).json({ error: 'projectId (number) and glossaryId (number) required' }) + return + } + const accepted: string[] = [] + const failed: Array<{ id: string; message: string }> = [] + for (const suggestion of listByProject(body.projectId)) { + try { + await acceptOne(suggestion, body.glossaryId) + accepted.push(suggestion.id) + } catch (err) { + failed.push({ id: suggestion.id, message: String(err) }) + } + } + res.json({ accepted, failed }) + }) +} diff --git a/apps/example-apps/glossary-keeper/server/routes/manifest.ts b/apps/example-apps/glossary-keeper/server/routes/manifest.ts new file mode 100644 index 00000000000..3bbde6ca56d --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/routes/manifest.ts @@ -0,0 +1,8 @@ +import type { Express, Request, Response } from 'express' +import { renderManifest } from '../manifest' + +export const registerManifestRoute = (app: Express): void => { + app.get('/manifest.json', (_req: Request, res: Response) => { + res.type('application/json').send(renderManifest()) + }) +} diff --git a/apps/example-apps/glossary-keeper/server/routes/suggestions.ts b/apps/example-apps/glossary-keeper/server/routes/suggestions.ts new file mode 100644 index 00000000000..fe7746d3221 --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/routes/suggestions.ts @@ -0,0 +1,14 @@ +import type { Express } from 'express' +import { listByProject } from '../store' + +/** GET /suggestions?projectId= — pending glossary suggestions for the dashboard. */ +export const registerSuggestionsRoute = (app: Express): void => { + app.get('/suggestions', (req, res) => { + const projectId = Number(req.query.projectId) + if (!Number.isFinite(projectId)) { + res.status(400).json({ error: 'projectId query param is required' }) + return + } + res.json({ suggestions: listByProject(projectId) }) + }) +} diff --git a/apps/example-apps/glossary-keeper/server/routes/webhook.ts b/apps/example-apps/glossary-keeper/server/routes/webhook.ts new file mode 100644 index 00000000000..3b043514235 --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/routes/webhook.ts @@ -0,0 +1,149 @@ +import express, { type Express, type Request, type Response } from 'express' +import { + onWebhook, + verifyWebhookSignature, + type AppWebhookPayload, + type WebhookPayloadFor, +} from '@tolgee/apps-sdk/server' +import { WEBHOOK_SECRET } from '../config' +import { + detectSuspicious, + isAnthropicConfigured, + suggestGlossaryEntry, +} from '../anthropic' +import { suggestionId, upsertSuggestion } from '../store' +import { fetchKeyContext, getProjectLanguages, tolgeeReady } from '../tolgeeApi' + +export const registerWebhookRoute = (app: Express): void => { + // express.text keeps the body verbatim — the SDK verifier needs the raw bytes Tolgee signed. + app.post( + '/webhook', + express.text({ type: 'application/json', limit: '5mb' }), + handleWebhook + ) +} + +const handleWebhook = async (req: Request, res: Response): Promise => { + const raw = typeof req.body === 'string' ? req.body : '' + if (!(await verifyOrAllow(req, raw))) { + res.status(401).json({ error: 'invalid signature' }) + return + } + const payload = parsePayload(raw) + if (!payload) { + res.status(400).json({ error: 'invalid json' }) + return + } + + onWebhook(payload, 'SET_TRANSLATIONS', (typed) => { + void handleSetTranslations(typed).catch((err) => { + console.error('[webhook] SET_TRANSLATIONS handler failed', err) + }) + }) + + // Always 204 — Tolgee shouldn't retry while the (slow) LLM analysis runs. + res.status(204).end() +} + +type SetTranslationsPayload = WebhookPayloadFor<'SET_TRANSLATIONS'> + +type ModifiedTranslation = { + languageId?: number + keyId?: number + text?: string +} + +const handleSetTranslations = async ( + payload: SetTranslationsPayload +): Promise => { + if (!tolgeeReady()) { + console.warn('[webhook] install record missing — run `npm run register`.') + return + } + if (!isAnthropicConfigured()) { + console.warn('[webhook] ANTHROPIC_API_KEY not set — skipping analysis.') + return + } + const data = payload.activityData + if (!data) return + // Activity payloads carry projectId on the wrapper, not on each entity. + const projectId = (data as { projectId?: number }).projectId + if (typeof projectId !== 'number') return + const mods = (data.modifiedEntities?.Translation ?? []) as ModifiedTranslation[] + if (mods.length === 0) return + + const langs = await getProjectLanguages(projectId) + + for (const m of mods) { + if (typeof m.keyId !== 'number' || typeof m.languageId !== 'number') continue + const tag = langs.byId.get(m.languageId) + // Only inspect non-base translations (the term is judged against the base text). + if (!tag || tag === langs.baseLang) continue + if (typeof m.text !== 'string' || m.text.length === 0) continue + await analyseOne(projectId, m.keyId, tag, m.text, langs.baseLang).catch((err) => { + console.error(`[webhook] analysis failed for key ${m.keyId}/${tag}`, err) + }) + } +} + +const analyseOne = async ( + projectId: number, + keyId: number, + languageTag: string, + text: string, + baseLang: string +): Promise => { + const ctx = await fetchKeyContext(projectId, keyId, baseLang, languageTag) + if (!ctx.baseText) return + + const detection = await detectSuspicious({ + baseLang, + baseText: ctx.baseText, + languageTag, + text, + }) + if (!detection.suspicious) return + + const entry = await suggestGlossaryEntry({ + baseLang, + baseText: ctx.baseText, + languageTag, + text, + candidateTerm: detection.candidateTerm, + }) + + upsertSuggestion({ + id: suggestionId(keyId, languageTag), + projectId, + keyId, + keyName: ctx.keyName, + languageTag, + term: entry.term, + translation: entry.translation, + description: entry.description, + sourceText: text, + createdAt: new Date().toISOString(), + }) +} + +const verifyOrAllow = async (req: Request, raw: string): Promise => { + if (!WEBHOOK_SECRET) { + console.warn( + 'TOLGEE_WEBHOOK_SECRET is not set — accepting webhook without verification.' + ) + return true + } + return verifyWebhookSignature({ + header: req.header('Tolgee-Signature'), + rawBody: raw, + secret: WEBHOOK_SECRET, + }) +} + +const parsePayload = (raw: string): AppWebhookPayload | null => { + try { + return JSON.parse(raw) as AppWebhookPayload + } catch { + return null + } +} diff --git a/apps/example-apps/glossary-keeper/server/store.test.ts b/apps/example-apps/glossary-keeper/server/store.test.ts new file mode 100644 index 00000000000..087db0d09e1 --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/store.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + getSuggestion, + listByProject, + removeSuggestion, + suggestionId, + upsertSuggestion, + type Suggestion, +} from './store' + +// A project id unlikely to collide with anything persisted in .data/. +const PROJECT = 987654 + +const make = (keyId: number, languageTag: string, term: string): Suggestion => ({ + id: suggestionId(keyId, languageTag), + projectId: PROJECT, + keyId, + keyName: `key-${keyId}`, + languageTag, + term, + translation: `${term}-translated`, + description: 'desc', + sourceText: 'src', + createdAt: new Date().toISOString(), +}) + +afterEach(() => { + for (const s of listByProject(PROJECT)) removeSuggestion(s.id) +}) + +describe('suggestion store', () => { + it('upserts and lists by project', () => { + upsertSuggestion(make(1, 'de', 'Cobalt')) + upsertSuggestion(make(2, 'fr', 'Widget')) + expect( + listByProject(PROJECT) + .map((s) => s.term) + .sort() + ).toEqual(['Cobalt', 'Widget']) + }) + + it('dedupes by key + language (a re-edit overwrites)', () => { + upsertSuggestion(make(1, 'de', 'Cobalt')) + upsertSuggestion(make(1, 'de', 'Kobalt')) + const list = listByProject(PROJECT) + expect(list).toHaveLength(1) + expect(list[0].term).toBe('Kobalt') + }) + + it('removes an accepted suggestion', () => { + const s = make(3, 'es', 'Foo') + upsertSuggestion(s) + expect(getSuggestion(s.id)).toBeDefined() + removeSuggestion(s.id) + expect(getSuggestion(s.id)).toBeUndefined() + }) +}) diff --git a/apps/example-apps/glossary-keeper/server/store.ts b/apps/example-apps/glossary-keeper/server/store.ts new file mode 100644 index 00000000000..195e06c2f06 --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/store.ts @@ -0,0 +1,71 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { STORE_DIR, STORE_FILE } from './config' + +export type Suggestion = { + /** `${keyId}:${languageTag}` — one pending suggestion per cell. */ + id: string + projectId: number + keyId: number + keyName: string + /** Language whose changed translation triggered the suggestion. */ + languageTag: string + /** Canonical term, in the project base language. */ + term: string + /** Suggested translation of the term in `languageTag`. */ + translation: string + description: string + /** The translation text that triggered this suggestion (used for dedup). */ + sourceText: string + createdAt: string +} + +export const suggestionId = (keyId: number, languageTag: string): string => + `${keyId}:${languageTag}` + +type Persisted = { suggestions: Suggestion[] } + +const ensureDir = (): void => { + try { + mkdirSync(STORE_DIR, { recursive: true }) + } catch { + /* directory may already exist */ + } +} + +const readPersisted = (): Persisted => { + try { + const parsed = JSON.parse(readFileSync(STORE_FILE, 'utf8')) as Persisted + return parsed.suggestions ? parsed : { suggestions: [] } + } catch { + return { suggestions: [] } + } +} + +const map = new Map( + readPersisted().suggestions.map((s) => [s.id, s] as const) +) + +const flush = (): void => { + ensureDir() + writeFileSync( + STORE_FILE, + JSON.stringify({ suggestions: Array.from(map.values()) }, null, 2) + '\n', + 'utf8' + ) +} + +export const upsertSuggestion = (suggestion: Suggestion): void => { + map.set(suggestion.id, suggestion) + flush() +} + +export const getSuggestion = (id: string): Suggestion | undefined => map.get(id) + +export const removeSuggestion = (id: string): void => { + if (map.delete(id)) flush() +} + +export const listByProject = (projectId: number): Suggestion[] => + Array.from(map.values()) + .filter((s) => s.projectId === projectId) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) diff --git a/apps/example-apps/glossary-keeper/server/tolgeeApi.ts b/apps/example-apps/glossary-keeper/server/tolgeeApi.ts new file mode 100644 index 00000000000..b2da8a557ac --- /dev/null +++ b/apps/example-apps/glossary-keeper/server/tolgeeApi.ts @@ -0,0 +1,174 @@ +import { readFileSync } from 'node:fs' +import { INSTALL_FILE, TOLGEE_URL } from './config' + +type InstallRecord = { + tolgeeUrl?: string + clientSecret?: string + organizationId?: number +} + +type Install = { + clientSecret: string + tolgeeUrl: string + organizationId: number +} + +let cached: Install | null = null + +const readInstall = (): Install | null => { + if (cached) return cached + try { + const parsed = JSON.parse(readFileSync(INSTALL_FILE, 'utf8')) as InstallRecord + if (!parsed.clientSecret || typeof parsed.organizationId !== 'number') return null + cached = { + clientSecret: parsed.clientSecret, + tolgeeUrl: parsed.tolgeeUrl ?? TOLGEE_URL, + organizationId: parsed.organizationId, + } + return cached + } catch { + return null + } +} + +const install = (): Install => { + const rec = readInstall() + if (!rec) throw new Error('install record missing — run `npm run register`') + return rec +} + +const authHeaders = (): Record => ({ + 'X-API-Key': install().clientSecret, +}) + +const tolgeeBase = (): string => install().tolgeeUrl + +export const tolgeeReady = (): boolean => readInstall() !== null + +/** The organization this install belongs to — owns the glossaries it may write. */ +export const getOrganizationId = (): number => install().organizationId + +type Language = { id: number; tag: string; base: boolean } + +type ProjectLanguages = { baseLang: string; byId: Map } + +const projectLanguagesCache = new Map>() + +/** Cached for the process lifetime. Restart `npm run dev` after changing languages. */ +export const getProjectLanguages = async ( + projectId: number +): Promise => { + const cachedLangs = projectLanguagesCache.get(projectId) + if (cachedLangs) return cachedLangs + const pending = (async (): Promise => { + const res = await fetch( + `${tolgeeBase()}/v2/projects/${projectId}/languages?size=100`, + { headers: authHeaders() } + ) + if (!res.ok) { + throw new Error(`languages lookup failed: ${res.status} ${await res.text()}`) + } + const json = (await res.json()) as { _embedded?: { languages?: Language[] } } + const languages = json._embedded?.languages ?? [] + const base = languages.find((l) => l.base) + if (!base) throw new Error('No base language reported for project') + return { + baseLang: base.tag, + byId: new Map(languages.map((l) => [l.id, l.tag] as const)), + } + })() + projectLanguagesCache.set(projectId, pending) + return pending.catch((err) => { + projectLanguagesCache.delete(projectId) + throw err + }) +} + +type KeyContext = { + keyName: string + baseText: string | null + text: string | null +} + +export const fetchKeyContext = async ( + projectId: number, + keyId: number, + baseLang: string, + languageTag: string +): Promise => { + const langs = Array.from(new Set([baseLang, languageTag])) + const qs = new URLSearchParams({ size: '1' }) + qs.set('filterKeyId', String(keyId)) + for (const tag of langs) qs.append('languages', tag) + const res = await fetch( + `${tolgeeBase()}/v2/projects/${projectId}/translations?${qs.toString()}`, + { headers: authHeaders() } + ) + if (!res.ok) { + throw new Error(`translation lookup failed: ${res.status} ${await res.text()}`) + } + type Row = { + keyName: string + translations: Record + } + const json = (await res.json()) as { _embedded?: { keys?: Row[] } } + const row = json._embedded?.keys?.[0] + return { + keyName: row?.keyName ?? `key#${keyId}`, + baseText: row?.translations?.[baseLang]?.text ?? null, + text: row?.translations?.[languageTag]?.text ?? null, + } +} + +const jsonHeaders = (): Record => ({ + ...authHeaders(), + 'Content-Type': 'application/json', +}) + +/** + * Creates a glossary term with its base-language text. Requires the install to hold the + * `glossary.edit` org scope (see the platform's app org-level authorization). + */ +export const createGlossaryTerm = async ( + glossaryId: number, + text: string, + description: string +): Promise => { + const orgId = getOrganizationId() + const res = await fetch( + `${tolgeeBase()}/v2/organizations/${orgId}/glossaries/${glossaryId}/terms`, + { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ text, description }), + } + ) + if (!res.ok) { + throw new Error(`glossary term create failed: ${res.status} ${await res.text()}`) + } + const json = (await res.json()) as { term?: { id?: number } } + const termId = json.term?.id + if (typeof termId !== 'number') throw new Error('glossary term create returned no id') + return termId +} + +/** Adds (or updates) the translation of a term in a given language. */ +export const setGlossaryTranslation = async ( + glossaryId: number, + termId: number, + languageTag: string, + text: string +): Promise => { + const orgId = getOrganizationId() + const res = await fetch( + `${tolgeeBase()}/v2/organizations/${orgId}/glossaries/${glossaryId}/terms/${termId}/translations`, + { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ languageTag, text }), + } + ) + if (!res.ok) { + throw new Error(`glossary translation set failed: ${res.status} ${await res.text()}`) + } +} diff --git a/apps/example-apps/glossary-keeper/src/App.css b/apps/example-apps/glossary-keeper/src/App.css new file mode 100644 index 00000000000..75dbc81351c --- /dev/null +++ b/apps/example-apps/glossary-keeper/src/App.css @@ -0,0 +1,137 @@ +/* Colors come from applyTolgeeTheme(ctx.theme) → --tg-color-* (hex values are fallbacks). */ +html, +body, +#root { + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, system-ui, sans-serif; + color: var(--tg-color-text, #1d1b20); + background: var(--tg-color-background, #fff); +} + +.gk-page { + padding: 16px 20px; + max-width: 900px; +} + +.gk-page h1 { + margin: 0 0 4px; + font-size: 1.3rem; +} + +.gk-subtitle { + margin: 0 0 16px; + font-size: 0.9rem; + color: var(--tg-color-text-secondary, #6b6375); +} + +.gk-muted { + font-size: 0.9rem; + color: var(--tg-color-text-secondary, #6b6375); +} + +.gk-info { + padding: 12px 14px; + border: 1px solid var(--tg-color-divider, #e4e2dd); + border-radius: 8px; + background: var(--tg-color-background-paper, #f4f3ec); + font-size: 0.9rem; + line-height: 1.45; +} + +.gk-toolbar { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 12px; + margin-bottom: 12px; +} + +.gk-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.78rem; + color: var(--tg-color-text-secondary, #6b6375); +} + +.gk-field select { + font-size: 0.9rem; + padding: 5px 8px; + border-radius: 6px; + border: 1px solid var(--tg-color-divider, #e4e2dd); + background: var(--tg-color-background-paper, #fff); + color: var(--tg-color-text, #1d1b20); +} + +.gk-btn { + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--tg-color-divider, #d0d0d0); + background: var(--tg-color-background-paper, #fafafa); + color: var(--tg-color-text, #1d1b20); + cursor: pointer; + font-size: 0.85rem; +} + +.gk-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.gk-btn-primary { + border-color: transparent; + background: var(--tg-color-primary, #aa3bff); + color: var(--tg-color-primary-contrast, #fff); +} + +.gk-table { + width: 100%; + border-collapse: collapse; +} + +.gk-table th { + text-align: left; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--tg-color-text-secondary, #6b6375); + border-bottom: 1px solid var(--tg-color-divider, #e4e2dd); + padding: 6px 10px; +} + +.gk-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--tg-color-divider, #e4e2dd); + vertical-align: top; +} + +.gk-term { + font-weight: 600; +} + +.gk-desc { + font-size: 0.8rem; + color: var(--tg-color-text-secondary, #6b6375); + margin-top: 2px; +} + +.gk-lang { + display: inline-block; + font-size: 0.7rem; + padding: 1px 6px; + border-radius: 10px; + background: var(--tg-color-background-paper, #f4f3ec); + color: var(--tg-color-text-secondary, #6b6375); + margin-right: 4px; +} + +.gk-page code { + background: var(--tg-color-background-paper, #f4f3ec); + padding: 1px 5px; + border-radius: 4px; + font-size: 0.85em; +} diff --git a/apps/example-apps/glossary-keeper/src/main.tsx b/apps/example-apps/glossary-keeper/src/main.tsx new file mode 100644 index 00000000000..4be99e08849 --- /dev/null +++ b/apps/example-apps/glossary-keeper/src/main.tsx @@ -0,0 +1,37 @@ +import { StrictMode, type ComponentType } from 'react' +import { createRoot } from 'react-dom/client' +import './App.css' + +const ROUTES: Record Promise<{ default: ComponentType }>> = { + '/': () => import('./modules/dashboard'), +} + +const NotFound = () => ( +
+

Glossary Keeper

+

+ No module matches {location.pathname}. +

+
+) + +async function mount() { + const root = createRoot(document.getElementById('root')!) + const loader = ROUTES[location.pathname] + if (!loader) { + root.render( + + + + ) + return + } + const { default: Component } = await loader() + root.render( + + + + ) +} + +mount() diff --git a/apps/example-apps/glossary-keeper/src/modules/dashboard/index.tsx b/apps/example-apps/glossary-keeper/src/modules/dashboard/index.tsx new file mode 100644 index 00000000000..9a508e89a61 --- /dev/null +++ b/apps/example-apps/glossary-keeper/src/modules/dashboard/index.tsx @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + applyTolgeeTheme, + createTolgeeApp, + type TolgeeApp, + type TolgeeAppContext, +} from '@tolgee/apps-sdk/browser' +import './../../App.css' + +type Glossary = { id: number; name: string; baseLanguageTag: string } + +type Suggestion = { + id: string + keyName: string + languageTag: string + term: string + translation: string + description: string +} + +type GlossariesState = + | { status: 'loading' } + | { status: 'unavailable' } + | { status: 'ready'; glossaries: Glossary[] } + +export default function Dashboard() { + const [ctx, setCtx] = useState(null) + const [glossaries, setGlossaries] = useState({ status: 'loading' }) + const [selectedGlossaryId, setSelectedGlossaryId] = useState(null) + const [suggestions, setSuggestions] = useState([]) + const [busy, setBusy] = useState(false) + const containerRef = useRef(null) + const appRef = useRef(null) + + useEffect(() => { + const app = createTolgeeApp() + appRef.current = app + app.context.then(setCtx) + const offTheme = app.onThemeChanged(applyTolgeeTheme) + return () => { + offTheme() + app.dispose() + appRef.current = null + } + }, []) + + // Discover the project's glossaries (none / one / many) via the app token. + useEffect(() => { + if (!ctx) return + const ctrl = new AbortController() + fetch(`${ctx.apiUrl}/v2/projects/${ctx.projectId}/glossaries`, { + headers: { Authorization: `Bearer ${ctx.token}` }, + signal: ctrl.signal, + }) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) + .then((data: { _embedded?: { glossaries?: Glossary[] } }) => { + const list = data._embedded?.glossaries ?? [] + setGlossaries({ status: 'ready', glossaries: list }) + setSelectedGlossaryId((cur) => cur ?? list[0]?.id ?? null) + }) + .catch((err) => { + if (err.name === 'AbortError') return + setGlossaries({ status: 'unavailable' }) + }) + return () => ctrl.abort() + }, [ctx]) + + const loadSuggestions = useCallback(() => { + if (!ctx) return + fetch(`/suggestions?projectId=${ctx.projectId}`) + .then((r) => (r.ok ? r.json() : { suggestions: [] })) + .then((data: { suggestions?: Suggestion[] }) => setSuggestions(data.suggestions ?? [])) + .catch(() => setSuggestions([])) + }, [ctx]) + + useEffect(() => loadSuggestions(), [loadSuggestions]) + + // Keep the iframe sized to its content. + useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver(() => appRef.current?.resize(el.scrollHeight)) + observer.observe(el) + return () => observer.disconnect() + }, []) + + const accept = async (suggestionId: string) => { + if (selectedGlossaryId == null) return + setBusy(true) + try { + const res = await fetch('/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ suggestionId, glossaryId: selectedGlossaryId }), + }) + if (res.ok) setSuggestions((cur) => cur.filter((s) => s.id !== suggestionId)) + } finally { + setBusy(false) + } + } + + const acceptAll = async () => { + if (selectedGlossaryId == null || ctx == null) return + setBusy(true) + try { + await fetch('/accept-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId: ctx.projectId, glossaryId: selectedGlossaryId }), + }) + loadSuggestions() + } finally { + setBusy(false) + } + } + + return ( +
+

Glossary updates

+

+ Suggested glossary entries from recent translation changes. +

+ + {glossaries.status === 'loading' &&

Loading glossaries…

} + + {(glossaries.status === 'unavailable' || + (glossaries.status === 'ready' && glossaries.glossaries.length === 0)) && ( +
+ This project has no glossary yet. Create a glossary and assign it to this project, + then suggestions can be accepted into it. +
+ )} + + {glossaries.status === 'ready' && glossaries.glossaries.length > 0 && ( + <> +
+ + +
+ + {suggestions.length === 0 ? ( +

No pending suggestions. Edit a translation to generate some.

+ ) : ( + + + + + + + + + + {suggestions.map((s) => ( + + + + + + + ))} + +
TermTranslationKey +
+
{s.term}
+
{s.description}
+
+ {s.languageTag} {s.translation} + + {s.keyName} + + +
+ )} + + )} +
+ ) +} diff --git a/apps/example-apps/glossary-keeper/tsconfig.app.json b/apps/example-apps/glossary-keeper/tsconfig.app.json new file mode 100644 index 00000000000..c23f9637f3c --- /dev/null +++ b/apps/example-apps/glossary-keeper/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/apps/example-apps/glossary-keeper/tsconfig.json b/apps/example-apps/glossary-keeper/tsconfig.json new file mode 100644 index 00000000000..1ffef600d95 --- /dev/null +++ b/apps/example-apps/glossary-keeper/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/example-apps/glossary-keeper/tsconfig.node.json b/apps/example-apps/glossary-keeper/tsconfig.node.json new file mode 100644 index 00000000000..44af4f212cd --- /dev/null +++ b/apps/example-apps/glossary-keeper/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts", "server/**/*.ts", "scripts/**/*.ts"] +} diff --git a/apps/example-apps/glossary-keeper/vite.config.ts b/apps/example-apps/glossary-keeper/vite.config.ts new file mode 100644 index 00000000000..ce17b0ea871 --- /dev/null +++ b/apps/example-apps/glossary-keeper/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, loadEnv } from 'vite' +import react from '@vitejs/plugin-react' + +// Ports are env-overridable so multiple example plugins can run side-by-side. +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const vitePort = Number(env.VITE_PORT ?? 5182) + const serverPort = Number(env.SERVER_PORT ?? 5183) + const serverTarget = `http://localhost:${serverPort}` + return { + plugins: [react()], + server: { + port: vitePort, + strictPort: true, + // Cloudflare quick tunnels expose a single port. Vite is the tunnel target; + // these proxies forward Tolgee → Express endpoints through the one hostname. + proxy: { + '/manifest.json': serverTarget, + '/webhook': serverTarget, + '/suggestions': serverTarget, + '/accept': serverTarget, + '/accept-all': serverTarget, + }, + // Quick tunnels rewrite the Host header to trycloudflare.com. + allowedHosts: true, + }, + } +}) diff --git a/apps/example-apps/glossary-keeper/vitest.config.ts b/apps/example-apps/glossary-keeper/vitest.config.ts new file mode 100644 index 00000000000..3b92de9d565 --- /dev/null +++ b/apps/example-apps/glossary-keeper/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['server/**/*.test.ts'], + }, +}) diff --git a/apps/tolgee-apps-dev/dist/cli.js b/apps/tolgee-apps-dev/dist/cli.js new file mode 100755 index 00000000000..726e38a703f --- /dev/null +++ b/apps/tolgee-apps-dev/dist/cli.js @@ -0,0 +1,393 @@ +#!/usr/bin/env node + +// src/lib.ts +import { mkdirSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +var STATE_DIR = join(process.cwd(), ".tolgee-dev"); +var INSTALL_FILE = join(STATE_DIR, "install.json"); +var TUNNEL_FILE = join(STATE_DIR, "tunnel.json"); +var writeJson = (path, value) => { + mkdirSync(STATE_DIR, { recursive: true }); + writeFileSync(path, JSON.stringify(value, null, 2) + "\n", "utf8"); +}; +var readInstallState = () => { + try { + return JSON.parse(readFileSync(INSTALL_FILE, "utf8")); + } catch { + return null; + } +}; +var writeTunnelState = (state) => { + writeJson(TUNNEL_FILE, state); +}; +var clearTunnelState = () => { + const vitePort3 = Number(process.env.VITE_PORT ?? 5180); + writeJson(TUNNEL_FILE, { baseUrl: `http://localhost:${vitePort3}` }); +}; +var readTunnelState = () => { + try { + return JSON.parse(readFileSync(TUNNEL_FILE, "utf8")); + } catch { + return null; + } +}; +var patchManifestUrl = async (install3, manifestUrl) => { + const url = `${install3.tolgeeUrl}/v2/apps/self/manifest-url`; + const res = await fetch(url, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-API-Key": install3.clientSecret + }, + body: JSON.stringify({ manifestUrl }) + }); + if (!res.ok) { + throw new Error( + `PATCH manifest-url failed (${res.status}): ${await res.text()}` + ); + } +}; +var isLocalHost = (urlString) => { + try { + const host = new URL(urlString).hostname; + return host === "localhost" || host === "127.0.0.1" || host === "::1"; + } catch { + return false; + } +}; +var loadEnvLocal = () => { + try { + process.loadEnvFile(join(process.cwd(), ".env.local")); + } catch { + } +}; + +// src/dev.ts +import { existsSync } from "fs"; +import { bin, install, Tunnel } from "cloudflared"; +var vitePort = () => Number(process.env.VITE_PORT ?? 5180); +var localBaseUrl = () => `http://localhost:${vitePort()}`; +var shouldSkipTunnel = (installState) => { + if (process.env.TOLGEE_DEV_TUNNEL === "none") return true; + if (installState && isLocalHost(installState.tolgeeUrl)) return true; + if (process.env.TOLGEE_URL && isLocalHost(process.env.TOLGEE_URL)) return true; + return false; +}; +var ensureBinary = async () => { + if (!existsSync(bin)) { + console.log("[tunnel] installing cloudflared binary\u2026"); + await install(bin); + } +}; +var startTunnel = async () => { + const tunnel = Tunnel.quick(`http://localhost:${vitePort()}`); + const url = await new Promise((resolve) => { + tunnel.once("url", resolve); + }); + tunnel.on("exit", (code) => { + console.log(`[tunnel] cloudflared exited with code ${code}`); + }); + process.once("SIGINT", () => tunnel.stop()); + process.once("SIGTERM", () => tunnel.stop()); + return url; +}; +var maybePatchManifestUrl = async (installState, baseUrl) => { + if (!installState) { + console.log( + "[tunnel] no install registered \u2014 run `tolgee-app register` to install the plugin against a Tolgee organization." + ); + return; + } + try { + await patchManifestUrl(installState, `${baseUrl}/manifest.json`); + console.log( + `[tunnel] PATCHed install ${installState.installId} to use ${baseUrl}/manifest.json` + ); + } catch (err) { + console.error("[tunnel] manifest-url update failed:", err); + } +}; +var runDev = async () => { + clearTunnelState(); + const installState = readInstallState(); + if (shouldSkipTunnel(installState)) { + console.log( + `[tunnel] skipping cloudflared \u2014 Tolgee reaches the plugin at ${localBaseUrl()}` + ); + writeTunnelState({ baseUrl: localBaseUrl() }); + await maybePatchManifestUrl(installState, localBaseUrl()); + const heartbeat = setInterval(() => { + }, 1 << 30); + await new Promise((resolve) => { + process.once("SIGINT", () => resolve()); + process.once("SIGTERM", () => resolve()); + }); + clearInterval(heartbeat); + return; + } + await ensureBinary(); + const baseUrl = await startTunnel(); + console.log(`[tunnel] live at ${baseUrl}`); + writeTunnelState({ baseUrl }); + await maybePatchManifestUrl(installState, baseUrl); +}; + +// src/register.ts +import { createInterface } from "readline/promises"; +import { existsSync as existsSync2 } from "fs"; +import { + createServer +} from "http"; +import { randomUUID, timingSafeEqual } from "crypto"; +import open from "open"; +import { bin as bin2, install as install2, Tunnel as Tunnel2 } from "cloudflared"; +var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +var BROWSER_TIMEOUT_MS = 5 * 6e4; +var vitePort2 = () => Number(process.env.VITE_PORT ?? 5180); +var serverPort = () => Number(process.env.SERVER_PORT ?? process.env.PORT ?? 5181); +var verifyManifestReachable = async (manifestUrl) => { + const attempts = 5; + let lastError; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const res = await fetch(manifestUrl); + if (res.ok) return; + lastError = new Error(`${manifestUrl} returned ${res.status}`); + } catch (err) { + lastError = err; + } + if (attempt < attempts) await sleep(1e3); + } + throw new Error( + `Could not reach ${manifestUrl}. Is \`npm run dev\` running in another terminal? (${lastError instanceof Error ? lastError.message : String(lastError)})` + ); +}; +var startTunnel2 = async () => { + if (!existsSync2(bin2)) { + console.log("Installing cloudflared binary\u2026"); + await install2(bin2); + } + console.log("Starting Cloudflare quick tunnel\u2026"); + const tunnel = Tunnel2.quick(`http://localhost:${vitePort2()}`); + const baseUrl = await new Promise( + (resolve) => tunnel.once("url", resolve) + ); + console.log(`Tunnel live at ${baseUrl}`); + return baseUrl; +}; +var reusableTunnel = async () => { + const baseUrl = readTunnelState()?.baseUrl; + if (!baseUrl || isLocalHost(baseUrl)) return null; + try { + const res = await fetch(`${baseUrl}/manifest.json`); + if (res.ok) { + console.log(`Reusing live tunnel from \`tolgee-app dev\`: ${baseUrl}`); + return baseUrl; + } + } catch { + } + return null; +}; +var resolveManifestUrl = async (tolgeeUrl) => { + const localManifestUrl = `http://localhost:${serverPort()}/manifest.json`; + if (isLocalHost(tolgeeUrl)) { + writeTunnelState({ baseUrl: `http://localhost:${vitePort2()}` }); + console.log(`Skipping tunnel \u2014 Tolgee is local; using ${localManifestUrl}`); + return localManifestUrl; + } + const base = await reusableTunnel() ?? await startTunnel2(); + writeTunnelState({ baseUrl: base }); + return `${base}/manifest.json`; +}; +var constantTimeEq = (a, b) => { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) return false; + return timingSafeEqual(ab, bb); +}; +var browserRegister = async (tolgeeUrl, manifestUrl) => { + const state = randomUUID(); + return await new Promise((resolve, reject) => { + let settled = false; + const finish = (kind, value) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + server.close(); + if (kind === "resolve") resolve(value); + else reject(value); + }; + const server = createServer((req, res) => { + const url = new URL(req.url ?? "", `http://127.0.0.1`); + if (url.pathname !== "/cb") { + res.statusCode = 404; + res.end("Not found"); + return; + } + const incomingState = url.searchParams.get("state") ?? ""; + if (!constantTimeEq(incomingState, state)) { + res.statusCode = 400; + res.end(htmlPage("State mismatch \u2014 refusing this callback.", false)); + finish("reject", new Error("Callback state did not match.")); + return; + } + const error = url.searchParams.get("error"); + if (error) { + res.end(htmlPage(`Install ${error}. You can close this tab.`, false)); + finish("reject", new Error(`Install ${error}`)); + return; + } + const installId = Number(url.searchParams.get("installId")); + const organizationId = Number(url.searchParams.get("organizationId")); + const clientId = url.searchParams.get("clientId") ?? ""; + const clientSecret = url.searchParams.get("clientSecret") ?? ""; + const webhookSecret = url.searchParams.get("webhookSecret") ?? ""; + if (!installId || !organizationId || !clientId || !clientSecret) { + res.statusCode = 400; + res.end(htmlPage("Callback missing required fields.", false)); + finish("reject", new Error("Callback missing required fields.")); + return; + } + res.end(htmlPage("Installed. You can close this tab.", true)); + finish("resolve", { + tolgeeUrl, + organizationId, + installId, + clientId, + clientSecret, + webhookSecret + }); + }); + const timeout = setTimeout( + () => finish("reject", new Error("Browser flow timed out.")), + BROWSER_TIMEOUT_MS + ); + server.on("error", (err) => finish("reject", err)); + server.listen(0, "127.0.0.1", async () => { + const address = server.address(); + if (!address || typeof address === "string") { + finish("reject", new Error("Failed to bind localhost port")); + return; + } + const callback = `http://127.0.0.1:${address.port}/cb`; + const installUrl = `${tolgeeUrl.replace(/\/$/, "")}/install-app?manifestUrl=${encodeURIComponent(manifestUrl)}&callback=${encodeURIComponent(callback)}&state=${encodeURIComponent(state)}`; + console.log(` +Opening browser to install the plugin\u2026`); + console.log(` ${installUrl} +`); + console.log( + `If the browser doesn't open automatically, paste that URL into it. +Waiting up to ${BROWSER_TIMEOUT_MS / 1e3}s for you to approve\u2026` + ); + try { + await open(installUrl); + } catch { + } + }); + }); +}; +var htmlPage = (message, ok) => { + const color = ok ? "#1b8c43" : "#c44343"; + return ` +Tolgee plugin install + +
${ok ? "\u2713 Done" : "\u2717 Stopped"}
+

${message}

+`; +}; +var patRegister = async (tolgeeUrl, manifestUrl, pat) => { + if (!pat.startsWith("tgpat_")) { + throw new Error("--pat value must start with tgpat_"); + } + const orgs = await (async () => { + const res2 = await fetch(`${tolgeeUrl}/v2/organizations?size=100`, { + headers: { "X-API-Key": pat } + }); + if (!res2.ok) { + throw new Error( + `Could not list organizations (${res2.status}): ${await res2.text()}` + ); + } + const json = await res2.json(); + return json._embedded?.organizations ?? []; + })(); + if (orgs.length === 0) { + throw new Error("No organizations visible to this PAT."); + } + console.log("\nOrganizations:"); + orgs.forEach((o, i) => console.log(` ${i + 1}. ${o.name} (${o.slug})`)); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const idxStr = (await rl.question(`Pick one [1-${orgs.length}]: `)).trim(); + rl.close(); + const idx = Number.parseInt(idxStr, 10) - 1; + const org = orgs[idx]; + if (!org) throw new Error("Invalid selection"); + const res = await fetch(`${tolgeeUrl}/v2/organizations/${org.id}/apps`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-API-Key": pat }, + body: JSON.stringify({ manifestUrl }) + }); + if (!res.ok) { + throw new Error(`Register failed (${res.status}): ${await res.text()}`); + } + const data = await res.json(); + return { + tolgeeUrl, + organizationId: org.id, + installId: data.id, + clientId: data.clientId, + clientSecret: data.clientSecret, + webhookSecret: data.webhookSecret + }; +}; +var runRegister = async (args) => { + const patFlag = args.find((a) => a.startsWith("--pat="))?.slice("--pat=".length) ?? null; + if (existsSync2(INSTALL_FILE)) { + console.log( + `Install record already exists at ${INSTALL_FILE}. Delete it to re-register, or just run \`npm run dev\` to keep using the existing install.` + ); + return; + } + const tolgeeUrl = (process.env.TOLGEE_URL ?? "https://app.tolgee.io").replace( + /\/$/, + "" + ); + const manifestUrl = await resolveManifestUrl(tolgeeUrl); + await verifyManifestReachable(manifestUrl); + const state = patFlag ? await patRegister(tolgeeUrl, manifestUrl, patFlag) : await browserRegister(tolgeeUrl, manifestUrl); + writeJson(INSTALL_FILE, state); + console.log( + ` +Registered. Install ${state.installId} for org ${state.organizationId}. Saved to ${INSTALL_FILE}.` + ); +}; + +// src/cli.ts +var USAGE = `tolgee-app \u2014 Tolgee Apps dev toolchain + +Usage: + tolgee-app dev Boot the tunnel + repoint the install's manifest URL + tolgee-app register One-time install against a Tolgee org (browser flow) + Pass --pat=tgpat_\u2026 for the headless flow. +`; +var main = async () => { + loadEnvLocal(); + const [command, ...rest] = process.argv.slice(2); + if (command === "dev") { + await runDev(); + return; + } + if (command === "register") { + await runRegister(rest); + return; + } + console.log(USAGE); + process.exit(command ? 1 : 0); +}; +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/apps/tolgee-apps-dev/package.json b/apps/tolgee-apps-dev/package.json new file mode 100644 index 00000000000..269d17e2510 --- /dev/null +++ b/apps/tolgee-apps-dev/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tolgee/apps-dev", + "version": "0.0.1-alpha.7", + "description": "Dev toolchain for Tolgee Apps — `tolgee-app dev` (tunnel + manifest repoint) and `tolgee-app register` (browser install).", + "type": "module", + "bin": { + "tolgee-app": "./dist/cli.js" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup" + }, + "dependencies": { + "cloudflared": "^0.7.1", + "open": "^10.1.0" + }, + "devDependencies": { + "@types/node": "^24.12.3", + "tsup": "^8.3.5", + "typescript": "~6.0.2" + }, + "engines": { + "node": ">=20.12" + } +} diff --git a/apps/tolgee-apps-dev/src/cli.ts b/apps/tolgee-apps-dev/src/cli.ts new file mode 100644 index 00000000000..2f0edb3ad93 --- /dev/null +++ b/apps/tolgee-apps-dev/src/cli.ts @@ -0,0 +1,33 @@ +import { loadEnvLocal } from './lib' +import { runDev } from './dev' +import { runRegister } from './register' + +const USAGE = `tolgee-app — Tolgee Apps dev toolchain + +Usage: + tolgee-app dev Boot the tunnel + repoint the install's manifest URL + tolgee-app register One-time install against a Tolgee org (browser flow) + Pass --pat=tgpat_… for the headless flow. +` + +const main = async (): Promise => { + loadEnvLocal() + const [command, ...rest] = process.argv.slice(2) + + if (command === 'dev') { + await runDev() + return + } + if (command === 'register') { + await runRegister(rest) + return + } + + console.log(USAGE) + process.exit(command ? 1 : 0) +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/apps/tolgee-apps-dev/src/dev.ts b/apps/tolgee-apps-dev/src/dev.ts new file mode 100644 index 00000000000..45e52f2a9c7 --- /dev/null +++ b/apps/tolgee-apps-dev/src/dev.ts @@ -0,0 +1,93 @@ +import { existsSync } from 'node:fs' +import { bin, install, Tunnel } from 'cloudflared' +import { + clearTunnelState, + isLocalHost, + patchManifestUrl, + readInstallState, + writeTunnelState, + type InstallState, +} from './lib' + +const vitePort = (): number => Number(process.env.VITE_PORT ?? 5180) +const localBaseUrl = (): string => `http://localhost:${vitePort()}` + +const shouldSkipTunnel = (installState: InstallState | null): boolean => { + if (process.env.TOLGEE_DEV_TUNNEL === 'none') return true + if (installState && isLocalHost(installState.tolgeeUrl)) return true + if (process.env.TOLGEE_URL && isLocalHost(process.env.TOLGEE_URL)) return true + return false +} + +const ensureBinary = async (): Promise => { + if (!existsSync(bin)) { + console.log('[tunnel] installing cloudflared binary…') + await install(bin) + } +} + +const startTunnel = async (): Promise => { + const tunnel = Tunnel.quick(`http://localhost:${vitePort()}`) + const url = await new Promise((resolve) => { + tunnel.once('url', resolve) + }) + tunnel.on('exit', (code) => { + console.log(`[tunnel] cloudflared exited with code ${code}`) + }) + process.once('SIGINT', () => tunnel.stop()) + process.once('SIGTERM', () => tunnel.stop()) + return url +} + +const maybePatchManifestUrl = async ( + installState: InstallState | null, + baseUrl: string +): Promise => { + if (!installState) { + console.log( + '[tunnel] no install registered — run `tolgee-app register` to ' + + 'install the plugin against a Tolgee organization.' + ) + return + } + try { + await patchManifestUrl(installState, `${baseUrl}/manifest.json`) + console.log( + `[tunnel] PATCHed install ${installState.installId} to use ${baseUrl}/manifest.json` + ) + } catch (err) { + console.error('[tunnel] manifest-url update failed:', err) + } +} + +/** + * Boots a Cloudflare quick tunnel pointing at the local Vite server, writes its + * URL to `.tolgee-dev/tunnel.json` (read by the manifest route), and repoints + * the registered install at it. Skips the tunnel when Tolgee is on localhost. + */ +export const runDev = async (): Promise => { + clearTunnelState() + const installState = readInstallState() + + if (shouldSkipTunnel(installState)) { + console.log( + `[tunnel] skipping cloudflared — Tolgee reaches the plugin at ${localBaseUrl()}` + ) + writeTunnelState({ baseUrl: localBaseUrl() }) + await maybePatchManifestUrl(installState, localBaseUrl()) + // Park so `concurrently -k` doesn't tear down the other processes. + const heartbeat = setInterval(() => {}, 1 << 30) + await new Promise((resolve) => { + process.once('SIGINT', () => resolve()) + process.once('SIGTERM', () => resolve()) + }) + clearInterval(heartbeat) + return + } + + await ensureBinary() + const baseUrl = await startTunnel() + console.log(`[tunnel] live at ${baseUrl}`) + writeTunnelState({ baseUrl }) + await maybePatchManifestUrl(installState, baseUrl) +} diff --git a/apps/tolgee-apps-dev/src/lib.ts b/apps/tolgee-apps-dev/src/lib.ts new file mode 100644 index 00000000000..1d741886c6a --- /dev/null +++ b/apps/tolgee-apps-dev/src/lib.ts @@ -0,0 +1,100 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * State lives under the app being developed — i.e. the directory `tolgee-app` + * is invoked from — not under this package. + */ +export const STATE_DIR = join(process.cwd(), '.tolgee-dev') +export const INSTALL_FILE = join(STATE_DIR, 'install.json') +export const TUNNEL_FILE = join(STATE_DIR, 'tunnel.json') + +export type InstallState = { + tolgeeUrl: string + organizationId: number + installId: number + /** + * Plugin credentials Tolgee returned when this install was registered. + * `clientSecret` (`X-API-Key: tgapps_…`) authenticates every outbound call + * the plugin makes back to Tolgee — webhook handler lookups AND the + * self-service manifest-url PATCH the dev orchestrator fires on every + * restart. `webhookSecret` signs the inbound webhook bodies. + */ + clientId: string + clientSecret: string + webhookSecret: string +} + +export type TunnelState = { + baseUrl: string +} + +export const writeJson = (path: string, value: unknown): void => { + mkdirSync(STATE_DIR, { recursive: true }) + writeFileSync(path, JSON.stringify(value, null, 2) + '\n', 'utf8') +} + +export const readInstallState = (): InstallState | null => { + try { + return JSON.parse(readFileSync(INSTALL_FILE, 'utf8')) as InstallState + } catch { + return null + } +} + +export const writeTunnelState = (state: TunnelState): void => { + writeJson(TUNNEL_FILE, state) +} + +export const clearTunnelState = (): void => { + const vitePort = Number(process.env.VITE_PORT ?? 5180) + writeJson(TUNNEL_FILE, { baseUrl: `http://localhost:${vitePort}` }) +} + +export const readTunnelState = (): TunnelState | null => { + try { + return JSON.parse(readFileSync(TUNNEL_FILE, 'utf8')) as TunnelState + } catch { + return null + } +} + +export const patchManifestUrl = async ( + install: InstallState, + manifestUrl: string +): Promise => { + // Self-service endpoint — the install authenticates as itself with its + // clientSecret and may only update its own manifest URL. + const url = `${install.tolgeeUrl}/v2/apps/self/manifest-url` + const res = await fetch(url, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': install.clientSecret, + }, + body: JSON.stringify({ manifestUrl }), + }) + if (!res.ok) { + throw new Error( + `PATCH manifest-url failed (${res.status}): ${await res.text()}` + ) + } +} + +export const isLocalHost = (urlString: string): boolean => { + try { + const host = new URL(urlString).hostname + return host === 'localhost' || host === '127.0.0.1' || host === '::1' + } catch { + return false + } +} + +/** Loads `.env.local` from the app dir if present (replaces tsx's --env-file-if-exists). */ +export const loadEnvLocal = (): void => { + try { + process.loadEnvFile(join(process.cwd(), '.env.local')) + } catch { + // no .env.local — rely on the ambient environment + } +} diff --git a/apps/tolgee-apps-dev/src/register.ts b/apps/tolgee-apps-dev/src/register.ts new file mode 100644 index 00000000000..21856ef3d33 --- /dev/null +++ b/apps/tolgee-apps-dev/src/register.ts @@ -0,0 +1,311 @@ +import { createInterface } from 'node:readline/promises' +import { existsSync } from 'node:fs' +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http' +import { randomUUID, timingSafeEqual } from 'node:crypto' +import open from 'open' +import { bin, install, Tunnel } from 'cloudflared' +import { + INSTALL_FILE, + isLocalHost, + readTunnelState, + writeJson, + writeTunnelState, + type InstallState, +} from './lib' + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)) + +type Org = { id: number; name: string; slug: string } + +const BROWSER_TIMEOUT_MS = 5 * 60_000 + +const vitePort = (): number => Number(process.env.VITE_PORT ?? 5180) +const serverPort = (): number => + Number(process.env.SERVER_PORT ?? process.env.PORT ?? 5181) + +const verifyManifestReachable = async (manifestUrl: string): Promise => { + // A freshly-started quick tunnel can take a moment to become edge-routable, + // so retry a few times before giving up. + const attempts = 5 + let lastError: unknown + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const res = await fetch(manifestUrl) + if (res.ok) return + lastError = new Error(`${manifestUrl} returned ${res.status}`) + } catch (err) { + lastError = err + } + if (attempt < attempts) await sleep(1000) + } + throw new Error( + `Could not reach ${manifestUrl}. Is \`npm run dev\` running in another terminal? (${ + lastError instanceof Error ? lastError.message : String(lastError) + })` + ) +} + +const startTunnel = async (): Promise => { + if (!existsSync(bin)) { + console.log('Installing cloudflared binary…') + await install(bin) + } + console.log('Starting Cloudflare quick tunnel…') + const tunnel = Tunnel.quick(`http://localhost:${vitePort()}`) + const baseUrl = await new Promise((resolve) => + tunnel.once('url', resolve) + ) + console.log(`Tunnel live at ${baseUrl}`) + return baseUrl +} + +/** + * Reuses a live tunnel that `tolgee-app dev` already opened, if any. Reads the + * URL `dev` persisted to tunnel.json and confirms it actually serves the + * manifest — tunnel.json can hold a stale/dead URL from a previous session, and + * a localhost value means no tunnel is up. Returns null in those cases so the + * caller starts its own tunnel. + */ +const reusableTunnel = async (): Promise => { + const baseUrl = readTunnelState()?.baseUrl + if (!baseUrl || isLocalHost(baseUrl)) return null + try { + const res = await fetch(`${baseUrl}/manifest.json`) + if (res.ok) { + console.log(`Reusing live tunnel from \`tolgee-app dev\`: ${baseUrl}`) + return baseUrl + } + } catch { + // unreachable (stale URL) — fall back to starting our own tunnel + } + return null +} + +/** + * Picks the manifest URL Tolgee will fetch + writes the tunnel state so the + * local server serves the right baseUrl. + * local Tolgee → http://localhost:/manifest.json (Express direct) + * remote Tolgee → reuse `dev`'s live tunnel, or open a fresh Cloudflare one + */ +const resolveManifestUrl = async (tolgeeUrl: string): Promise => { + const localManifestUrl = `http://localhost:${serverPort()}/manifest.json` + if (isLocalHost(tolgeeUrl)) { + writeTunnelState({ baseUrl: `http://localhost:${vitePort()}` }) + console.log(`Skipping tunnel — Tolgee is local; using ${localManifestUrl}`) + return localManifestUrl + } + const base = (await reusableTunnel()) ?? (await startTunnel()) + writeTunnelState({ baseUrl: base }) + return `${base}/manifest.json` +} + +const constantTimeEq = (a: string, b: string): boolean => { + const ab = Buffer.from(a) + const bb = Buffer.from(b) + if (ab.length !== bb.length) return false + return timingSafeEqual(ab, bb) +} + +/** + * Browser flow: spins up a one-shot HTTP server on 127.0.0.1, opens the Tolgee + * install page, waits for the redirect callback. + */ +const browserRegister = async ( + tolgeeUrl: string, + manifestUrl: string +): Promise => { + const state = randomUUID() + return await new Promise((resolve, reject) => { + let settled = false + const finish = ( + kind: 'resolve' | 'reject', + value: InstallState | Error + ) => { + if (settled) return + settled = true + clearTimeout(timeout) + server.close() + if (kind === 'resolve') resolve(value as InstallState) + else reject(value as Error) + } + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '', `http://127.0.0.1`) + if (url.pathname !== '/cb') { + res.statusCode = 404 + res.end('Not found') + return + } + const incomingState = url.searchParams.get('state') ?? '' + if (!constantTimeEq(incomingState, state)) { + res.statusCode = 400 + res.end(htmlPage('State mismatch — refusing this callback.', false)) + finish('reject', new Error('Callback state did not match.')) + return + } + const error = url.searchParams.get('error') + if (error) { + res.end(htmlPage(`Install ${error}. You can close this tab.`, false)) + finish('reject', new Error(`Install ${error}`)) + return + } + const installId = Number(url.searchParams.get('installId')) + const organizationId = Number(url.searchParams.get('organizationId')) + const clientId = url.searchParams.get('clientId') ?? '' + const clientSecret = url.searchParams.get('clientSecret') ?? '' + const webhookSecret = url.searchParams.get('webhookSecret') ?? '' + if (!installId || !organizationId || !clientId || !clientSecret) { + res.statusCode = 400 + res.end(htmlPage('Callback missing required fields.', false)) + finish('reject', new Error('Callback missing required fields.')) + return + } + res.end(htmlPage('Installed. You can close this tab.', true)) + finish('resolve', { + tolgeeUrl, + organizationId, + installId, + clientId, + clientSecret, + webhookSecret, + }) + }) + + const timeout = setTimeout( + () => finish('reject', new Error('Browser flow timed out.')), + BROWSER_TIMEOUT_MS + ) + + server.on('error', (err) => finish('reject', err)) + server.listen(0, '127.0.0.1', async () => { + const address = server.address() + if (!address || typeof address === 'string') { + finish('reject', new Error('Failed to bind localhost port')) + return + } + const callback = `http://127.0.0.1:${address.port}/cb` + const installUrl = + `${tolgeeUrl.replace(/\/$/, '')}/install-app` + + `?manifestUrl=${encodeURIComponent(manifestUrl)}` + + `&callback=${encodeURIComponent(callback)}` + + `&state=${encodeURIComponent(state)}` + console.log(`\nOpening browser to install the plugin…`) + console.log(` ${installUrl}\n`) + console.log( + `If the browser doesn't open automatically, paste that URL into it.\n` + + `Waiting up to ${BROWSER_TIMEOUT_MS / 1000}s for you to approve…` + ) + try { + await open(installUrl) + } catch { + // ignore — user can paste the URL manually + } + }) + }) +} + +const htmlPage = (message: string, ok: boolean): string => { + const color = ok ? '#1b8c43' : '#c44343' + return ` +Tolgee plugin install + +
${ok ? '✓ Done' : '✗ Stopped'}
+

${message}

+` +} + +/** Headless fallback: prompts for org by listing them via PAT. */ +const patRegister = async ( + tolgeeUrl: string, + manifestUrl: string, + pat: string +): Promise => { + if (!pat.startsWith('tgpat_')) { + throw new Error('--pat value must start with tgpat_') + } + const orgs = await (async () => { + const res = await fetch(`${tolgeeUrl}/v2/organizations?size=100`, { + headers: { 'X-API-Key': pat }, + }) + if (!res.ok) { + throw new Error( + `Could not list organizations (${res.status}): ${await res.text()}` + ) + } + const json = (await res.json()) as { _embedded?: { organizations?: Org[] } } + return json._embedded?.organizations ?? [] + })() + if (orgs.length === 0) { + throw new Error('No organizations visible to this PAT.') + } + console.log('\nOrganizations:') + orgs.forEach((o, i) => console.log(` ${i + 1}. ${o.name} (${o.slug})`)) + const rl = createInterface({ input: process.stdin, output: process.stdout }) + const idxStr = (await rl.question(`Pick one [1-${orgs.length}]: `)).trim() + rl.close() + const idx = Number.parseInt(idxStr, 10) - 1 + const org = orgs[idx] + if (!org) throw new Error('Invalid selection') + + const res = await fetch(`${tolgeeUrl}/v2/organizations/${org.id}/apps`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': pat }, + body: JSON.stringify({ manifestUrl }), + }) + if (!res.ok) { + throw new Error(`Register failed (${res.status}): ${await res.text()}`) + } + const data = (await res.json()) as { + id: number + clientId: string + clientSecret: string + webhookSecret: string + } + return { + tolgeeUrl, + organizationId: org.id, + installId: data.id, + clientId: data.clientId, + clientSecret: data.clientSecret, + webhookSecret: data.webhookSecret, + } +} + +/** One-time install bootstrap. Pass `--pat=tgpat_…` for the headless flow. */ +export const runRegister = async (args: string[]): Promise => { + const patFlag = + args.find((a) => a.startsWith('--pat='))?.slice('--pat='.length) ?? null + + if (existsSync(INSTALL_FILE)) { + console.log( + `Install record already exists at ${INSTALL_FILE}. Delete it to re-register, ` + + 'or just run `npm run dev` to keep using the existing install.' + ) + return + } + + const tolgeeUrl = (process.env.TOLGEE_URL ?? 'https://app.tolgee.io').replace( + /\/$/, + '' + ) + const manifestUrl = await resolveManifestUrl(tolgeeUrl) + await verifyManifestReachable(manifestUrl) + + const state = patFlag + ? await patRegister(tolgeeUrl, manifestUrl, patFlag) + : await browserRegister(tolgeeUrl, manifestUrl) + + writeJson(INSTALL_FILE, state) + console.log( + `\nRegistered. Install ${state.installId} for org ${state.organizationId}. ` + + `Saved to ${INSTALL_FILE}.` + ) +} diff --git a/apps/tolgee-apps-dev/tsconfig.json b/apps/tolgee-apps-dev/tsconfig.json new file mode 100644 index 00000000000..08fda6d9c7c --- /dev/null +++ b/apps/tolgee-apps-dev/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true + }, + "include": ["src"] +} diff --git a/apps/tolgee-apps-dev/tsup.config.ts b/apps/tolgee-apps-dev/tsup.config.ts new file mode 100644 index 00000000000..c376be60408 --- /dev/null +++ b/apps/tolgee-apps-dev/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/cli.ts'], + outDir: 'dist', + format: ['esm'], + target: 'node20', + clean: true, + banner: { js: '#!/usr/bin/env node' }, +}) diff --git a/apps/tolgee-apps-sdk/README.md b/apps/tolgee-apps-sdk/README.md new file mode 100644 index 00000000000..a4961b40173 --- /dev/null +++ b/apps/tolgee-apps-sdk/README.md @@ -0,0 +1,24 @@ +# @tolgee/apps-sdk + +SDK for building Tolgee Apps plugins. **Skeleton** — public API not yet stable. + +## What lives here (planned) + +- **Manifest schema** — Zod-typed authoring (`defineManifest({...})`) so + module-key typos and missing required fields fail at build time. +- **postMessage handshake** — typed helpers for the iframe side of the + `tolgee-app:ready` / `tolgee-app:init` / `tolgee-app:resize` / + `tolgee-app:selection-changed` / `tolgee-app:close` protocol. +- **JWT context decoder** — read `tg.app.inst`, `tg.app.proj`, `sub` out + of the init token so a plugin can call back into Tolgee's REST API as + the install + user. +- **Webhook signature verifier** — middleware matching the + `Tolgee-Signature: {"timestamp": , "signature": ""}` header + scheme. HMAC-SHA256 over `${timestamp}.${rawBody}`, constant-time + comparison. +- **Typed webhook handler dispatcher** — narrow `payload.activityData.type` + to give plugin authors a fully-typed payload per `ActivityType`. + +Reference implementation today lives in +[`example-apps/dev-plugin`](../example-apps/dev-plugin/). Code will be +lifted from there as the contract stabilises. diff --git a/apps/tolgee-apps-sdk/dist/browser.d.ts b/apps/tolgee-apps-sdk/dist/browser.d.ts new file mode 100644 index 00000000000..db17e154f9b --- /dev/null +++ b/apps/tolgee-apps-sdk/dist/browser.d.ts @@ -0,0 +1,86 @@ +import { T as TolgeeAppContext, a as TolgeeAppSelection, b as TolgeeAppTheme } from './contextTypes-xgD-1LAp.js'; +import { createApiClient } from '@tginternal/client'; + +type Unsubscribe = () => void; +/** + * Iframe-side handle to the Tolgee Apps postMessage protocol. + * + * Construct via `createTolgeeApp()`. Sends `tolgee-app:ready` to the + * parent automatically on the next microtask, so you can attach + * listeners before the host's init message arrives. The first init + * resolves `context`; subsequent selection changes fire registered + * selection handlers. + */ +declare class TolgeeApp { + private contextPromise; + private resolveContext; + private selectionHandlers; + private currentSelection; + private themeHandlers; + private currentTheme; + constructor(); + /** + * Resolves with the init payload the host posted via + * `tolgee-app:init`. Will never resolve outside a Tolgee iframe — + * test for `window.parent !== window` before constructing if you + * need a fallback path. + */ + get context(): Promise; + /** + * Subscribe to selection changes (the host posts these when the user + * focuses a different key/translation cell). Returns an unsubscribe + * function. The handler also fires once with the initial selection + * after init. + */ + onSelectionChanged(handler: (selection: TolgeeAppSelection) => void): Unsubscribe; + /** + * Subscribe to host theme changes (light/dark toggles). Returns an + * unsubscribe function and fires once with the current theme after init. + * Pair with `applyTolgeeTheme` to restyle the iframe live. + */ + onThemeChanged(handler: (theme: TolgeeAppTheme) => void): Unsubscribe; + /** Asks the host to close the modal/panel containing this iframe. */ + close(): void; + /** Tells the host how tall this iframe wants to be. */ + resize(height: number): void; + /** Detaches the message listener. Safe to call multiple times. */ + dispose(): void; + private onMessage; +} +declare const createTolgeeApp: () => TolgeeApp; + +/** + * Builds a typed Tolgee REST client wired with the install-context + * token, base URL, and project id from the iframe's + * `TolgeeAppContext`. Errors are returned in the `error` field rather + * than thrown, matching `createApiClient`'s `autoThrow: false` mode. + * + * const app = createTolgeeApp() + * const ctx = await app.context + * const tolgee = createTolgeeAppClient(ctx) + * const { data, error } = await tolgee.GET('/v2/projects/{projectId}', { + * params: { path: { projectId: ctx.projectId } }, + * }) + */ +declare const createTolgeeAppClient: (context: TolgeeAppContext) => ReturnType; + +/** + * Applies the host theme to the iframe document so the plugin matches Tolgee: + * exposes each palette color as a `--tg-color-` CSS custom property + * (e.g. `--tg-color-background`, `--tg-color-text-secondary`), sets + * `[data-tg-theme="light|dark"]`, and sets `color-scheme` (so native form + * controls and scrollbars follow the mode). + * + * Call it once with `ctx.theme` and again from `onThemeChanged` to follow live + * light/dark toggles: + * + * ```ts + * const app = createTolgeeApp() + * const ctx = await app.context + * applyTolgeeTheme(ctx.theme) + * app.onThemeChanged(applyTolgeeTheme) + * ``` + */ +declare const applyTolgeeTheme: (theme: TolgeeAppTheme, root?: HTMLElement) => void; + +export { TolgeeApp, TolgeeAppContext, TolgeeAppSelection, TolgeeAppTheme, applyTolgeeTheme, createTolgeeApp, createTolgeeAppClient }; diff --git a/apps/tolgee-apps-sdk/dist/browser.js b/apps/tolgee-apps-sdk/dist/browser.js new file mode 100644 index 00000000000..4d1f06c0f29 --- /dev/null +++ b/apps/tolgee-apps-sdk/dist/browser.js @@ -0,0 +1,156 @@ +// src/browser/tolgeeApp.ts +var KNOWN_INIT_FIELDS = /* @__PURE__ */ new Set([ + "type", + "token", + "apiUrl", + "organizationId", + "projectId", + "keyId", + "languageId", + "translationId", + "languageTag", + "selectedLanguages", + "theme" +]); +var isInit = (d) => typeof d === "object" && d !== null && d.type === "tolgee-app:init"; +var isSelectionChanged = (d) => typeof d === "object" && d !== null && d.type === "tolgee-app:selection-changed"; +var isThemeChanged = (d) => typeof d === "object" && d !== null && d.type === "tolgee-app:theme-changed"; +var parseInit = (m) => { + const extra = {}; + for (const [k, v] of Object.entries(m)) { + if (!KNOWN_INIT_FIELDS.has(k)) extra[k] = v; + } + return { + token: m.token, + apiUrl: m.apiUrl, + organizationId: m.organizationId, + projectId: m.projectId, + selection: { + keyId: m.keyId, + languageId: m.languageId, + languageTag: m.languageTag, + translationId: m.translationId, + selectedLanguages: m.selectedLanguages + }, + theme: m.theme, + extra + }; +}; +var TolgeeApp = class { + contextPromise; + resolveContext; + selectionHandlers = /* @__PURE__ */ new Set(); + currentSelection = {}; + themeHandlers = /* @__PURE__ */ new Set(); + currentTheme; + constructor() { + this.contextPromise = new Promise((resolve) => { + this.resolveContext = resolve; + }); + window.addEventListener("message", this.onMessage); + queueMicrotask(() => { + window.parent.postMessage({ type: "tolgee-app:ready" }, "*"); + }); + } + /** + * Resolves with the init payload the host posted via + * `tolgee-app:init`. Will never resolve outside a Tolgee iframe — + * test for `window.parent !== window` before constructing if you + * need a fallback path. + */ + get context() { + return this.contextPromise; + } + /** + * Subscribe to selection changes (the host posts these when the user + * focuses a different key/translation cell). Returns an unsubscribe + * function. The handler also fires once with the initial selection + * after init. + */ + onSelectionChanged(handler) { + this.selectionHandlers.add(handler); + if (Object.values(this.currentSelection).some((v) => v !== void 0)) { + handler(this.currentSelection); + } + return () => { + this.selectionHandlers.delete(handler); + }; + } + /** + * Subscribe to host theme changes (light/dark toggles). Returns an + * unsubscribe function and fires once with the current theme after init. + * Pair with `applyTolgeeTheme` to restyle the iframe live. + */ + onThemeChanged(handler) { + this.themeHandlers.add(handler); + if (this.currentTheme) handler(this.currentTheme); + return () => { + this.themeHandlers.delete(handler); + }; + } + /** Asks the host to close the modal/panel containing this iframe. */ + close() { + window.parent.postMessage({ type: "tolgee-app:close" }, "*"); + } + /** Tells the host how tall this iframe wants to be. */ + resize(height) { + window.parent.postMessage({ type: "tolgee-app:resize", height }, "*"); + } + /** Detaches the message listener. Safe to call multiple times. */ + dispose() { + window.removeEventListener("message", this.onMessage); + } + onMessage = (event) => { + const d = event.data; + if (isInit(d)) { + const ctx = parseInit(d); + this.currentSelection = ctx.selection; + this.currentTheme = ctx.theme; + this.resolveContext(ctx); + this.selectionHandlers.forEach((h) => h(ctx.selection)); + this.themeHandlers.forEach((h) => h(ctx.theme)); + } else if (isSelectionChanged(d)) { + this.currentSelection = { + keyId: d.keyId, + languageId: d.languageId, + languageTag: d.languageTag, + translationId: d.translationId, + selectedLanguages: d.selectedLanguages + }; + this.selectionHandlers.forEach((h) => h(this.currentSelection)); + } else if (isThemeChanged(d)) { + this.currentTheme = d.theme; + this.themeHandlers.forEach((h) => h(d.theme)); + } + }; +}; +var createTolgeeApp = () => new TolgeeApp(); + +// src/browser/client.ts +import { createApiClient } from "@tginternal/client"; +var createTolgeeAppClient = (context) => { + return createApiClient({ + baseUrl: context.apiUrl, + userToken: context.token, + projectId: context.projectId, + autoThrow: false + }); +}; + +// src/browser/applyTheme.ts +var VAR_PREFIX = "--tg-color-"; +var kebab = (s) => s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); +var applyTolgeeTheme = (theme, root = document.documentElement) => { + for (const [name, value] of Object.entries(theme.colors)) { + root.style.setProperty(`${VAR_PREFIX}${kebab(name)}`, value); + } + root.dataset.tgTheme = theme.mode; + root.style.colorScheme = theme.mode; +}; +export { + TolgeeApp, + applyTolgeeTheme, + createTolgeeApp, + createTolgeeAppClient +}; +//# sourceMappingURL=browser.js.map \ No newline at end of file diff --git a/apps/tolgee-apps-sdk/dist/browser.js.map b/apps/tolgee-apps-sdk/dist/browser.js.map new file mode 100644 index 00000000000..a96466822bd --- /dev/null +++ b/apps/tolgee-apps-sdk/dist/browser.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/browser/tolgeeApp.ts","../src/browser/client.ts","../src/browser/applyTheme.ts"],"sourcesContent":["import type {\n TolgeeAppContext,\n TolgeeAppSelection,\n TolgeeAppTheme,\n} from '../shared/contextTypes'\n\ntype Unsubscribe = () => void\n\nconst KNOWN_INIT_FIELDS = new Set([\n 'type',\n 'token',\n 'apiUrl',\n 'organizationId',\n 'projectId',\n 'keyId',\n 'languageId',\n 'translationId',\n 'languageTag',\n 'selectedLanguages',\n 'theme',\n])\n\ntype InitMessage = {\n type: 'tolgee-app:init'\n token: string\n apiUrl: string\n organizationId: number\n projectId: number\n keyId?: number\n languageId?: number\n languageTag?: string\n translationId?: number\n selectedLanguages?: string[]\n theme: TolgeeAppTheme\n} & Record\n\ntype SelectionChangedMessage = {\n type: 'tolgee-app:selection-changed'\n keyId?: number\n languageId?: number\n languageTag?: string\n translationId?: number\n selectedLanguages?: string[]\n}\n\nconst isInit = (d: unknown): d is InitMessage =>\n typeof d === 'object' &&\n d !== null &&\n (d as { type: unknown }).type === 'tolgee-app:init'\n\nconst isSelectionChanged = (d: unknown): d is SelectionChangedMessage =>\n typeof d === 'object' &&\n d !== null &&\n (d as { type: unknown }).type === 'tolgee-app:selection-changed'\n\ntype ThemeChangedMessage = {\n type: 'tolgee-app:theme-changed'\n theme: TolgeeAppTheme\n}\n\nconst isThemeChanged = (d: unknown): d is ThemeChangedMessage =>\n typeof d === 'object' &&\n d !== null &&\n (d as { type: unknown }).type === 'tolgee-app:theme-changed'\n\nconst parseInit = (m: InitMessage): TolgeeAppContext => {\n const extra: Record = {}\n for (const [k, v] of Object.entries(m)) {\n if (!KNOWN_INIT_FIELDS.has(k)) extra[k] = v\n }\n return {\n token: m.token,\n apiUrl: m.apiUrl,\n organizationId: m.organizationId,\n projectId: m.projectId,\n selection: {\n keyId: m.keyId,\n languageId: m.languageId,\n languageTag: m.languageTag,\n translationId: m.translationId,\n selectedLanguages: m.selectedLanguages,\n },\n theme: m.theme,\n extra,\n }\n}\n\n/**\n * Iframe-side handle to the Tolgee Apps postMessage protocol.\n *\n * Construct via `createTolgeeApp()`. Sends `tolgee-app:ready` to the\n * parent automatically on the next microtask, so you can attach\n * listeners before the host's init message arrives. The first init\n * resolves `context`; subsequent selection changes fire registered\n * selection handlers.\n */\nexport class TolgeeApp {\n private contextPromise: Promise\n private resolveContext!: (ctx: TolgeeAppContext) => void\n private selectionHandlers = new Set<(s: TolgeeAppSelection) => void>()\n private currentSelection: TolgeeAppSelection = {}\n private themeHandlers = new Set<(t: TolgeeAppTheme) => void>()\n private currentTheme: TolgeeAppTheme | undefined\n\n constructor() {\n this.contextPromise = new Promise((resolve) => {\n this.resolveContext = resolve\n })\n window.addEventListener('message', this.onMessage)\n queueMicrotask(() => {\n window.parent.postMessage({ type: 'tolgee-app:ready' }, '*')\n })\n }\n\n /**\n * Resolves with the init payload the host posted via\n * `tolgee-app:init`. Will never resolve outside a Tolgee iframe —\n * test for `window.parent !== window` before constructing if you\n * need a fallback path.\n */\n get context(): Promise {\n return this.contextPromise\n }\n\n /**\n * Subscribe to selection changes (the host posts these when the user\n * focuses a different key/translation cell). Returns an unsubscribe\n * function. The handler also fires once with the initial selection\n * after init.\n */\n onSelectionChanged(\n handler: (selection: TolgeeAppSelection) => void\n ): Unsubscribe {\n this.selectionHandlers.add(handler)\n if (Object.values(this.currentSelection).some((v) => v !== undefined)) {\n handler(this.currentSelection)\n }\n return () => {\n this.selectionHandlers.delete(handler)\n }\n }\n\n /**\n * Subscribe to host theme changes (light/dark toggles). Returns an\n * unsubscribe function and fires once with the current theme after init.\n * Pair with `applyTolgeeTheme` to restyle the iframe live.\n */\n onThemeChanged(handler: (theme: TolgeeAppTheme) => void): Unsubscribe {\n this.themeHandlers.add(handler)\n if (this.currentTheme) handler(this.currentTheme)\n return () => {\n this.themeHandlers.delete(handler)\n }\n }\n\n /** Asks the host to close the modal/panel containing this iframe. */\n close(): void {\n window.parent.postMessage({ type: 'tolgee-app:close' }, '*')\n }\n\n /** Tells the host how tall this iframe wants to be. */\n resize(height: number): void {\n window.parent.postMessage({ type: 'tolgee-app:resize', height }, '*')\n }\n\n /** Detaches the message listener. Safe to call multiple times. */\n dispose(): void {\n window.removeEventListener('message', this.onMessage)\n }\n\n private onMessage = (event: MessageEvent): void => {\n const d = event.data\n if (isInit(d)) {\n const ctx = parseInit(d)\n this.currentSelection = ctx.selection\n this.currentTheme = ctx.theme\n this.resolveContext(ctx)\n // Deliver the initial values to handlers registered before init arrived\n // (createTolgeeApp() is called, then handlers are added synchronously, all\n // before the host's init message). Without this, onSelectionChanged /\n // onThemeChanged would only fire on later changes, never on first load.\n this.selectionHandlers.forEach((h) => h(ctx.selection))\n this.themeHandlers.forEach((h) => h(ctx.theme))\n } else if (isSelectionChanged(d)) {\n this.currentSelection = {\n keyId: d.keyId,\n languageId: d.languageId,\n languageTag: d.languageTag,\n translationId: d.translationId,\n selectedLanguages: d.selectedLanguages,\n }\n this.selectionHandlers.forEach((h) => h(this.currentSelection))\n } else if (isThemeChanged(d)) {\n this.currentTheme = d.theme\n this.themeHandlers.forEach((h) => h(d.theme))\n }\n }\n}\n\nexport const createTolgeeApp = (): TolgeeApp => new TolgeeApp()\n","import { createApiClient } from '@tginternal/client'\nimport type { TolgeeAppContext } from '../shared/contextTypes'\n\n/**\n * Builds a typed Tolgee REST client wired with the install-context\n * token, base URL, and project id from the iframe's\n * `TolgeeAppContext`. Errors are returned in the `error` field rather\n * than thrown, matching `createApiClient`'s `autoThrow: false` mode.\n *\n * const app = createTolgeeApp()\n * const ctx = await app.context\n * const tolgee = createTolgeeAppClient(ctx)\n * const { data, error } = await tolgee.GET('/v2/projects/{projectId}', {\n * params: { path: { projectId: ctx.projectId } },\n * })\n */\nexport const createTolgeeAppClient = (\n context: TolgeeAppContext\n): ReturnType => {\n return createApiClient({\n baseUrl: context.apiUrl,\n userToken: context.token,\n projectId: context.projectId,\n autoThrow: false,\n })\n}\n","import type { TolgeeAppTheme } from '../shared/contextTypes'\n\nconst VAR_PREFIX = '--tg-color-'\n\nconst kebab = (s: string): string =>\n s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)\n\n/**\n * Applies the host theme to the iframe document so the plugin matches Tolgee:\n * exposes each palette color as a `--tg-color-` CSS custom property\n * (e.g. `--tg-color-background`, `--tg-color-text-secondary`), sets\n * `[data-tg-theme=\"light|dark\"]`, and sets `color-scheme` (so native form\n * controls and scrollbars follow the mode).\n *\n * Call it once with `ctx.theme` and again from `onThemeChanged` to follow live\n * light/dark toggles:\n *\n * ```ts\n * const app = createTolgeeApp()\n * const ctx = await app.context\n * applyTolgeeTheme(ctx.theme)\n * app.onThemeChanged(applyTolgeeTheme)\n * ```\n */\nexport const applyTolgeeTheme = (\n theme: TolgeeAppTheme,\n root: HTMLElement = document.documentElement\n): void => {\n for (const [name, value] of Object.entries(theme.colors)) {\n root.style.setProperty(`${VAR_PREFIX}${kebab(name)}`, value)\n }\n root.dataset.tgTheme = theme.mode\n root.style.colorScheme = theme.mode\n}\n"],"mappings":";AAQA,IAAM,oBAAoB,oBAAI,IAAI;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAyBD,IAAM,SAAS,CAAC,MACd,OAAO,MAAM,YACb,MAAM,QACL,EAAwB,SAAS;AAEpC,IAAM,qBAAqB,CAAC,MAC1B,OAAO,MAAM,YACb,MAAM,QACL,EAAwB,SAAS;AAOpC,IAAM,iBAAiB,CAAC,MACtB,OAAO,MAAM,YACb,MAAM,QACL,EAAwB,SAAS;AAEpC,IAAM,YAAY,CAAC,MAAqC;AACtD,QAAM,QAAiC,CAAC;AACxC,aAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,CAAC,GAAG;AACtC,QAAI,CAAC,kBAAkB,IAAI,CAAC,EAAG,OAAM,CAAC,IAAI;AAAA,EAC5C;AACA,SAAO;AAAA,IACL,OAAO,EAAE;AAAA,IACT,QAAQ,EAAE;AAAA,IACV,gBAAgB,EAAE;AAAA,IAClB,WAAW,EAAE;AAAA,IACb,WAAW;AAAA,MACT,OAAO,EAAE;AAAA,MACT,YAAY,EAAE;AAAA,MACd,aAAa,EAAE;AAAA,MACf,eAAe,EAAE;AAAA,MACjB,mBAAmB,EAAE;AAAA,IACvB;AAAA,IACA,OAAO,EAAE;AAAA,IACT;AAAA,EACF;AACF;AAWO,IAAM,YAAN,MAAgB;AAAA,EACb;AAAA,EACA;AAAA,EACA,oBAAoB,oBAAI,IAAqC;AAAA,EAC7D,mBAAuC,CAAC;AAAA,EACxC,gBAAgB,oBAAI,IAAiC;AAAA,EACrD;AAAA,EAER,cAAc;AACZ,SAAK,iBAAiB,IAAI,QAAQ,CAAC,YAAY;AAC7C,WAAK,iBAAiB;AAAA,IACxB,CAAC;AACD,WAAO,iBAAiB,WAAW,KAAK,SAAS;AACjD,mBAAe,MAAM;AACnB,aAAO,OAAO,YAAY,EAAE,MAAM,mBAAmB,GAAG,GAAG;AAAA,IAC7D,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,IAAI,UAAqC;AACvC,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,mBACE,SACa;AACb,SAAK,kBAAkB,IAAI,OAAO;AAClC,QAAI,OAAO,OAAO,KAAK,gBAAgB,EAAE,KAAK,CAAC,MAAM,MAAM,MAAS,GAAG;AACrE,cAAQ,KAAK,gBAAgB;AAAA,IAC/B;AACA,WAAO,MAAM;AACX,WAAK,kBAAkB,OAAO,OAAO;AAAA,IACvC;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,eAAe,SAAuD;AACpE,SAAK,cAAc,IAAI,OAAO;AAC9B,QAAI,KAAK,aAAc,SAAQ,KAAK,YAAY;AAChD,WAAO,MAAM;AACX,WAAK,cAAc,OAAO,OAAO;AAAA,IACnC;AAAA,EACF;AAAA;AAAA,EAGA,QAAc;AACZ,WAAO,OAAO,YAAY,EAAE,MAAM,mBAAmB,GAAG,GAAG;AAAA,EAC7D;AAAA;AAAA,EAGA,OAAO,QAAsB;AAC3B,WAAO,OAAO,YAAY,EAAE,MAAM,qBAAqB,OAAO,GAAG,GAAG;AAAA,EACtE;AAAA;AAAA,EAGA,UAAgB;AACd,WAAO,oBAAoB,WAAW,KAAK,SAAS;AAAA,EACtD;AAAA,EAEQ,YAAY,CAAC,UAA8B;AACjD,UAAM,IAAI,MAAM;AAChB,QAAI,OAAO,CAAC,GAAG;AACb,YAAM,MAAM,UAAU,CAAC;AACvB,WAAK,mBAAmB,IAAI;AAC5B,WAAK,eAAe,IAAI;AACxB,WAAK,eAAe,GAAG;AAKvB,WAAK,kBAAkB,QAAQ,CAAC,MAAM,EAAE,IAAI,SAAS,CAAC;AACtD,WAAK,cAAc,QAAQ,CAAC,MAAM,EAAE,IAAI,KAAK,CAAC;AAAA,IAChD,WAAW,mBAAmB,CAAC,GAAG;AAChC,WAAK,mBAAmB;AAAA,QACtB,OAAO,EAAE;AAAA,QACT,YAAY,EAAE;AAAA,QACd,aAAa,EAAE;AAAA,QACf,eAAe,EAAE;AAAA,QACjB,mBAAmB,EAAE;AAAA,MACvB;AACA,WAAK,kBAAkB,QAAQ,CAAC,MAAM,EAAE,KAAK,gBAAgB,CAAC;AAAA,IAChE,WAAW,eAAe,CAAC,GAAG;AAC5B,WAAK,eAAe,EAAE;AACtB,WAAK,cAAc,QAAQ,CAAC,MAAM,EAAE,EAAE,KAAK,CAAC;AAAA,IAC9C;AAAA,EACF;AACF;AAEO,IAAM,kBAAkB,MAAiB,IAAI,UAAU;;;ACvM9D,SAAS,uBAAuB;AAgBzB,IAAM,wBAAwB,CACnC,YACuC;AACvC,SAAO,gBAAgB;AAAA,IACrB,SAAS,QAAQ;AAAA,IACjB,WAAW,QAAQ;AAAA,IACnB,WAAW,QAAQ;AAAA,IACnB,WAAW;AAAA,EACb,CAAC;AACH;;;ACvBA,IAAM,aAAa;AAEnB,IAAM,QAAQ,CAAC,MACb,EAAE,QAAQ,UAAU,CAAC,MAAM,IAAI,EAAE,YAAY,CAAC,EAAE;AAmB3C,IAAM,mBAAmB,CAC9B,OACA,OAAoB,SAAS,oBACpB;AACT,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,MAAM,MAAM,GAAG;AACxD,SAAK,MAAM,YAAY,GAAG,UAAU,GAAG,MAAM,IAAI,CAAC,IAAI,KAAK;AAAA,EAC7D;AACA,OAAK,QAAQ,UAAU,MAAM;AAC7B,OAAK,MAAM,cAAc,MAAM;AACjC;","names":[]} \ No newline at end of file diff --git a/apps/tolgee-apps-sdk/dist/contextTypes-xgD-1LAp.d.ts b/apps/tolgee-apps-sdk/dist/contextTypes-xgD-1LAp.d.ts new file mode 100644 index 00000000000..20021001b39 --- /dev/null +++ b/apps/tolgee-apps-sdk/dist/contextTypes-xgD-1LAp.d.ts @@ -0,0 +1,73 @@ +/** + * Selection passed to iframes that render in a context where the user + * has focused a specific key, language, or translation cell. All + * fields are optional — modules launched from the project sidebar see + * an empty selection. + */ +type TolgeeAppSelection = { + keyId?: number; + languageId?: number; + languageTag?: string; + translationId?: number; + /** + * Tags of the languages currently shown in the translations view. Provided + * to the `translation-tools-panel-empty` module; updates as the user changes + * the language selector. Undefined for surfaces that don't supply it. + */ + selectedLanguages?: string[]; +}; +/** + * The host's current theme. Delivered at init and again whenever the user + * toggles light/dark, so the plugin can match Tolgee's look. `colors` are + * resolved CSS color strings from Tolgee's palette; feed them to + * {@link applyTolgeeTheme} to expose them as `--tg-color-*` CSS variables. + */ +type TolgeeAppTheme = { + mode: 'light' | 'dark'; + colors: { + background: string; + backgroundPaper: string; + text: string; + textSecondary: string; + primary: string; + primaryContrast: string; + divider: string; + error: string; + }; +}; +/** + * Context delivered to the iframe via the `tolgee-app:init` postMessage. + * `token` is the install-context JWT — pass it as a bearer token on + * every REST call back to Tolgee. + */ +type TolgeeAppContext = { + token: string; + apiUrl: string; + organizationId: number; + projectId: number; + selection: TolgeeAppSelection; + /** Host theme at init; subscribe to changes via `onThemeChanged`. */ + theme: TolgeeAppTheme; + /** + * Trigger-specific fields merged into the init payload (e.g. + * `selectedKeyIds` for bulk actions, `keyId` for key-edit-footer + * modals). Untyped because the set varies per trigger surface; cast + * as needed. + */ + extra: Record; +}; +/** + * Claims extracted from the Tolgee-issued JWT carried in + * `TolgeeAppContext.token`. Backend plugins typically don't need to + * verify the signature themselves — they pass the token as a bearer + * token on REST calls back to Tolgee, which verifies it server-side. + */ +type AppContextClaims = { + installId: number; + projectId: number; + userId: number; + audience: string; + expiresAt: number; +}; + +export type { AppContextClaims as A, TolgeeAppContext as T, TolgeeAppSelection as a, TolgeeAppTheme as b }; diff --git a/apps/tolgee-apps-sdk/dist/index.d.ts b/apps/tolgee-apps-sdk/dist/index.d.ts new file mode 100644 index 00000000000..ce1bfe3a843 --- /dev/null +++ b/apps/tolgee-apps-sdk/dist/index.d.ts @@ -0,0 +1,139 @@ +import { webhooks } from '@tginternal/client'; +export { A as AppContextClaims, T as TolgeeAppContext, a as TolgeeAppSelection } from './contextTypes-xgD-1LAp.js'; + +/** + * Identifier of a webhook event Tolgee can send — keys of the `webhooks` + * interface from the generated OpenAPI schema. Examples: `"SET_TRANSLATIONS"`, + * `"BATCH_KEY_RESTORE"`, `"CREATE_KEY"`. + */ +type WebhookType = keyof webhooks; +/** + * Request-body type for a given webhook event. + * + * type SetTranslationsPayload = WebhookPayloadFor<'SET_TRANSLATIONS'> + */ +type WebhookPayloadFor = webhooks[T]['post']['requestBody']['content']['application/json']; +/** + * Discriminated union of every typed webhook payload Tolgee can send. + * Use this as the parsed type for the raw POST body before dispatching + * via `onWebhook` from `@tolgee/apps-sdk/server`. + */ +type AppWebhookPayload = { + [K in WebhookType]: WebhookPayloadFor; +}[WebhookType]; + +/** + * Typed model of a Tolgee App manifest. This is the single source of truth + * shared by `create-tolgee-app`'s builder, the generated `manifest.template.json`, + * and hand-written apps. The JSON keys (incl. kebab-case module names) match + * exactly what the platform's `AppManifestFetcher` parses and validates. + */ +type AppManifest = { + id: string; + name: string; + version: string; + baseUrl: string; + /** Endpoint the webapp POSTs to for dynamic row decorators. */ + decoratorsUrl?: string; + /** Tolgee permission scopes the app requests at install time (e.g. `keys.edit`). */ + scopes?: string[]; + webhooks?: AppWebhooks; + modules?: AppModules; +}; +type AppWebhooks = { + events: WebhookType[]; + /** Relative to `baseUrl` or absolute. */ + url: string; +}; +/** + * The action surface kind. Trigger surfaces (bulk/toolbar/menu/shortcut) accept + * only `link` and `modal`; `key-action` additionally accepts `tab`; `translation-action` + * additionally accepts `panel`. The platform rejects invalid combinations at register time. + */ +type AppActionType = 'link' | 'tab' | 'panel' | 'modal'; +/** An iframe-bearing module (dashboard page, panel, key-edit tab, modal). */ +type AppIframeModule = { + key: string; + title: string; + /** Named icon from the platform icon set (e.g. `LayoutAlt04`). */ + icon?: string; + /** Route the iframe loads, relative to `baseUrl`. */ + entry: string; + /** Modal-only: iframe pixel size. */ + width?: number; + height?: number; +}; +/** An action that triggers a link, an iframe module, or a modal. */ +type AppAction = { + key: string; + type: AppActionType; + icon?: string; + tooltip?: string; + title?: string; + /** `link` actions: target URL, may contain placeholders like `{keyId}`. */ + urlTemplate?: string; + /** `tab` actions: which `key-edit-tab` to open. */ + tabKey?: string; + /** `panel` actions: which `translation-tools-panel` to open. */ + panelKey?: string; + /** `modal` actions: which `modal` to open. */ + modalKey?: string; + /** + * When true, visibility/decoration of this action is driven by the app's + * `decoratorsUrl` response rather than shown statically on every row. + */ + dynamic?: boolean; +}; +/** A global keyboard-shortcut binding that triggers a link or modal. */ +type AppShortcut = Omit & { + /** e.g. `Mod+Shift+E`. */ + combination: string; + type: Extract; +}; +/** + * App modules keyed by the platform's kebab-case module identifiers. Every + * key is optional; an app contributes only the surfaces it needs. + */ +type AppModules = { + 'project-dashboard-page'?: AppIframeModule[]; + 'translation-tools-panel'?: AppIframeModule[]; + /** Panel shown in the translations tools area when no cell is being edited. */ + 'translation-tools-panel-empty'?: AppIframeModule[]; + 'key-edit-tab'?: AppIframeModule[]; + modal?: AppIframeModule[]; + 'key-action'?: AppAction[]; + 'translation-action'?: AppAction[]; + 'bulk-action'?: AppAction[]; + 'translations-toolbar-action'?: AppAction[]; + 'project-menu-action'?: AppAction[]; + shortcut?: AppShortcut[]; +}; + +/** + * Contract for the dynamic-decorators endpoint (`manifest.decoratorsUrl`). + * The Tolgee webapp POSTs a [DecoratorsRequest] describing the rows currently + * visible; the app replies with a [DecoratorsResponse] of icon decorations to + * render alongside native row icons. + */ +type DecoratorsRequest = { + installId: number; + projectId: number; + /** Key rows in view. */ + keyIds?: number[]; + /** Language columns in view, by tag. */ + languageTags?: string[]; +}; +/** One decoration the app wants rendered on a key (or key+language) cell. */ +type DecoratorItem = { + keyId: number; + /** Omit for a key-level decoration; set for a translation-cell decoration. */ + languageTag?: string; + /** Named icon from the platform icon set. */ + icon: string; + tooltip?: string; +}; +type DecoratorsResponse = { + items: DecoratorItem[]; +}; + +export type { AppAction, AppActionType, AppIframeModule, AppManifest, AppModules, AppShortcut, AppWebhookPayload, AppWebhooks, DecoratorItem, DecoratorsRequest, DecoratorsResponse, WebhookPayloadFor, WebhookType }; diff --git a/apps/tolgee-apps-sdk/dist/index.js b/apps/tolgee-apps-sdk/dist/index.js new file mode 100644 index 00000000000..8332f84cff5 --- /dev/null +++ b/apps/tolgee-apps-sdk/dist/index.js @@ -0,0 +1 @@ +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/apps/tolgee-apps-sdk/dist/index.js.map b/apps/tolgee-apps-sdk/dist/index.js.map new file mode 100644 index 00000000000..84c51b288c4 --- /dev/null +++ b/apps/tolgee-apps-sdk/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]} \ No newline at end of file diff --git a/apps/tolgee-apps-sdk/dist/server.d.ts b/apps/tolgee-apps-sdk/dist/server.d.ts new file mode 100644 index 00000000000..85c4cbc8129 --- /dev/null +++ b/apps/tolgee-apps-sdk/dist/server.d.ts @@ -0,0 +1,122 @@ +import { WebhookType, AppWebhookPayload, WebhookPayloadFor } from './index.js'; +export { AppAction, AppActionType, AppIframeModule, AppManifest, AppModules, AppShortcut, AppWebhooks, DecoratorItem, DecoratorsRequest, DecoratorsResponse } from './index.js'; +import { A as AppContextClaims } from './contextTypes-xgD-1LAp.js'; +import '@tginternal/client'; + +/** + * Verifies the `Tolgee-Signature` HMAC header against the raw POST body. + * Returns true when the signature matches the per-install webhook + * secret. Uses WebCrypto so the same code runs on Node 20+, Cloudflare + * Workers, Bun, and Deno. + * + * The caller is responsible for reading the raw request body before any + * JSON parser sees it — Express's `express.text({ type: 'application/json' })` + * is the standard way. + * + * const ok = await verifyWebhookSignature({ + * header: req.header('Tolgee-Signature'), + * rawBody: req.body, + * secret: process.env.TOLGEE_WEBHOOK_SECRET!, + * }) + */ +declare function verifyWebhookSignature(opts: { + header: string | null | undefined; + rawBody: string; + secret: string; +}): Promise; + +/** + * If `payload` matches one of the supplied activity `types`, invokes + * `handler` with the payload narrowed to that specific type's schema. + * Returns true when the handler ran. + * + * onWebhook(payload, 'SET_TRANSLATIONS', (p) => { + * // p: WebhookPayloadFor<'SET_TRANSLATIONS'> + * p.activityData?.modifiedEntities?.Translation?.forEach(...) + * }) + * + * onWebhook(payload, ['SET_TRANSLATIONS', 'BATCH_SET_TRANSLATION_STATE'], (p) => { + * // p: WebhookPayloadFor<'SET_TRANSLATIONS' | 'BATCH_SET_TRANSLATION_STATE'> + * }) + */ +declare function onWebhook(payload: AppWebhookPayload, types: T | readonly T[], handler: (typed: WebhookPayloadFor) => void): boolean; + +type ReceiveWebhookInput = { + /** The exact bytes Tolgee signed — do NOT pass a re-serialized object. */ + rawBody: string; + /** Value of the `Tolgee-Signature` header. */ + signatureHeader: string | null | undefined; + /** + * Per-install webhook secret. When null/undefined, signature verification + * is skipped — only acceptable in local development. + */ + secret: string | null | undefined; +}; +type ReceiveWebhookResult = { + ok: true; + payload: AppWebhookPayload; +} | { + ok: false; + status: 401 | 400; + error: string; +}; +/** + * Framework-agnostic webhook intake: verifies the `Tolgee-Signature` over the + * raw body and parses it into a typed payload. Returns a result the caller maps + * to its HTTP framework; on success, dispatch with `onWebhook`. + * + * const r = await receiveWebhook({ rawBody, signatureHeader, secret }) + * if (!r.ok) return res.status(r.status).json({ error: r.error }) + * onWebhook(r.payload, 'SET_TRANSLATIONS', (p) => { ... }) + */ +declare function receiveWebhook(input: ReceiveWebhookInput): Promise; + +/** + * Decodes (does NOT cryptographically verify) the Tolgee-issued context + * JWT into typed claims. Plugin backends generally don't have access to + * the platform's signing key, so verification happens server-side at + * Tolgee — passing the same token as a bearer on REST calls back to + * Tolgee is what authenticates the plugin. + * + * Use this when you need the install/user/project ids out of the token + * for logging, routing, or persistence. + */ +declare const decodeContextToken: (jwt: string) => AppContextClaims; + +/** + * CORS headers a Tolgee App's server endpoints (decorators, custom APIs) must + * return so the webapp — served from a different origin than the app — can call + * them. Wildcard origin is safe here because the install token travels in the + * `Authorization` header, not a credentialed cookie. + * + * Apply them in any framework, e.g. Express: + * for (const [k, v] of Object.entries(tolgeeAppCorsHeaders())) res.setHeader(k, v) + */ +declare const tolgeeAppCorsHeaders: () => Record; + +/** + * Substitutes the `__BASE_URL__` placeholder in a manifest template with the + * app's currently-reachable base URL (a tunnel URL in dev, the deployed origin + * in production). Keeping the placeholder in the stored template lets the URL + * change between dev restarts without editing the manifest. + */ +declare const renderManifest: (template: string, baseUrl: string) => string; + +type TolgeeAppConfig = { + /** Base URL of the Tolgee instance the app is installed on. */ + tolgeeUrl: string; + /** Per-install webhook secret; null when unset (dev: signatures unverified). */ + webhookSecret: string | null; + /** Vite dev-server port (iframe assets). */ + vitePort: number; + /** App server port (manifest/webhook/decorator routes). */ + serverPort: number; +}; +/** + * Reads a Tolgee App's standard environment variables into a typed config. + * Centralizes the env-var contract (names + defaults) so every app reads them + * the same way. + */ +declare const loadTolgeeAppConfig: (env?: NodeJS.ProcessEnv) => TolgeeAppConfig; + +export { AppContextClaims, AppWebhookPayload, type ReceiveWebhookInput, type ReceiveWebhookResult, type TolgeeAppConfig, WebhookPayloadFor, WebhookType, decodeContextToken, loadTolgeeAppConfig, onWebhook, receiveWebhook, renderManifest, tolgeeAppCorsHeaders, verifyWebhookSignature }; diff --git a/apps/tolgee-apps-sdk/dist/server.js b/apps/tolgee-apps-sdk/dist/server.js new file mode 100644 index 00000000000..f3640d2f915 --- /dev/null +++ b/apps/tolgee-apps-sdk/dist/server.js @@ -0,0 +1,126 @@ +// src/server/verifyWebhookSignature.ts +async function verifyWebhookSignature(opts) { + if (!opts.header) return false; + let parsed; + try { + parsed = JSON.parse(opts.header); + } catch { + return false; + } + const { timestamp, signature } = parsed; + if (typeof timestamp !== "number" || typeof signature !== "string") { + return false; + } + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(opts.secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const sigBytes = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(`${timestamp}.${opts.rawBody}`) + ); + const expected = hexEncode(new Uint8Array(sigBytes)); + return timingSafeStringEqual(expected, signature); +} +var hexEncode = (bytes) => { + let out = ""; + for (const b of bytes) out += b.toString(16).padStart(2, "0"); + return out; +}; +var timingSafeStringEqual = (a, b) => { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +}; + +// src/server/onWebhook.ts +function onWebhook(payload, types, handler) { + const list = Array.isArray(types) ? types : [types]; + const activityType = payload.activityData?.type; + if (activityType && list.includes(activityType)) { + handler(payload); + return true; + } + return false; +} + +// src/server/receiveWebhook.ts +async function receiveWebhook(input) { + if (input.secret) { + const valid = await verifyWebhookSignature({ + header: input.signatureHeader, + rawBody: input.rawBody, + secret: input.secret + }); + if (!valid) return { ok: false, status: 401, error: "invalid signature" }; + } + try { + return { ok: true, payload: JSON.parse(input.rawBody) }; + } catch { + return { ok: false, status: 400, error: "invalid json" }; + } +} + +// src/server/decodeContextToken.ts +var decodeContextToken = (jwt) => { + const parts = jwt.split("."); + if (parts.length !== 3) { + throw new Error("Malformed JWT: expected 3 segments"); + } + const payload = JSON.parse(base64UrlDecode(parts[1])); + const installId = payload["tg.app.inst"]; + const projectId = payload["tg.app.proj"]; + if (typeof installId !== "number" || typeof projectId !== "number") { + throw new Error("Token missing tg.app.inst or tg.app.proj claim"); + } + return { + installId, + projectId, + userId: Number(payload.sub), + audience: String(payload.aud), + expiresAt: Number(payload.exp) + }; +}; +var base64UrlDecode = (s) => { + const normalized = s.replace(/-/g, "+").replace(/_/g, "/"); + const pad = (4 - normalized.length % 4) % 4; + return atob(normalized + "=".repeat(pad)); +}; + +// src/server/cors.ts +var tolgeeAppCorsHeaders = () => ({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers": "Authorization,Content-Type", + "Access-Control-Max-Age": "600" +}); + +// src/server/renderManifest.ts +var BASE_URL_PLACEHOLDER = "__BASE_URL__"; +var renderManifest = (template, baseUrl) => template.replaceAll(BASE_URL_PLACEHOLDER, baseUrl); + +// src/server/config.ts +var loadTolgeeAppConfig = (env = process.env) => ({ + tolgeeUrl: env.TOLGEE_URL ?? "https://app.tolgee.io", + webhookSecret: env.TOLGEE_WEBHOOK_SECRET ?? null, + vitePort: Number(env.VITE_PORT ?? 5180), + serverPort: Number(env.SERVER_PORT ?? env.PORT ?? 5181) +}); +export { + decodeContextToken, + loadTolgeeAppConfig, + onWebhook, + receiveWebhook, + renderManifest, + tolgeeAppCorsHeaders, + verifyWebhookSignature +}; +//# sourceMappingURL=server.js.map \ No newline at end of file diff --git a/apps/tolgee-apps-sdk/dist/server.js.map b/apps/tolgee-apps-sdk/dist/server.js.map new file mode 100644 index 00000000000..f2c98c62491 --- /dev/null +++ b/apps/tolgee-apps-sdk/dist/server.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/server/verifyWebhookSignature.ts","../src/server/onWebhook.ts","../src/server/receiveWebhook.ts","../src/server/decodeContextToken.ts","../src/server/cors.ts","../src/server/renderManifest.ts","../src/server/config.ts"],"sourcesContent":["/**\n * Verifies the `Tolgee-Signature` HMAC header against the raw POST body.\n * Returns true when the signature matches the per-install webhook\n * secret. Uses WebCrypto so the same code runs on Node 20+, Cloudflare\n * Workers, Bun, and Deno.\n *\n * The caller is responsible for reading the raw request body before any\n * JSON parser sees it — Express's `express.text({ type: 'application/json' })`\n * is the standard way.\n *\n * const ok = await verifyWebhookSignature({\n * header: req.header('Tolgee-Signature'),\n * rawBody: req.body,\n * secret: process.env.TOLGEE_WEBHOOK_SECRET!,\n * })\n */\nexport async function verifyWebhookSignature(opts: {\n header: string | null | undefined\n rawBody: string\n secret: string\n}): Promise {\n if (!opts.header) return false\n\n let parsed: { timestamp?: unknown; signature?: unknown }\n try {\n parsed = JSON.parse(opts.header)\n } catch {\n return false\n }\n const { timestamp, signature } = parsed\n if (typeof timestamp !== 'number' || typeof signature !== 'string') {\n return false\n }\n\n const encoder = new TextEncoder()\n const key = await crypto.subtle.importKey(\n 'raw',\n encoder.encode(opts.secret),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign']\n )\n const sigBytes = await crypto.subtle.sign(\n 'HMAC',\n key,\n encoder.encode(`${timestamp}.${opts.rawBody}`)\n )\n const expected = hexEncode(new Uint8Array(sigBytes))\n return timingSafeStringEqual(expected, signature)\n}\n\nconst hexEncode = (bytes: Uint8Array): string => {\n let out = ''\n for (const b of bytes) out += b.toString(16).padStart(2, '0')\n return out\n}\n\nconst timingSafeStringEqual = (a: string, b: string): boolean => {\n if (a.length !== b.length) return false\n let diff = 0\n for (let i = 0; i < a.length; i++) {\n diff |= a.charCodeAt(i) ^ b.charCodeAt(i)\n }\n return diff === 0\n}\n","import type {\n AppWebhookPayload,\n WebhookPayloadFor,\n WebhookType,\n} from '../shared/webhookTypes'\n\n/**\n * If `payload` matches one of the supplied activity `types`, invokes\n * `handler` with the payload narrowed to that specific type's schema.\n * Returns true when the handler ran.\n *\n * onWebhook(payload, 'SET_TRANSLATIONS', (p) => {\n * // p: WebhookPayloadFor<'SET_TRANSLATIONS'>\n * p.activityData?.modifiedEntities?.Translation?.forEach(...)\n * })\n *\n * onWebhook(payload, ['SET_TRANSLATIONS', 'BATCH_SET_TRANSLATION_STATE'], (p) => {\n * // p: WebhookPayloadFor<'SET_TRANSLATIONS' | 'BATCH_SET_TRANSLATION_STATE'>\n * })\n */\nexport function onWebhook(\n payload: AppWebhookPayload,\n types: T | readonly T[],\n handler: (typed: WebhookPayloadFor) => void\n): boolean {\n const list = Array.isArray(types) ? types : [types as T]\n const activityType = payload.activityData?.type\n if (activityType && list.includes(activityType as T)) {\n handler(payload as WebhookPayloadFor)\n return true\n }\n return false\n}\n","import type { AppWebhookPayload } from '../shared/webhookTypes'\nimport { verifyWebhookSignature } from './verifyWebhookSignature'\n\nexport type ReceiveWebhookInput = {\n /** The exact bytes Tolgee signed — do NOT pass a re-serialized object. */\n rawBody: string\n /** Value of the `Tolgee-Signature` header. */\n signatureHeader: string | null | undefined\n /**\n * Per-install webhook secret. When null/undefined, signature verification\n * is skipped — only acceptable in local development.\n */\n secret: string | null | undefined\n}\n\nexport type ReceiveWebhookResult =\n | { ok: true; payload: AppWebhookPayload }\n | { ok: false; status: 401 | 400; error: string }\n\n/**\n * Framework-agnostic webhook intake: verifies the `Tolgee-Signature` over the\n * raw body and parses it into a typed payload. Returns a result the caller maps\n * to its HTTP framework; on success, dispatch with `onWebhook`.\n *\n * const r = await receiveWebhook({ rawBody, signatureHeader, secret })\n * if (!r.ok) return res.status(r.status).json({ error: r.error })\n * onWebhook(r.payload, 'SET_TRANSLATIONS', (p) => { ... })\n */\nexport async function receiveWebhook(\n input: ReceiveWebhookInput\n): Promise {\n if (input.secret) {\n const valid = await verifyWebhookSignature({\n header: input.signatureHeader,\n rawBody: input.rawBody,\n secret: input.secret,\n })\n if (!valid) return { ok: false, status: 401, error: 'invalid signature' }\n }\n\n try {\n return { ok: true, payload: JSON.parse(input.rawBody) as AppWebhookPayload }\n } catch {\n return { ok: false, status: 400, error: 'invalid json' }\n }\n}\n","import type { AppContextClaims } from '../shared/contextTypes'\n\n/**\n * Decodes (does NOT cryptographically verify) the Tolgee-issued context\n * JWT into typed claims. Plugin backends generally don't have access to\n * the platform's signing key, so verification happens server-side at\n * Tolgee — passing the same token as a bearer on REST calls back to\n * Tolgee is what authenticates the plugin.\n *\n * Use this when you need the install/user/project ids out of the token\n * for logging, routing, or persistence.\n */\nexport const decodeContextToken = (jwt: string): AppContextClaims => {\n const parts = jwt.split('.')\n if (parts.length !== 3) {\n throw new Error('Malformed JWT: expected 3 segments')\n }\n const payload = JSON.parse(base64UrlDecode(parts[1])) as Record\n\n const installId = payload['tg.app.inst']\n const projectId = payload['tg.app.proj']\n if (typeof installId !== 'number' || typeof projectId !== 'number') {\n throw new Error('Token missing tg.app.inst or tg.app.proj claim')\n }\n return {\n installId,\n projectId,\n userId: Number(payload.sub),\n audience: String(payload.aud),\n expiresAt: Number(payload.exp),\n }\n}\n\nconst base64UrlDecode = (s: string): string => {\n const normalized = s.replace(/-/g, '+').replace(/_/g, '/')\n const pad = (4 - (normalized.length % 4)) % 4\n return atob(normalized + '='.repeat(pad))\n}\n","/**\n * CORS headers a Tolgee App's server endpoints (decorators, custom APIs) must\n * return so the webapp — served from a different origin than the app — can call\n * them. Wildcard origin is safe here because the install token travels in the\n * `Authorization` header, not a credentialed cookie.\n *\n * Apply them in any framework, e.g. Express:\n * for (const [k, v] of Object.entries(tolgeeAppCorsHeaders())) res.setHeader(k, v)\n */\nexport const tolgeeAppCorsHeaders = (): Record => ({\n 'Access-Control-Allow-Origin': '*',\n 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',\n 'Access-Control-Allow-Headers': 'Authorization,Content-Type',\n 'Access-Control-Max-Age': '600',\n})\n","const BASE_URL_PLACEHOLDER = '__BASE_URL__'\n\n/**\n * Substitutes the `__BASE_URL__` placeholder in a manifest template with the\n * app's currently-reachable base URL (a tunnel URL in dev, the deployed origin\n * in production). Keeping the placeholder in the stored template lets the URL\n * change between dev restarts without editing the manifest.\n */\nexport const renderManifest = (template: string, baseUrl: string): string =>\n template.replaceAll(BASE_URL_PLACEHOLDER, baseUrl)\n","export type TolgeeAppConfig = {\n /** Base URL of the Tolgee instance the app is installed on. */\n tolgeeUrl: string\n /** Per-install webhook secret; null when unset (dev: signatures unverified). */\n webhookSecret: string | null\n /** Vite dev-server port (iframe assets). */\n vitePort: number\n /** App server port (manifest/webhook/decorator routes). */\n serverPort: number\n}\n\n/**\n * Reads a Tolgee App's standard environment variables into a typed config.\n * Centralizes the env-var contract (names + defaults) so every app reads them\n * the same way.\n */\nexport const loadTolgeeAppConfig = (\n env: NodeJS.ProcessEnv = process.env\n): TolgeeAppConfig => ({\n tolgeeUrl: env.TOLGEE_URL ?? 'https://app.tolgee.io',\n webhookSecret: env.TOLGEE_WEBHOOK_SECRET ?? null,\n vitePort: Number(env.VITE_PORT ?? 5180),\n serverPort: Number(env.SERVER_PORT ?? env.PORT ?? 5181),\n})\n"],"mappings":";AAgBA,eAAsB,uBAAuB,MAIxB;AACnB,MAAI,CAAC,KAAK,OAAQ,QAAO;AAEzB,MAAI;AACJ,MAAI;AACF,aAAS,KAAK,MAAM,KAAK,MAAM;AAAA,EACjC,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,EAAE,WAAW,UAAU,IAAI;AACjC,MAAI,OAAO,cAAc,YAAY,OAAO,cAAc,UAAU;AAClE,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,IAAI,YAAY;AAChC,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA,QAAQ,OAAO,KAAK,MAAM;AAAA,IAC1B,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,QAAM,WAAW,MAAM,OAAO,OAAO;AAAA,IACnC;AAAA,IACA;AAAA,IACA,QAAQ,OAAO,GAAG,SAAS,IAAI,KAAK,OAAO,EAAE;AAAA,EAC/C;AACA,QAAM,WAAW,UAAU,IAAI,WAAW,QAAQ,CAAC;AACnD,SAAO,sBAAsB,UAAU,SAAS;AAClD;AAEA,IAAM,YAAY,CAAC,UAA8B;AAC/C,MAAI,MAAM;AACV,aAAW,KAAK,MAAO,QAAO,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG;AAC5D,SAAO;AACT;AAEA,IAAM,wBAAwB,CAAC,GAAW,MAAuB;AAC/D,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;AACjC,YAAQ,EAAE,WAAW,CAAC,IAAI,EAAE,WAAW,CAAC;AAAA,EAC1C;AACA,SAAO,SAAS;AAClB;;;AC5CO,SAAS,UACd,SACA,OACA,SACS;AACT,QAAM,OAAO,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC,KAAU;AACvD,QAAM,eAAe,QAAQ,cAAc;AAC3C,MAAI,gBAAgB,KAAK,SAAS,YAAiB,GAAG;AACpD,YAAQ,OAA+B;AACvC,WAAO;AAAA,EACT;AACA,SAAO;AACT;;;ACJA,eAAsB,eACpB,OAC+B;AAC/B,MAAI,MAAM,QAAQ;AAChB,UAAM,QAAQ,MAAM,uBAAuB;AAAA,MACzC,QAAQ,MAAM;AAAA,MACd,SAAS,MAAM;AAAA,MACf,QAAQ,MAAM;AAAA,IAChB,CAAC;AACD,QAAI,CAAC,MAAO,QAAO,EAAE,IAAI,OAAO,QAAQ,KAAK,OAAO,oBAAoB;AAAA,EAC1E;AAEA,MAAI;AACF,WAAO,EAAE,IAAI,MAAM,SAAS,KAAK,MAAM,MAAM,OAAO,EAAuB;AAAA,EAC7E,QAAQ;AACN,WAAO,EAAE,IAAI,OAAO,QAAQ,KAAK,OAAO,eAAe;AAAA,EACzD;AACF;;;ACjCO,IAAM,qBAAqB,CAAC,QAAkC;AACnE,QAAM,QAAQ,IAAI,MAAM,GAAG;AAC3B,MAAI,MAAM,WAAW,GAAG;AACtB,UAAM,IAAI,MAAM,oCAAoC;AAAA,EACtD;AACA,QAAM,UAAU,KAAK,MAAM,gBAAgB,MAAM,CAAC,CAAC,CAAC;AAEpD,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,YAAY,QAAQ,aAAa;AACvC,MAAI,OAAO,cAAc,YAAY,OAAO,cAAc,UAAU;AAClE,UAAM,IAAI,MAAM,gDAAgD;AAAA,EAClE;AACA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,QAAQ,OAAO,QAAQ,GAAG;AAAA,IAC1B,UAAU,OAAO,QAAQ,GAAG;AAAA,IAC5B,WAAW,OAAO,QAAQ,GAAG;AAAA,EAC/B;AACF;AAEA,IAAM,kBAAkB,CAAC,MAAsB;AAC7C,QAAM,aAAa,EAAE,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG;AACzD,QAAM,OAAO,IAAK,WAAW,SAAS,KAAM;AAC5C,SAAO,KAAK,aAAa,IAAI,OAAO,GAAG,CAAC;AAC1C;;;AC5BO,IAAM,uBAAuB,OAA+B;AAAA,EACjE,+BAA+B;AAAA,EAC/B,gCAAgC;AAAA,EAChC,gCAAgC;AAAA,EAChC,0BAA0B;AAC5B;;;ACdA,IAAM,uBAAuB;AAQtB,IAAM,iBAAiB,CAAC,UAAkB,YAC/C,SAAS,WAAW,sBAAsB,OAAO;;;ACO5C,IAAM,sBAAsB,CACjC,MAAyB,QAAQ,SACZ;AAAA,EACrB,WAAW,IAAI,cAAc;AAAA,EAC7B,eAAe,IAAI,yBAAyB;AAAA,EAC5C,UAAU,OAAO,IAAI,aAAa,IAAI;AAAA,EACtC,YAAY,OAAO,IAAI,eAAe,IAAI,QAAQ,IAAI;AACxD;","names":[]} \ No newline at end of file diff --git a/apps/tolgee-apps-sdk/package.json b/apps/tolgee-apps-sdk/package.json new file mode 100644 index 00000000000..341a8c2e4e2 --- /dev/null +++ b/apps/tolgee-apps-sdk/package.json @@ -0,0 +1,42 @@ +{ + "name": "@tolgee/apps-sdk", + "version": "0.0.1-alpha.7", + "description": "SDK for building Tolgee Apps plugins — postMessage handshake, manifest schema, webhook signature verifier, JWT context helpers.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js" + }, + "./browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup" + }, + "dependencies": { + "@tginternal/client": "0.0.0-prerelease.c8329b552" + }, + "devDependencies": { + "@types/node": "^24.12.3", + "tsup": "^8.3.5", + "typescript": "~6.0.2" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=20" + } +} diff --git a/apps/tolgee-apps-sdk/src/browser/applyTheme.ts b/apps/tolgee-apps-sdk/src/browser/applyTheme.ts new file mode 100644 index 00000000000..bea60f3f3f4 --- /dev/null +++ b/apps/tolgee-apps-sdk/src/browser/applyTheme.ts @@ -0,0 +1,34 @@ +import type { TolgeeAppTheme } from '../shared/contextTypes' + +const VAR_PREFIX = '--tg-color-' + +const kebab = (s: string): string => + s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) + +/** + * Applies the host theme to the iframe document so the plugin matches Tolgee: + * exposes each palette color as a `--tg-color-` CSS custom property + * (e.g. `--tg-color-background`, `--tg-color-text-secondary`), sets + * `[data-tg-theme="light|dark"]`, and sets `color-scheme` (so native form + * controls and scrollbars follow the mode). + * + * Call it once with `ctx.theme` and again from `onThemeChanged` to follow live + * light/dark toggles: + * + * ```ts + * const app = createTolgeeApp() + * const ctx = await app.context + * applyTolgeeTheme(ctx.theme) + * app.onThemeChanged(applyTolgeeTheme) + * ``` + */ +export const applyTolgeeTheme = ( + theme: TolgeeAppTheme, + root: HTMLElement = document.documentElement +): void => { + for (const [name, value] of Object.entries(theme.colors)) { + root.style.setProperty(`${VAR_PREFIX}${kebab(name)}`, value) + } + root.dataset.tgTheme = theme.mode + root.style.colorScheme = theme.mode +} diff --git a/apps/tolgee-apps-sdk/src/browser/client.ts b/apps/tolgee-apps-sdk/src/browser/client.ts new file mode 100644 index 00000000000..be9eace0d9f --- /dev/null +++ b/apps/tolgee-apps-sdk/src/browser/client.ts @@ -0,0 +1,26 @@ +import { createApiClient } from '@tginternal/client' +import type { TolgeeAppContext } from '../shared/contextTypes' + +/** + * Builds a typed Tolgee REST client wired with the install-context + * token, base URL, and project id from the iframe's + * `TolgeeAppContext`. Errors are returned in the `error` field rather + * than thrown, matching `createApiClient`'s `autoThrow: false` mode. + * + * const app = createTolgeeApp() + * const ctx = await app.context + * const tolgee = createTolgeeAppClient(ctx) + * const { data, error } = await tolgee.GET('/v2/projects/{projectId}', { + * params: { path: { projectId: ctx.projectId } }, + * }) + */ +export const createTolgeeAppClient = ( + context: TolgeeAppContext +): ReturnType => { + return createApiClient({ + baseUrl: context.apiUrl, + userToken: context.token, + projectId: context.projectId, + autoThrow: false, + }) +} diff --git a/apps/tolgee-apps-sdk/src/browser/index.ts b/apps/tolgee-apps-sdk/src/browser/index.ts new file mode 100644 index 00000000000..b336676d6eb --- /dev/null +++ b/apps/tolgee-apps-sdk/src/browser/index.ts @@ -0,0 +1,8 @@ +export { createTolgeeApp, TolgeeApp } from './tolgeeApp' +export { createTolgeeAppClient } from './client' +export { applyTolgeeTheme } from './applyTheme' +export type { + TolgeeAppContext, + TolgeeAppSelection, + TolgeeAppTheme, +} from '../shared/contextTypes' diff --git a/apps/tolgee-apps-sdk/src/browser/tolgeeApp.ts b/apps/tolgee-apps-sdk/src/browser/tolgeeApp.ts new file mode 100644 index 00000000000..5d99fa7d383 --- /dev/null +++ b/apps/tolgee-apps-sdk/src/browser/tolgeeApp.ts @@ -0,0 +1,200 @@ +import type { + TolgeeAppContext, + TolgeeAppSelection, + TolgeeAppTheme, +} from '../shared/contextTypes' + +type Unsubscribe = () => void + +const KNOWN_INIT_FIELDS = new Set([ + 'type', + 'token', + 'apiUrl', + 'organizationId', + 'projectId', + 'keyId', + 'languageId', + 'translationId', + 'languageTag', + 'selectedLanguages', + 'theme', +]) + +type InitMessage = { + type: 'tolgee-app:init' + token: string + apiUrl: string + organizationId: number + projectId: number + keyId?: number + languageId?: number + languageTag?: string + translationId?: number + selectedLanguages?: string[] + theme: TolgeeAppTheme +} & Record + +type SelectionChangedMessage = { + type: 'tolgee-app:selection-changed' + keyId?: number + languageId?: number + languageTag?: string + translationId?: number + selectedLanguages?: string[] +} + +const isInit = (d: unknown): d is InitMessage => + typeof d === 'object' && + d !== null && + (d as { type: unknown }).type === 'tolgee-app:init' + +const isSelectionChanged = (d: unknown): d is SelectionChangedMessage => + typeof d === 'object' && + d !== null && + (d as { type: unknown }).type === 'tolgee-app:selection-changed' + +type ThemeChangedMessage = { + type: 'tolgee-app:theme-changed' + theme: TolgeeAppTheme +} + +const isThemeChanged = (d: unknown): d is ThemeChangedMessage => + typeof d === 'object' && + d !== null && + (d as { type: unknown }).type === 'tolgee-app:theme-changed' + +const parseInit = (m: InitMessage): TolgeeAppContext => { + const extra: Record = {} + for (const [k, v] of Object.entries(m)) { + if (!KNOWN_INIT_FIELDS.has(k)) extra[k] = v + } + return { + token: m.token, + apiUrl: m.apiUrl, + organizationId: m.organizationId, + projectId: m.projectId, + selection: { + keyId: m.keyId, + languageId: m.languageId, + languageTag: m.languageTag, + translationId: m.translationId, + selectedLanguages: m.selectedLanguages, + }, + theme: m.theme, + extra, + } +} + +/** + * Iframe-side handle to the Tolgee Apps postMessage protocol. + * + * Construct via `createTolgeeApp()`. Sends `tolgee-app:ready` to the + * parent automatically on the next microtask, so you can attach + * listeners before the host's init message arrives. The first init + * resolves `context`; subsequent selection changes fire registered + * selection handlers. + */ +export class TolgeeApp { + private contextPromise: Promise + private resolveContext!: (ctx: TolgeeAppContext) => void + private selectionHandlers = new Set<(s: TolgeeAppSelection) => void>() + private currentSelection: TolgeeAppSelection = {} + private themeHandlers = new Set<(t: TolgeeAppTheme) => void>() + private currentTheme: TolgeeAppTheme | undefined + + constructor() { + this.contextPromise = new Promise((resolve) => { + this.resolveContext = resolve + }) + window.addEventListener('message', this.onMessage) + queueMicrotask(() => { + window.parent.postMessage({ type: 'tolgee-app:ready' }, '*') + }) + } + + /** + * Resolves with the init payload the host posted via + * `tolgee-app:init`. Will never resolve outside a Tolgee iframe — + * test for `window.parent !== window` before constructing if you + * need a fallback path. + */ + get context(): Promise { + return this.contextPromise + } + + /** + * Subscribe to selection changes (the host posts these when the user + * focuses a different key/translation cell). Returns an unsubscribe + * function. The handler also fires once with the initial selection + * after init. + */ + onSelectionChanged( + handler: (selection: TolgeeAppSelection) => void + ): Unsubscribe { + this.selectionHandlers.add(handler) + if (Object.values(this.currentSelection).some((v) => v !== undefined)) { + handler(this.currentSelection) + } + return () => { + this.selectionHandlers.delete(handler) + } + } + + /** + * Subscribe to host theme changes (light/dark toggles). Returns an + * unsubscribe function and fires once with the current theme after init. + * Pair with `applyTolgeeTheme` to restyle the iframe live. + */ + onThemeChanged(handler: (theme: TolgeeAppTheme) => void): Unsubscribe { + this.themeHandlers.add(handler) + if (this.currentTheme) handler(this.currentTheme) + return () => { + this.themeHandlers.delete(handler) + } + } + + /** Asks the host to close the modal/panel containing this iframe. */ + close(): void { + window.parent.postMessage({ type: 'tolgee-app:close' }, '*') + } + + /** Tells the host how tall this iframe wants to be. */ + resize(height: number): void { + window.parent.postMessage({ type: 'tolgee-app:resize', height }, '*') + } + + /** Detaches the message listener. Safe to call multiple times. */ + dispose(): void { + window.removeEventListener('message', this.onMessage) + } + + private onMessage = (event: MessageEvent): void => { + const d = event.data + if (isInit(d)) { + const ctx = parseInit(d) + this.currentSelection = ctx.selection + this.currentTheme = ctx.theme + this.resolveContext(ctx) + // Deliver the initial values to handlers registered before init arrived + // (createTolgeeApp() is called, then handlers are added synchronously, all + // before the host's init message). Without this, onSelectionChanged / + // onThemeChanged would only fire on later changes, never on first load. + this.selectionHandlers.forEach((h) => h(ctx.selection)) + this.themeHandlers.forEach((h) => h(ctx.theme)) + } else if (isSelectionChanged(d)) { + this.currentSelection = { + keyId: d.keyId, + languageId: d.languageId, + languageTag: d.languageTag, + translationId: d.translationId, + selectedLanguages: d.selectedLanguages, + } + this.selectionHandlers.forEach((h) => h(this.currentSelection)) + } else if (isThemeChanged(d)) { + this.currentTheme = d.theme + this.themeHandlers.forEach((h) => h(d.theme)) + } + } +} + +export const createTolgeeApp = (): TolgeeApp => new TolgeeApp() diff --git a/apps/tolgee-apps-sdk/src/index.ts b/apps/tolgee-apps-sdk/src/index.ts new file mode 100644 index 00000000000..31585c92acd --- /dev/null +++ b/apps/tolgee-apps-sdk/src/index.ts @@ -0,0 +1,29 @@ +/** + * @tolgee/apps-sdk — shared types for both iframe and backend code. + * Environment-specific entry points: `@tolgee/apps-sdk/browser` and + * `@tolgee/apps-sdk/server`. + */ +export type { + AppWebhookPayload, + WebhookPayloadFor, + WebhookType, +} from './shared/webhookTypes' +export type { + AppContextClaims, + TolgeeAppContext, + TolgeeAppSelection, +} from './shared/contextTypes' +export type { + AppManifest, + AppModules, + AppWebhooks, + AppAction, + AppActionType, + AppIframeModule, + AppShortcut, +} from './shared/manifestTypes' +export type { + DecoratorsRequest, + DecoratorsResponse, + DecoratorItem, +} from './shared/decoratorTypes' diff --git a/apps/tolgee-apps-sdk/src/server/config.ts b/apps/tolgee-apps-sdk/src/server/config.ts new file mode 100644 index 00000000000..91e7c101457 --- /dev/null +++ b/apps/tolgee-apps-sdk/src/server/config.ts @@ -0,0 +1,24 @@ +export type TolgeeAppConfig = { + /** Base URL of the Tolgee instance the app is installed on. */ + tolgeeUrl: string + /** Per-install webhook secret; null when unset (dev: signatures unverified). */ + webhookSecret: string | null + /** Vite dev-server port (iframe assets). */ + vitePort: number + /** App server port (manifest/webhook/decorator routes). */ + serverPort: number +} + +/** + * Reads a Tolgee App's standard environment variables into a typed config. + * Centralizes the env-var contract (names + defaults) so every app reads them + * the same way. + */ +export const loadTolgeeAppConfig = ( + env: NodeJS.ProcessEnv = process.env +): TolgeeAppConfig => ({ + tolgeeUrl: env.TOLGEE_URL ?? 'https://app.tolgee.io', + webhookSecret: env.TOLGEE_WEBHOOK_SECRET ?? null, + vitePort: Number(env.VITE_PORT ?? 5180), + serverPort: Number(env.SERVER_PORT ?? env.PORT ?? 5181), +}) diff --git a/apps/tolgee-apps-sdk/src/server/cors.ts b/apps/tolgee-apps-sdk/src/server/cors.ts new file mode 100644 index 00000000000..30dbad469fb --- /dev/null +++ b/apps/tolgee-apps-sdk/src/server/cors.ts @@ -0,0 +1,15 @@ +/** + * CORS headers a Tolgee App's server endpoints (decorators, custom APIs) must + * return so the webapp — served from a different origin than the app — can call + * them. Wildcard origin is safe here because the install token travels in the + * `Authorization` header, not a credentialed cookie. + * + * Apply them in any framework, e.g. Express: + * for (const [k, v] of Object.entries(tolgeeAppCorsHeaders())) res.setHeader(k, v) + */ +export const tolgeeAppCorsHeaders = (): Record => ({ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Allow-Headers': 'Authorization,Content-Type', + 'Access-Control-Max-Age': '600', +}) diff --git a/apps/tolgee-apps-sdk/src/server/decodeContextToken.ts b/apps/tolgee-apps-sdk/src/server/decodeContextToken.ts new file mode 100644 index 00000000000..85f43d1d900 --- /dev/null +++ b/apps/tolgee-apps-sdk/src/server/decodeContextToken.ts @@ -0,0 +1,38 @@ +import type { AppContextClaims } from '../shared/contextTypes' + +/** + * Decodes (does NOT cryptographically verify) the Tolgee-issued context + * JWT into typed claims. Plugin backends generally don't have access to + * the platform's signing key, so verification happens server-side at + * Tolgee — passing the same token as a bearer on REST calls back to + * Tolgee is what authenticates the plugin. + * + * Use this when you need the install/user/project ids out of the token + * for logging, routing, or persistence. + */ +export const decodeContextToken = (jwt: string): AppContextClaims => { + const parts = jwt.split('.') + if (parts.length !== 3) { + throw new Error('Malformed JWT: expected 3 segments') + } + const payload = JSON.parse(base64UrlDecode(parts[1])) as Record + + const installId = payload['tg.app.inst'] + const projectId = payload['tg.app.proj'] + if (typeof installId !== 'number' || typeof projectId !== 'number') { + throw new Error('Token missing tg.app.inst or tg.app.proj claim') + } + return { + installId, + projectId, + userId: Number(payload.sub), + audience: String(payload.aud), + expiresAt: Number(payload.exp), + } +} + +const base64UrlDecode = (s: string): string => { + const normalized = s.replace(/-/g, '+').replace(/_/g, '/') + const pad = (4 - (normalized.length % 4)) % 4 + return atob(normalized + '='.repeat(pad)) +} diff --git a/apps/tolgee-apps-sdk/src/server/index.ts b/apps/tolgee-apps-sdk/src/server/index.ts new file mode 100644 index 00000000000..2168066e5be --- /dev/null +++ b/apps/tolgee-apps-sdk/src/server/index.ts @@ -0,0 +1,29 @@ +export { verifyWebhookSignature } from './verifyWebhookSignature' +export { onWebhook } from './onWebhook' +export { receiveWebhook } from './receiveWebhook' +export type { ReceiveWebhookInput, ReceiveWebhookResult } from './receiveWebhook' +export { decodeContextToken } from './decodeContextToken' +export { tolgeeAppCorsHeaders } from './cors' +export { renderManifest } from './renderManifest' +export { loadTolgeeAppConfig } from './config' +export type { TolgeeAppConfig } from './config' +export type { + AppWebhookPayload, + WebhookPayloadFor, + WebhookType, +} from '../shared/webhookTypes' +export type { AppContextClaims } from '../shared/contextTypes' +export type { + AppManifest, + AppModules, + AppWebhooks, + AppAction, + AppActionType, + AppIframeModule, + AppShortcut, +} from '../shared/manifestTypes' +export type { + DecoratorsRequest, + DecoratorsResponse, + DecoratorItem, +} from '../shared/decoratorTypes' diff --git a/apps/tolgee-apps-sdk/src/server/onWebhook.ts b/apps/tolgee-apps-sdk/src/server/onWebhook.ts new file mode 100644 index 00000000000..d396f8b9f32 --- /dev/null +++ b/apps/tolgee-apps-sdk/src/server/onWebhook.ts @@ -0,0 +1,33 @@ +import type { + AppWebhookPayload, + WebhookPayloadFor, + WebhookType, +} from '../shared/webhookTypes' + +/** + * If `payload` matches one of the supplied activity `types`, invokes + * `handler` with the payload narrowed to that specific type's schema. + * Returns true when the handler ran. + * + * onWebhook(payload, 'SET_TRANSLATIONS', (p) => { + * // p: WebhookPayloadFor<'SET_TRANSLATIONS'> + * p.activityData?.modifiedEntities?.Translation?.forEach(...) + * }) + * + * onWebhook(payload, ['SET_TRANSLATIONS', 'BATCH_SET_TRANSLATION_STATE'], (p) => { + * // p: WebhookPayloadFor<'SET_TRANSLATIONS' | 'BATCH_SET_TRANSLATION_STATE'> + * }) + */ +export function onWebhook( + payload: AppWebhookPayload, + types: T | readonly T[], + handler: (typed: WebhookPayloadFor) => void +): boolean { + const list = Array.isArray(types) ? types : [types as T] + const activityType = payload.activityData?.type + if (activityType && list.includes(activityType as T)) { + handler(payload as WebhookPayloadFor) + return true + } + return false +} diff --git a/apps/tolgee-apps-sdk/src/server/receiveWebhook.ts b/apps/tolgee-apps-sdk/src/server/receiveWebhook.ts new file mode 100644 index 00000000000..a098226e157 --- /dev/null +++ b/apps/tolgee-apps-sdk/src/server/receiveWebhook.ts @@ -0,0 +1,46 @@ +import type { AppWebhookPayload } from '../shared/webhookTypes' +import { verifyWebhookSignature } from './verifyWebhookSignature' + +export type ReceiveWebhookInput = { + /** The exact bytes Tolgee signed — do NOT pass a re-serialized object. */ + rawBody: string + /** Value of the `Tolgee-Signature` header. */ + signatureHeader: string | null | undefined + /** + * Per-install webhook secret. When null/undefined, signature verification + * is skipped — only acceptable in local development. + */ + secret: string | null | undefined +} + +export type ReceiveWebhookResult = + | { ok: true; payload: AppWebhookPayload } + | { ok: false; status: 401 | 400; error: string } + +/** + * Framework-agnostic webhook intake: verifies the `Tolgee-Signature` over the + * raw body and parses it into a typed payload. Returns a result the caller maps + * to its HTTP framework; on success, dispatch with `onWebhook`. + * + * const r = await receiveWebhook({ rawBody, signatureHeader, secret }) + * if (!r.ok) return res.status(r.status).json({ error: r.error }) + * onWebhook(r.payload, 'SET_TRANSLATIONS', (p) => { ... }) + */ +export async function receiveWebhook( + input: ReceiveWebhookInput +): Promise { + if (input.secret) { + const valid = await verifyWebhookSignature({ + header: input.signatureHeader, + rawBody: input.rawBody, + secret: input.secret, + }) + if (!valid) return { ok: false, status: 401, error: 'invalid signature' } + } + + try { + return { ok: true, payload: JSON.parse(input.rawBody) as AppWebhookPayload } + } catch { + return { ok: false, status: 400, error: 'invalid json' } + } +} diff --git a/apps/tolgee-apps-sdk/src/server/renderManifest.ts b/apps/tolgee-apps-sdk/src/server/renderManifest.ts new file mode 100644 index 00000000000..0f9a2327910 --- /dev/null +++ b/apps/tolgee-apps-sdk/src/server/renderManifest.ts @@ -0,0 +1,10 @@ +const BASE_URL_PLACEHOLDER = '__BASE_URL__' + +/** + * Substitutes the `__BASE_URL__` placeholder in a manifest template with the + * app's currently-reachable base URL (a tunnel URL in dev, the deployed origin + * in production). Keeping the placeholder in the stored template lets the URL + * change between dev restarts without editing the manifest. + */ +export const renderManifest = (template: string, baseUrl: string): string => + template.replaceAll(BASE_URL_PLACEHOLDER, baseUrl) diff --git a/apps/tolgee-apps-sdk/src/server/verifyWebhookSignature.ts b/apps/tolgee-apps-sdk/src/server/verifyWebhookSignature.ts new file mode 100644 index 00000000000..63a641c7690 --- /dev/null +++ b/apps/tolgee-apps-sdk/src/server/verifyWebhookSignature.ts @@ -0,0 +1,65 @@ +/** + * Verifies the `Tolgee-Signature` HMAC header against the raw POST body. + * Returns true when the signature matches the per-install webhook + * secret. Uses WebCrypto so the same code runs on Node 20+, Cloudflare + * Workers, Bun, and Deno. + * + * The caller is responsible for reading the raw request body before any + * JSON parser sees it — Express's `express.text({ type: 'application/json' })` + * is the standard way. + * + * const ok = await verifyWebhookSignature({ + * header: req.header('Tolgee-Signature'), + * rawBody: req.body, + * secret: process.env.TOLGEE_WEBHOOK_SECRET!, + * }) + */ +export async function verifyWebhookSignature(opts: { + header: string | null | undefined + rawBody: string + secret: string +}): Promise { + if (!opts.header) return false + + let parsed: { timestamp?: unknown; signature?: unknown } + try { + parsed = JSON.parse(opts.header) + } catch { + return false + } + const { timestamp, signature } = parsed + if (typeof timestamp !== 'number' || typeof signature !== 'string') { + return false + } + + const encoder = new TextEncoder() + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(opts.secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ) + const sigBytes = await crypto.subtle.sign( + 'HMAC', + key, + encoder.encode(`${timestamp}.${opts.rawBody}`) + ) + const expected = hexEncode(new Uint8Array(sigBytes)) + return timingSafeStringEqual(expected, signature) +} + +const hexEncode = (bytes: Uint8Array): string => { + let out = '' + for (const b of bytes) out += b.toString(16).padStart(2, '0') + return out +} + +const timingSafeStringEqual = (a: string, b: string): boolean => { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i) + } + return diff === 0 +} diff --git a/apps/tolgee-apps-sdk/src/shared/contextTypes.ts b/apps/tolgee-apps-sdk/src/shared/contextTypes.ts new file mode 100644 index 00000000000..85ae5050ee9 --- /dev/null +++ b/apps/tolgee-apps-sdk/src/shared/contextTypes.ts @@ -0,0 +1,74 @@ +/** + * Selection passed to iframes that render in a context where the user + * has focused a specific key, language, or translation cell. All + * fields are optional — modules launched from the project sidebar see + * an empty selection. + */ +export type TolgeeAppSelection = { + keyId?: number + languageId?: number + languageTag?: string + translationId?: number + /** + * Tags of the languages currently shown in the translations view. Provided + * to the `translation-tools-panel-empty` module; updates as the user changes + * the language selector. Undefined for surfaces that don't supply it. + */ + selectedLanguages?: string[] +} + +/** + * The host's current theme. Delivered at init and again whenever the user + * toggles light/dark, so the plugin can match Tolgee's look. `colors` are + * resolved CSS color strings from Tolgee's palette; feed them to + * {@link applyTolgeeTheme} to expose them as `--tg-color-*` CSS variables. + */ +export type TolgeeAppTheme = { + mode: 'light' | 'dark' + colors: { + background: string + backgroundPaper: string + text: string + textSecondary: string + primary: string + primaryContrast: string + divider: string + error: string + } +} + +/** + * Context delivered to the iframe via the `tolgee-app:init` postMessage. + * `token` is the install-context JWT — pass it as a bearer token on + * every REST call back to Tolgee. + */ +export type TolgeeAppContext = { + token: string + apiUrl: string + organizationId: number + projectId: number + selection: TolgeeAppSelection + /** Host theme at init; subscribe to changes via `onThemeChanged`. */ + theme: TolgeeAppTheme + /** + * Trigger-specific fields merged into the init payload (e.g. + * `selectedKeyIds` for bulk actions, `keyId` for key-edit-footer + * modals). Untyped because the set varies per trigger surface; cast + * as needed. + */ + extra: Record +} + +/** + * Claims extracted from the Tolgee-issued JWT carried in + * `TolgeeAppContext.token`. Backend plugins typically don't need to + * verify the signature themselves — they pass the token as a bearer + * token on REST calls back to Tolgee, which verifies it server-side. + */ +export type AppContextClaims = { + installId: number + projectId: number + userId: number + audience: string + expiresAt: number +} diff --git a/apps/tolgee-apps-sdk/src/shared/decoratorTypes.ts b/apps/tolgee-apps-sdk/src/shared/decoratorTypes.ts new file mode 100644 index 00000000000..05939ebccfa --- /dev/null +++ b/apps/tolgee-apps-sdk/src/shared/decoratorTypes.ts @@ -0,0 +1,28 @@ +/** + * Contract for the dynamic-decorators endpoint (`manifest.decoratorsUrl`). + * The Tolgee webapp POSTs a [DecoratorsRequest] describing the rows currently + * visible; the app replies with a [DecoratorsResponse] of icon decorations to + * render alongside native row icons. + */ +export type DecoratorsRequest = { + installId: number + projectId: number + /** Key rows in view. */ + keyIds?: number[] + /** Language columns in view, by tag. */ + languageTags?: string[] +} + +/** One decoration the app wants rendered on a key (or key+language) cell. */ +export type DecoratorItem = { + keyId: number + /** Omit for a key-level decoration; set for a translation-cell decoration. */ + languageTag?: string + /** Named icon from the platform icon set. */ + icon: string + tooltip?: string +} + +export type DecoratorsResponse = { + items: DecoratorItem[] +} diff --git a/apps/tolgee-apps-sdk/src/shared/manifestTypes.ts b/apps/tolgee-apps-sdk/src/shared/manifestTypes.ts new file mode 100644 index 00000000000..7e1a85cb97d --- /dev/null +++ b/apps/tolgee-apps-sdk/src/shared/manifestTypes.ts @@ -0,0 +1,94 @@ +import type { WebhookType } from './webhookTypes' + +/** + * Typed model of a Tolgee App manifest. This is the single source of truth + * shared by `create-tolgee-app`'s builder, the generated `manifest.template.json`, + * and hand-written apps. The JSON keys (incl. kebab-case module names) match + * exactly what the platform's `AppManifestFetcher` parses and validates. + */ +export type AppManifest = { + id: string + name: string + version: string + baseUrl: string + /** Endpoint the webapp POSTs to for dynamic row decorators. */ + decoratorsUrl?: string + /** Tolgee permission scopes the app requests at install time (e.g. `keys.edit`). */ + scopes?: string[] + webhooks?: AppWebhooks + modules?: AppModules +} + +export type AppWebhooks = { + events: WebhookType[] + /** Relative to `baseUrl` or absolute. */ + url: string +} + +/** + * The action surface kind. Trigger surfaces (bulk/toolbar/menu/shortcut) accept + * only `link` and `modal`; `key-action` additionally accepts `tab`; `translation-action` + * additionally accepts `panel`. The platform rejects invalid combinations at register time. + */ +export type AppActionType = 'link' | 'tab' | 'panel' | 'modal' + +/** An iframe-bearing module (dashboard page, panel, key-edit tab, modal). */ +export type AppIframeModule = { + key: string + title: string + /** Named icon from the platform icon set (e.g. `LayoutAlt04`). */ + icon?: string + /** Route the iframe loads, relative to `baseUrl`. */ + entry: string + /** Modal-only: iframe pixel size. */ + width?: number + height?: number +} + +/** An action that triggers a link, an iframe module, or a modal. */ +export type AppAction = { + key: string + type: AppActionType + icon?: string + tooltip?: string + title?: string + /** `link` actions: target URL, may contain placeholders like `{keyId}`. */ + urlTemplate?: string + /** `tab` actions: which `key-edit-tab` to open. */ + tabKey?: string + /** `panel` actions: which `translation-tools-panel` to open. */ + panelKey?: string + /** `modal` actions: which `modal` to open. */ + modalKey?: string + /** + * When true, visibility/decoration of this action is driven by the app's + * `decoratorsUrl` response rather than shown statically on every row. + */ + dynamic?: boolean +} + +/** A global keyboard-shortcut binding that triggers a link or modal. */ +export type AppShortcut = Omit & { + /** e.g. `Mod+Shift+E`. */ + combination: string + type: Extract +} + +/** + * App modules keyed by the platform's kebab-case module identifiers. Every + * key is optional; an app contributes only the surfaces it needs. + */ +export type AppModules = { + 'project-dashboard-page'?: AppIframeModule[] + 'translation-tools-panel'?: AppIframeModule[] + /** Panel shown in the translations tools area when no cell is being edited. */ + 'translation-tools-panel-empty'?: AppIframeModule[] + 'key-edit-tab'?: AppIframeModule[] + modal?: AppIframeModule[] + 'key-action'?: AppAction[] + 'translation-action'?: AppAction[] + 'bulk-action'?: AppAction[] + 'translations-toolbar-action'?: AppAction[] + 'project-menu-action'?: AppAction[] + shortcut?: AppShortcut[] +} diff --git a/apps/tolgee-apps-sdk/src/shared/webhookTypes.ts b/apps/tolgee-apps-sdk/src/shared/webhookTypes.ts new file mode 100644 index 00000000000..7e42293c753 --- /dev/null +++ b/apps/tolgee-apps-sdk/src/shared/webhookTypes.ts @@ -0,0 +1,25 @@ +import type { webhooks } from '@tginternal/client' + +/** + * Identifier of a webhook event Tolgee can send — keys of the `webhooks` + * interface from the generated OpenAPI schema. Examples: `"SET_TRANSLATIONS"`, + * `"BATCH_KEY_RESTORE"`, `"CREATE_KEY"`. + */ +export type WebhookType = keyof webhooks + +/** + * Request-body type for a given webhook event. + * + * type SetTranslationsPayload = WebhookPayloadFor<'SET_TRANSLATIONS'> + */ +export type WebhookPayloadFor = + webhooks[T]['post']['requestBody']['content']['application/json'] + +/** + * Discriminated union of every typed webhook payload Tolgee can send. + * Use this as the parsed type for the raw POST body before dispatching + * via `onWebhook` from `@tolgee/apps-sdk/server`. + */ +export type AppWebhookPayload = { + [K in WebhookType]: WebhookPayloadFor +}[WebhookType] diff --git a/apps/tolgee-apps-sdk/tsconfig.json b/apps/tolgee-apps-sdk/tsconfig.json new file mode 100644 index 00000000000..1c4287fde4d --- /dev/null +++ b/apps/tolgee-apps-sdk/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "src", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "ignoreDeprecations": "6.0", + "skipLibCheck": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/apps/tolgee-apps-sdk/tsup.config.ts b/apps/tolgee-apps-sdk/tsup.config.ts new file mode 100644 index 00000000000..2d84126cd91 --- /dev/null +++ b/apps/tolgee-apps-sdk/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + // Named entries so the three subpaths don't collide on `index.js`. + entry: { + index: 'src/index.ts', + server: 'src/server/index.ts', + browser: 'src/browser/index.ts', + }, + outDir: 'dist', + format: ['esm'], + target: 'es2022', + dts: true, + clean: true, + sourcemap: true, + // @tginternal/client is a runtime dependency — keep it external. + external: ['@tginternal/client'], +}) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/apps/AppSelfController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/apps/AppSelfController.kt new file mode 100644 index 00000000000..a78afdf281f --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/apps/AppSelfController.kt @@ -0,0 +1,68 @@ +package io.tolgee.api.v2.controllers.apps + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.constants.Message +import io.tolgee.dtos.request.RegisterAppRequest +import io.tolgee.exceptions.AuthenticationException +import io.tolgee.hateoas.organization.apps.AppInstallModel +import io.tolgee.hateoas.organization.apps.AppInstallModelAssembler +import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.service.apps.AppInstallService +import org.springframework.web.bind.annotation.CrossOrigin +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Endpoints a plugin install can call about itself. The auth filter accepts + * `X-API-Key: tgapps_` (or HTTP Basic with `clientId:clientSecret`) + * and resolves it to the calling [AppInstall]; this controller pulls the install + * out of the security context, so callers can't reach other installs. + */ +@RestController +@CrossOrigin(origins = ["*"]) +@RequestMapping(value = ["/v2/apps/self"]) +@Tag(name = "Self-service plugin API") +class AppSelfController( + private val authenticationFacade: AuthenticationFacade, + private val appInstallService: AppInstallService, + private val appInstallModelAssembler: AppInstallModelAssembler, +) { + @PatchMapping("/manifest-url") + @Operation( + summary = "Update own manifest URL", + description = + "Repoints the install at a new manifest URL and re-fetches the manifest. " + + "The new manifest must declare the same `id` as the original — a plugin can " + + "swap its dev tunnel URL between restarts but can't masquerade as a different " + + "plugin. Authenticated by the install's `clientSecret`.", + ) + fun updateManifestUrl( + @RequestBody body: RegisterAppRequest, + ): AppInstallModel { + val install = currentInstall() + val updated = + appInstallService.updateManifestUrl( + organizationId = install.organization.id, + installId = install.id, + manifestUrl = body.manifestUrl, + // Plugin-authenticated call: must never widen its own granted scopes. + allowScopeWidening = false, + ) + return appInstallModelAssembler.toModel(updated) + } + + private fun currentInstall() = + run { + val auth = + runCatching { authenticationFacade.appAuthentication } + .getOrNull() + ?: throw AuthenticationException(Message.INVALID_APP_CREDENTIALS) + if (!auth.isInstallContext) { + throw AuthenticationException(Message.INVALID_APP_CREDENTIALS) + } + auth.appInstall + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationAppsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationAppsController.kt new file mode 100644 index 00000000000..f0c3797c829 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationAppsController.kt @@ -0,0 +1,162 @@ +package io.tolgee.api.v2.controllers.organization + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.dtos.apps.AppManifest +import io.tolgee.dtos.request.RegisterAppRequest +import io.tolgee.hateoas.organization.apps.AppInstallModel +import io.tolgee.hateoas.organization.apps.AppInstallModelAssembler +import io.tolgee.hateoas.organization.apps.AppManifestPreviewModel +import io.tolgee.hateoas.organization.apps.AppRegistrationResponseModel +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.security.OrganizationHolder +import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.security.authorization.RequiresOrganizationRole +import io.tolgee.service.apps.AppInstallService +import org.springframework.hateoas.CollectionModel +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(value = ["/v2/organizations/{organizationId:[0-9]+}/apps"]) +@Tag(name = "Organization Apps") +class OrganizationAppsController( + private val organizationHolder: OrganizationHolder, + private val authenticationFacade: AuthenticationFacade, + private val appInstallService: AppInstallService, + private val appInstallModelAssembler: AppInstallModelAssembler, + private val objectMapper: ObjectMapper, +) { + @PostMapping("/preview") + @RequiresOrganizationRole(OrganizationRoleType.OWNER) + @Operation( + summary = "Preview a Tolgee app manifest", + description = + "Fetches the manifest at the given URL and returns its parsed contents (including the requested scopes) " + + "without persisting anything. Used by the registration UI to show a consent prompt before installing.", + ) + fun preview( + @PathVariable organizationId: Long, + @RequestBody data: RegisterAppRequest, + ): AppManifestPreviewModel { + val fetched = appInstallService.previewManifest(data.manifestUrl) + return AppManifestPreviewModel( + appId = fetched.manifest.id, + name = fetched.manifest.name, + version = fetched.manifest.version, + baseUrl = fetched.manifest.baseUrl, + modules = fetched.manifest.modules, + requestedScopes = fetched.scopes.map { it.value }, + requestedWebhookEvents = fetched.webhookEvents.toList(), + ) + } + + @PostMapping + @RequiresOrganizationRole(OrganizationRoleType.OWNER) + @Operation( + summary = "Register a Tolgee app", + description = "Fetches the manifest at the given URL and registers the app for the organization.", + ) + fun register( + @PathVariable organizationId: Long, + @RequestBody data: RegisterAppRequest, + ): AppRegistrationResponseModel { + val result = + appInstallService.register( + organization = organizationHolder.organizationEntity, + manifestUrl = data.manifestUrl, + author = authenticationFacade.authenticatedUserEntity, + ) + val install = result.install + val manifest = objectMapper.readValue(install.manifestJson) + return AppRegistrationResponseModel( + id = install.id, + manifestUrl = install.manifestUrl, + appId = install.appId, + name = install.name, + version = install.version, + baseUrl = install.baseUrl, + modules = manifest.modules, + scopes = install.grantedScopes.map { it.value }, + webhookEvents = install.webhookSubscriptions.toList(), + webhookUrl = install.webhookUrl, + clientId = install.clientId, + clientSecretPrefix = install.clientSecretPrefix, + webhookSecret = install.webhookSecret, + decoratorsUrl = manifest.decoratorsUrl, + clientSecret = result.plaintextClientSecret, + ) + } + + @GetMapping + @RequiresOrganizationRole(OrganizationRoleType.OWNER) + @Operation( + summary = "List registered apps", + description = "Returns all apps registered for the organization.", + ) + fun list( + @PathVariable organizationId: Long, + ): CollectionModel { + val installs = appInstallService.findAll(organizationId) + return appInstallModelAssembler.toCollectionModel(installs) + } + + @PostMapping("/{installId}/refresh") + @RequiresOrganizationRole(OrganizationRoleType.OWNER) + @Operation( + summary = "Refresh manifest", + description = "Re-fetches the manifest from the registered URL and updates the stored snapshot.", + ) + fun refresh( + @PathVariable organizationId: Long, + @PathVariable installId: Long, + ): AppInstallModel { + val install = appInstallService.refresh(organizationId, installId) + return appInstallModelAssembler.toModel(install) + } + + @PatchMapping("/{installId}/manifest-url") + @RequiresOrganizationRole(OrganizationRoleType.OWNER) + @Operation( + summary = "Update manifest URL", + description = + "Repoints an existing install at a new manifest URL and re-fetches the manifest from there. " + + "The new manifest must declare the same `id` as the original. Useful for development: a tunnel " + + "URL that changes on every restart can be swapped in without re-installing the app.", + ) + fun updateManifestUrl( + @PathVariable organizationId: Long, + @PathVariable installId: Long, + @RequestBody body: RegisterAppRequest, + ): AppInstallModel { + val install = + appInstallService.updateManifestUrl( + organizationId = organizationId, + installId = installId, + manifestUrl = body.manifestUrl, + allowScopeWidening = true, + ) + return appInstallModelAssembler.toModel(install) + } + + @DeleteMapping("/{installId}") + @RequiresOrganizationRole(OrganizationRoleType.OWNER) + @Operation( + summary = "Remove app", + description = "Removes the registered app from the organization.", + ) + fun remove( + @PathVariable organizationId: Long, + @PathVariable installId: Long, + ) { + appInstallService.remove(organizationId, installId) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectAppsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectAppsController.kt new file mode 100644 index 00000000000..ac06c440089 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectAppsController.kt @@ -0,0 +1,108 @@ +package io.tolgee.api.v2.controllers.project + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.constants.Message +import io.tolgee.exceptions.NotFoundException +import io.tolgee.hateoas.project.apps.AppTokenModel +import io.tolgee.hateoas.project.apps.ProjectAppModel +import io.tolgee.hateoas.project.apps.ProjectAppModelAssembler +import io.tolgee.model.enums.Scope +import io.tolgee.security.ProjectHolder +import io.tolgee.security.authentication.AppTokenService +import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.security.authorization.RequiresProjectPermissions +import io.tolgee.security.authorization.UseDefaultPermissions +import io.tolgee.service.apps.AppEnablementService +import org.springframework.hateoas.CollectionModel +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(value = ["/v2/projects/{projectId:[0-9]+}/apps"]) +@Tag(name = "Project Apps") +class ProjectAppsController( + private val projectHolder: ProjectHolder, + private val authenticationFacade: AuthenticationFacade, + private val appEnablementService: AppEnablementService, + private val appTokenService: AppTokenService, + private val projectAppModelAssembler: ProjectAppModelAssembler, +) { + @GetMapping + @UseDefaultPermissions + @Operation( + summary = "List apps for project", + description = + "Returns all apps registered in the project's organization, " + + "each annotated with whether it is enabled for this project.", + ) + fun list( + @PathVariable projectId: Long, + ): CollectionModel { + val project = projectHolder.projectEntity + val results = appEnablementService.listAppsForProject(project) + val models = results.map { (install, enabled) -> projectAppModelAssembler.toModel(install, enabled) } + return CollectionModel.of(models) + } + + @PutMapping("/{installId}") + @RequiresProjectPermissions([Scope.PROJECT_EDIT]) + @Operation( + summary = "Enable app for project", + description = "Enables the given app install for this project. Idempotent.", + ) + fun enable( + @PathVariable projectId: Long, + @PathVariable installId: Long, + ): ProjectAppModel { + val install = + appEnablementService.enable( + project = projectHolder.projectEntity, + installId = installId, + author = authenticationFacade.authenticatedUserEntity, + ) + return projectAppModelAssembler.toModel(install, enabled = true) + } + + @DeleteMapping("/{installId}") + @RequiresProjectPermissions([Scope.PROJECT_EDIT]) + @Operation( + summary = "Disable app for project", + description = "Disables the given app for this project. Idempotent — no-op if it wasn't enabled.", + ) + fun disable( + @PathVariable projectId: Long, + @PathVariable installId: Long, + ) { + appEnablementService.disable(projectHolder.project.id, installId) + } + + @PostMapping("/{installId}/token") + @UseDefaultPermissions + @Operation( + summary = "Mint a user-context app token", + description = + "Issues a short-lived JWT bound to (install, project, current user) that the iframe can use to call " + + "Tolgee's REST API on behalf of the user. Returns 404 if the install is not enabled for this project.", + ) + fun mintToken( + @PathVariable projectId: Long, + @PathVariable installId: Long, + ): AppTokenModel { + if (!appEnablementService.isEnabledForProject(projectId, installId)) { + throw NotFoundException(Message.APP_INSTALL_NOT_FOUND) + } + val token = + appTokenService.mintUserContextToken( + installId = installId, + userId = authenticationFacade.authenticatedUser.id, + projectId = projectId, + ) + return AppTokenModel(token = token) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppInstallModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppInstallModel.kt new file mode 100644 index 00000000000..52a8fc6c4b7 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppInstallModel.kt @@ -0,0 +1,23 @@ +package io.tolgee.hateoas.organization.apps + +import io.tolgee.dtos.apps.AppManifestModules +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "appInstalls", itemRelation = "appInstall") +open class AppInstallModel( + val id: Long, + val manifestUrl: String, + val appId: String, + val name: String, + val version: String, + val baseUrl: String, + val modules: AppManifestModules, + val scopes: List, + val webhookEvents: List, + val webhookUrl: String?, + val clientId: String?, + val clientSecretPrefix: String?, + val webhookSecret: String?, + val decoratorsUrl: String? = null, +) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppInstallModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppInstallModelAssembler.kt new file mode 100644 index 00000000000..b879b3aca85 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppInstallModelAssembler.kt @@ -0,0 +1,33 @@ +package io.tolgee.hateoas.organization.apps + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.tolgee.dtos.apps.AppManifest +import io.tolgee.model.apps.AppInstall +import org.springframework.hateoas.server.RepresentationModelAssembler +import org.springframework.stereotype.Component + +@Component +class AppInstallModelAssembler( + private val objectMapper: ObjectMapper, +) : RepresentationModelAssembler { + override fun toModel(entity: AppInstall): AppInstallModel { + val manifest = objectMapper.readValue(entity.manifestJson) + return AppInstallModel( + id = entity.id, + manifestUrl = entity.manifestUrl, + appId = entity.appId, + name = entity.name, + version = entity.version, + baseUrl = entity.baseUrl, + modules = manifest.modules, + scopes = entity.grantedScopes.map { it.value }, + webhookEvents = entity.webhookSubscriptions.toList(), + webhookUrl = entity.webhookUrl, + clientId = entity.clientId, + clientSecretPrefix = entity.clientSecretPrefix, + webhookSecret = entity.webhookSecret, + decoratorsUrl = manifest.decoratorsUrl, + ) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppManifestPreviewModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppManifestPreviewModel.kt new file mode 100644 index 00000000000..c028c5b49e3 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppManifestPreviewModel.kt @@ -0,0 +1,16 @@ +package io.tolgee.hateoas.organization.apps + +import io.tolgee.dtos.apps.AppManifestModules +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(itemRelation = "appManifestPreview") +open class AppManifestPreviewModel( + val appId: String, + val name: String, + val version: String, + val baseUrl: String, + val modules: AppManifestModules, + val requestedScopes: List, + val requestedWebhookEvents: List, +) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppRegistrationResponseModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppRegistrationResponseModel.kt new file mode 100644 index 00000000000..1fb10ce9b69 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppRegistrationResponseModel.kt @@ -0,0 +1,38 @@ +package io.tolgee.hateoas.organization.apps + +import io.tolgee.dtos.apps.AppManifestModules +import org.springframework.hateoas.server.core.Relation + +@Relation(itemRelation = "appRegistration") +class AppRegistrationResponseModel( + id: Long, + manifestUrl: String, + appId: String, + name: String, + version: String, + baseUrl: String, + modules: AppManifestModules, + scopes: List, + webhookEvents: List, + webhookUrl: String?, + clientId: String?, + clientSecretPrefix: String?, + webhookSecret: String?, + decoratorsUrl: String?, + val clientSecret: String, +) : AppInstallModel( + id = id, + manifestUrl = manifestUrl, + appId = appId, + name = name, + version = version, + baseUrl = baseUrl, + modules = modules, + scopes = scopes, + webhookEvents = webhookEvents, + webhookUrl = webhookUrl, + clientId = clientId, + clientSecretPrefix = clientSecretPrefix, + webhookSecret = webhookSecret, + decoratorsUrl = decoratorsUrl, + ) diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/apps/AppTokenModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/apps/AppTokenModel.kt new file mode 100644 index 00000000000..dc99124882f --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/apps/AppTokenModel.kt @@ -0,0 +1,9 @@ +package io.tolgee.hateoas.project.apps + +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(itemRelation = "appToken") +open class AppTokenModel( + val token: String, +) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/apps/ProjectAppModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/apps/ProjectAppModel.kt new file mode 100644 index 00000000000..4c49557dc9d --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/apps/ProjectAppModel.kt @@ -0,0 +1,18 @@ +package io.tolgee.hateoas.project.apps + +import io.tolgee.dtos.apps.AppManifestModules +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "projectApps", itemRelation = "projectApp") +open class ProjectAppModel( + val id: Long, + val manifestUrl: String, + val appId: String, + val name: String, + val version: String, + val baseUrl: String, + val modules: AppManifestModules, + val enabled: Boolean, + val decoratorsUrl: String? = null, +) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/apps/ProjectAppModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/apps/ProjectAppModelAssembler.kt new file mode 100644 index 00000000000..100a27e4012 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/apps/ProjectAppModelAssembler.kt @@ -0,0 +1,30 @@ +package io.tolgee.hateoas.project.apps + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.tolgee.dtos.apps.AppManifest +import io.tolgee.model.apps.AppInstall +import org.springframework.stereotype.Component + +@Component +class ProjectAppModelAssembler( + private val objectMapper: ObjectMapper, +) { + fun toModel( + install: AppInstall, + enabled: Boolean, + ): ProjectAppModel { + val manifest = objectMapper.readValue(install.manifestJson) + return ProjectAppModel( + id = install.id, + manifestUrl = install.manifestUrl, + appId = install.appId, + name = install.name, + version = install.version, + baseUrl = install.baseUrl, + modules = manifest.modules, + enabled = enabled, + decoratorsUrl = manifest.decoratorsUrl, + ) + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/AppPermissionInterceptionTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/AppPermissionInterceptionTest.kt new file mode 100644 index 00000000000..438dfa34659 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/AppPermissionInterceptionTest.kt @@ -0,0 +1,278 @@ +package io.tolgee.api.v2.controllers + +import io.tolgee.component.KeyGenerator +import io.tolgee.development.testDataBuilder.data.AppsTestData +import io.tolgee.model.apps.AppInstall +import io.tolgee.model.enums.Scope +import io.tolgee.repository.apps.AppInstallRepository +import io.tolgee.security.authentication.AppTokenService +import io.tolgee.service.apps.AppEnablementService +import io.tolgee.service.apps.AppInstallService +import io.tolgee.testing.AuthorizedControllerTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.Base64 +import java.util.Date + +/** + * Exercises the full app-auth pipeline end-to-end: + * - AuthenticationFilter recognizes `Bearer ` (audience `tg.app`) and + * resolves install + user + per-project enablement live from the DB. + * - SecurityService intersects install.grantedScopes with the user's project scopes. + * - OrganizationAuthorizationInterceptor rejects app tokens outright. + * + * Tokens are minted via the real [AppTokenService]. Revocation paths (install removed, + * enablement removed, tokensValidNotBefore bumped) take effect immediately because the + * filter re-resolves on every request. + */ +class AppPermissionInterceptionTest : AuthorizedControllerTest() { + @Autowired + lateinit var appInstallService: AppInstallService + + @Autowired + lateinit var appInstallRepository: AppInstallRepository + + @Autowired + lateinit var appEnablementService: AppEnablementService + + @Autowired + lateinit var appTokenService: AppTokenService + + @Autowired + lateinit var keyGenerator: KeyGenerator + + lateinit var testData: AppsTestData + + @BeforeEach + fun setup() { + testData = AppsTestData() + testDataService.saveTestData(testData.root) + userAccount = testData.user + } + + @Test + fun `app-token call succeeds when install and user both grant the required scope`() { + val install = registerInstall(scopes = setOf(Scope.TRANSLATIONS_VIEW, Scope.KEYS_VIEW)) + appEnablementService.enable(testData.project, install.id, testData.user) + val token = appTokenService.mintUserContextToken(install.id, testData.user.id, testData.project.id) + + mvc + .perform(get(allKeysUrl()).header("Authorization", "Bearer $token")) + .andExpect(status().isOk) + } + + @Test + fun `app-token call denied when install lacks the required scope`() { + val install = registerInstall(scopes = setOf(Scope.KEYS_EDIT)) + appEnablementService.enable(testData.project, install.id, testData.user) + val token = appTokenService.mintUserContextToken(install.id, testData.user.id, testData.project.id) + + mvc + .perform(get(allKeysUrl()).header("Authorization", "Bearer $token")) + .andExpect(status().isForbidden) + } + + @Test + fun `app-token returns 401 when install was removed mid-life`() { + val install = registerInstall(scopes = setOf(Scope.TRANSLATIONS_VIEW)) + appEnablementService.enable(testData.project, install.id, testData.user) + val token = appTokenService.mintUserContextToken(install.id, testData.user.id, testData.project.id) + + appInstallService.remove(testData.organization.id, install.id) + + mvc + .perform(get(allKeysUrl()).header("Authorization", "Bearer $token")) + .andExpect(status().isUnauthorized) + } + + @Test + fun `app-token returns 401 when AppEnabledForProject was removed mid-life`() { + val install = registerInstall(scopes = setOf(Scope.TRANSLATIONS_VIEW)) + appEnablementService.enable(testData.project, install.id, testData.user) + val token = appTokenService.mintUserContextToken(install.id, testData.user.id, testData.project.id) + + appEnablementService.disable(testData.project.id, install.id) + + mvc + .perform(get(allKeysUrl()).header("Authorization", "Bearer $token")) + .andExpect(status().isUnauthorized) + } + + @Test + fun `app-token returns 401 when issued before user's tokensValidNotBefore`() { + val install = registerInstall(scopes = setOf(Scope.TRANSLATIONS_VIEW)) + appEnablementService.enable(testData.project, install.id, testData.user) + val token = appTokenService.mintUserContextToken(install.id, testData.user.id, testData.project.id) + + val user = userAccountService.findActive(testData.user.id)!! + user.tokensValidNotBefore = Date(currentDateProvider.date.time + 60_000) + userAccountService.save(user) + + mvc + .perform(get(allKeysUrl()).header("Authorization", "Bearer $token")) + .andExpect(status().isUnauthorized) + } + + @Test + fun `organization endpoint rejects app tokens`() { + val install = registerInstall(scopes = setOf(Scope.TRANSLATIONS_VIEW)) + appEnablementService.enable(testData.project, install.id, testData.user) + val token = appTokenService.mintUserContextToken(install.id, testData.user.id, testData.project.id) + + mvc + .perform( + get("/v2/organizations/${testData.organization.id}/apps") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isForbidden) + } + + @Test + fun `default-permissions endpoint accepts app token when install is enabled for project`() { + val install = registerInstall(scopes = setOf(Scope.TRANSLATIONS_VIEW)) + appEnablementService.enable(testData.project, install.id, testData.user) + val token = appTokenService.mintUserContextToken(install.id, testData.user.id, testData.project.id) + + mvc + .perform( + get("/v2/projects/${testData.project.id}/apps") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isOk) + } + + @Test + fun `malformed Bearer token returns 401`() { + mvc + .perform(get(allKeysUrl()).header("Authorization", "Bearer not.a.real.jwt")) + .andExpect(status().isUnauthorized) + } + + @Test + fun `X-API-Key with tgapps_ secret succeeds when install has required scope`() { + val (install, secret) = registerInstallWithCredentials(setOf(Scope.TRANSLATIONS_VIEW)) + appEnablementService.enable(testData.project, install.id, testData.user) + + mvc + .perform(get(allKeysUrl()).header("X-API-Key", secret)) + .andExpect(status().isOk) + } + + @Test + fun `X-API-Key denied when install lacks required scope`() { + val (install, secret) = registerInstallWithCredentials(setOf(Scope.KEYS_EDIT)) + appEnablementService.enable(testData.project, install.id, testData.user) + + mvc + .perform(get(allKeysUrl()).header("X-API-Key", secret)) + .andExpect(status().isForbidden) + } + + @Test + fun `X-API-Key with tgapps_ secret returns 401 when install is not enabled for project`() { + val (_, secret) = registerInstallWithCredentials(setOf(Scope.TRANSLATIONS_VIEW)) + mvc + .perform(get(allKeysUrl()).header("X-API-Key", secret)) + .andExpect(status().isUnauthorized) + } + + @Test + fun `X-API-Key with wrong secret returns 401`() { + val (install, _) = registerInstallWithCredentials(setOf(Scope.TRANSLATIONS_VIEW)) + appEnablementService.enable(testData.project, install.id, testData.user) + + mvc + .perform( + get(allKeysUrl()) + .header("X-API-Key", "tgapps_${"a".repeat(40)}"), + ).andExpect(status().isUnauthorized) + } + + @Test + fun `Authorization Basic with clientId clientSecret succeeds`() { + val (install, secret) = registerInstallWithCredentials(setOf(Scope.TRANSLATIONS_VIEW)) + appEnablementService.enable(testData.project, install.id, testData.user) + val header = "Basic " + Base64.getEncoder().encodeToString("${install.clientId}:$secret".toByteArray()) + + mvc + .perform(get(allKeysUrl()).header("Authorization", header)) + .andExpect(status().isOk) + } + + @Test + fun `Authorization Basic with wrong secret returns 401`() { + val (install, _) = registerInstallWithCredentials(setOf(Scope.TRANSLATIONS_VIEW)) + appEnablementService.enable(testData.project, install.id, testData.user) + val header = + "Basic " + + Base64.getEncoder().encodeToString("${install.clientId}:tgapps_${"x".repeat(40)}".toByteArray()) + + mvc + .perform(get(allKeysUrl()).header("Authorization", header)) + .andExpect(status().isUnauthorized) + } + + @Test + fun `acting-as header cannot impersonate a user on a non-project endpoint`() { + val (install, secret) = registerInstallWithCredentials(setOf(Scope.TRANSLATIONS_VIEW)) + appEnablementService.enable(testData.project, install.id, testData.user) + + // The app secret authenticates fine on this non-project endpoint as the install author... + mvc + .perform(get("/v2/user").header("X-API-Key", secret)) + .andExpect(status().isOk) + + // ...but it must not be able to act as an arbitrary user where there is no project to bound it. + mvc + .perform( + get("/v2/user") + .header("X-API-Key", secret) + .header("X-Tolgee-Act-As-User-Id", testData.outsider.id.toString()), + ).andExpect(status().isForbidden) + } + + private fun allKeysUrl() = "/v2/projects/${testData.project.id}/all-keys" + + /** + * Creates an `AppInstall` directly via the repository, bypassing the manifest-fetch flow. + * Tests need full control over `grantedScopes` and shouldn't depend on a mocked HTTP layer. + */ + private fun registerInstall(scopes: Set): AppInstall { + val appId = "test-app-${System.nanoTime()}" + val install = + AppInstall().apply { + this.organization = testData.organization + this.author = testData.user + this.manifestUrl = "https://example.com/manifest.json" + this.appId = appId + this.name = "Test App" + this.version = "0.1.0" + this.baseUrl = "https://app.example.com" + this.manifestJson = manifestJsonFor(appId) + this.grantedScopes = scopes.toMutableSet() + } + return appInstallRepository.save(install) + } + + private fun registerInstallWithCredentials(scopes: Set): Pair { + val install = registerInstall(scopes) + val plaintextSecret = "tgapps_${keyGenerator.generate(256)}" + install.clientId = "tgapp_${keyGenerator.generate(128)}" + install.clientSecretHash = keyGenerator.hash(plaintextSecret) + install.clientSecretPrefix = plaintextSecret.take(10) + install.webhookSecret = "tgappw_${keyGenerator.generate(256)}" + return appInstallRepository.save(install) to plaintextSecret + } + + private fun manifestJsonFor(appId: String): String = + """ + { + "id": "$appId", + "name": "Test App", + "version": "0.1.0", + "baseUrl": "https://app.example.com", + "modules": {"project-dashboard-page": []} + } + """.trimIndent() +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/apps/AppSelfControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/apps/AppSelfControllerTest.kt new file mode 100644 index 00000000000..c9383a46125 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/apps/AppSelfControllerTest.kt @@ -0,0 +1,188 @@ +package io.tolgee.api.v2.controllers.apps + +import io.tolgee.development.testDataBuilder.data.AppsTestData +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsBadRequest +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.andIsUnauthorized +import io.tolgee.fixtures.node +import io.tolgee.model.enums.Scope +import io.tolgee.service.apps.AppInstallService +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito.anyString +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.web.client.RestTemplate + +class AppSelfControllerTest : AuthorizedControllerTest() { + @Autowired + lateinit var appInstallService: AppInstallService + + @MockitoBean + @Autowired + lateinit var restTemplate: RestTemplate + + lateinit var testData: AppsTestData + + @BeforeEach + fun setup() { + testData = AppsTestData() + testDataService.saveTestData(testData.root) + userAccount = testData.user + } + + @Test + fun `updates own manifest URL when authenticated with clientSecret`() { + mockManifest(validManifest()) + val clientSecret = registerAndGetClientSecret() + + mockManifest(validManifestV2()) + performSelfPatch( + clientSecret, + mapOf("manifestUrl" to "https://example.com/v2/manifest.json"), + ).andIsOk.andAssertThatJson { + node("version").isEqualTo("0.2.0") + node("modules.project-dashboard-page[0].title").isEqualTo("Home v2") + } + + val install = appInstallService.findAll(testData.organization.id).single() + assertThat(install.manifestUrl).isEqualTo("https://example.com/v2/manifest.json") + } + + @Test + fun `rejects manifest whose app id changed`() { + mockManifest(validManifest()) + val clientSecret = registerAndGetClientSecret() + + mockManifest(validManifest().replace("\"test-app\"", "\"different-app\"")) + performSelfPatch( + clientSecret, + mapOf("manifestUrl" to "https://example.com/other/manifest.json"), + ).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("app_manifest_invalid") + } + } + + @Test + fun `does not widen granted scopes on self manifest update`() { + mockManifest(validManifest()) + val clientSecret = registerAndGetClientSecret() + + // A plugin must not be able to self-grant a scope the owner never consented to by repointing + // at a manifest that declares more scopes. + mockManifest(validManifestWithExtraScope()) + performSelfPatch( + clientSecret, + mapOf("manifestUrl" to "https://example.com/v2/manifest.json"), + ).andIsOk.andAssertThatJson { + node("scopes").isArray.containsExactlyInAnyOrder("translations.view", "keys.edit") + } + + val install = appInstallService.findAll(testData.organization.id).single() + assertThat(install.grantedScopes).containsExactlyInAnyOrder(Scope.TRANSLATIONS_VIEW, Scope.KEYS_EDIT) + } + + @Test + fun `rejects requests with wrong clientSecret`() { + mockManifest(validManifest()) + registerAndGetClientSecret() + + performSelfPatch( + "tgapps_notarealsecretjustbogusbytesforauthnegativetesting", + mapOf("manifestUrl" to "https://example.com/v2/manifest.json"), + ).andIsUnauthorized + } + + @Test + fun `rejects requests without any credentials`() { + perform( + patch("/v2/apps/self/manifest-url") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("manifestUrl" to "https://x/m.json"))), + ).andIsForbidden + } + + private fun registerAndGetClientSecret(): String { + val response = + performAuthPost( + "/v2/organizations/${testData.organization.id}/apps", + mapOf("manifestUrl" to "https://example.com/manifest.json"), + ).andIsOk.andReturn().response.contentAsString + val tree = objectMapper.readTree(response) + return tree.get("clientSecret").asText() + } + + private fun performSelfPatch( + clientSecret: String, + body: Any, + ): ResultActions { + return perform( + patch("/v2/apps/self/manifest-url") + .header("X-API-Key", clientSecret) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)), + ) + } + + private fun mockManifest(json: String) { + doReturn(json).whenever(restTemplate).getForObject(anyString(), eq(String::class.java)) + } + + private fun validManifest(): String = + """ + { + "id": "test-app", + "name": "Test App", + "version": "0.1.0", + "baseUrl": "https://app.example.com", + "scopes": ["translations.view", "keys.edit"], + "modules": { + "project-dashboard-page": [ + {"key": "home", "title": "Home", "icon": "🏠", "entry": "/"} + ] + } + } + """.trimIndent() + + private fun validManifestV2(): String = + """ + { + "id": "test-app", + "name": "Test App", + "version": "0.2.0", + "baseUrl": "https://app.example.com", + "scopes": ["translations.view", "keys.edit"], + "modules": { + "project-dashboard-page": [ + {"key": "home", "title": "Home v2", "icon": "🏠", "entry": "/"} + ] + } + } + """.trimIndent() + + private fun validManifestWithExtraScope(): String = + """ + { + "id": "test-app", + "name": "Test App", + "version": "0.2.0", + "baseUrl": "https://app.example.com", + "scopes": ["translations.view", "keys.edit", "screenshots.upload"], + "modules": { + "project-dashboard-page": [ + {"key": "home", "title": "Home v2", "icon": "🏠", "entry": "/"} + ] + } + } + """.trimIndent() +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationAppsControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationAppsControllerTest.kt new file mode 100644 index 00000000000..fdc8d1ad190 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationAppsControllerTest.kt @@ -0,0 +1,551 @@ +package io.tolgee.api.v2.controllers.organization + +import io.tolgee.development.testDataBuilder.data.AppsTestData +import io.tolgee.fixtures.AuthorizedRequestFactory +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsBadRequest +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.node +import io.tolgee.model.enums.Scope +import io.tolgee.service.apps.AppInstallService +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito.anyString +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.web.client.ResourceAccessException +import org.springframework.web.client.RestTemplate + +class OrganizationAppsControllerTest : AuthorizedControllerTest() { + @Autowired + lateinit var appInstallService: AppInstallService + + @MockitoBean + @Autowired + lateinit var restTemplate: RestTemplate + + lateinit var testData: AppsTestData + + @BeforeEach + fun setup() { + testData = AppsTestData() + testDataService.saveTestData(testData.root) + userAccount = testData.user + } + + @Test + fun `registers an app from a valid manifest`() { + mockManifest(validManifest()) + performAuthPost( + appsUrl(), + mapOf("manifestUrl" to "https://example.com/manifest.json"), + ).andIsOk.andAssertThatJson { + node("appId").isEqualTo("test-app") + node("name").isEqualTo("Test App") + node("version").isEqualTo("0.1.0") + node("baseUrl").isEqualTo("https://app.example.com") + node("modules.project-dashboard-page[0].key").isEqualTo("home") + node("modules.project-dashboard-page[0].title").isEqualTo("Home") + node("modules.project-dashboard-page[0].entry").isEqualTo("/") + node("scopes").isArray.containsExactlyInAnyOrder("translations.view", "keys.edit") + } + val install = appInstallService.findAll(testData.organization.id).single() + assertThat(install.grantedScopes).containsExactlyInAnyOrder(Scope.TRANSLATIONS_VIEW, Scope.KEYS_EDIT) + } + + @Test + fun `parses key-edit-tab and key+translation-action modules with decoratorsUrl`() { + mockManifest(manifestWithActions()) + performAuthPost(appsUrl(), registerBody()).andIsOk.andAssertThatJson { + node("decoratorsUrl").isEqualTo("https://app.example.com/decorators") + node("modules.key-edit-tab[0].key").isEqualTo("audit") + node("modules.key-edit-tab[0].title").isEqualTo("Audit") + node("modules.key-edit-tab[0].entry").isEqualTo("/key-edit-tab/audit") + node("modules.key-action[0].key").isEqualTo("view-source") + node("modules.key-action[0].type").isEqualTo("link") + node("modules.key-action[0].urlTemplate").isEqualTo("https://example.com/{keyName}") + node("modules.key-action[1].key").isEqualTo("open-audit") + node("modules.key-action[1].type").isEqualTo("tab") + node("modules.key-action[1].dynamic").isEqualTo(true) + node("modules.key-action[1].tabKey").isEqualTo("audit") + node("modules.key-action[1].visibility").isEqualTo("on-hover") + node("modules.translation-action[0].key").isEqualTo("show-activity") + node("modules.translation-action[0].type").isEqualTo("panel") + node("modules.translation-action[0].dynamic").isEqualTo(true) + node("modules.translation-action[0].panelKey").isEqualTo("activity") + node("modules.translation-action[0].visibility").isEqualTo("always") + } + } + + @Test + fun `rejects key-action of type tab whose tabKey doesn't resolve`() { + mockManifest(manifestWithActions().replace("\"tabKey\": \"audit\"", "\"tabKey\": \"nope\"")) + performAuthPost(appsUrl(), registerBody()).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("app_manifest_invalid") + } + } + + @Test + fun `rejects translation-action of type panel whose panelKey doesn't resolve`() { + mockManifest(manifestWithActions().replace("\"panelKey\": \"activity\"", "\"panelKey\": \"nope\"")) + performAuthPost(appsUrl(), registerBody()).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("app_manifest_invalid") + } + } + + @Test + fun `accepts webhook subscription to any ActivityType by name`() { + mockManifest(manifestWithWebhookEvents("SET_TRANSLATIONS", "CREATE_KEY", "BATCH_KEY_RESTORE")) + performAuthPost(appsUrl(), registerBody()).andIsOk.andAssertThatJson { + node("webhookEvents") + .isArray + .containsExactlyInAnyOrder("SET_TRANSLATIONS", "CREATE_KEY", "BATCH_KEY_RESTORE") + } + } + + @Test + fun `rejects webhook subscription to unknown event`() { + mockManifest(manifestWithWebhookEvents("NOT_A_REAL_EVENT")) + performAuthPost(appsUrl(), registerBody()).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("app_manifest_invalid") + } + } + + @Test + fun `parses modal and trigger modules and round-trips them`() { + mockManifest(manifestWithModalAndTriggers()) + performAuthPost(appsUrl(), registerBody()).andIsOk.andAssertThatJson { + node("modules.modal[0].key").isEqualTo("explain") + node("modules.modal[0].title").isEqualTo("Explain this key") + node("modules.modal[0].entry").isEqualTo("/modal/explain") + node("modules.bulk-action[0].key").isEqualTo("bulk-explain") + node("modules.bulk-action[0].modalKey").isEqualTo("explain") + node("modules.translations-toolbar-action[0].key").isEqualTo("toolbar-explain") + node("modules.project-menu-action[0].key").isEqualTo("menu-explain") + node("modules.shortcut[0].key").isEqualTo("shortcut-explain") + node("modules.shortcut[0].combination").isEqualTo("Mod+Shift+E") + } + } + + @Test + fun `rejects bulk-action with unknown modalKey`() { + mockManifest( + manifestWithModalAndTriggers().replace("\"modalKey\": \"explain\"", "\"modalKey\": \"nope\""), + ) + performAuthPost(appsUrl(), registerBody()).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("app_manifest_invalid") + } + } + + @Test + fun `rejects shortcut with blank combination`() { + mockManifest(manifestWithModalAndTriggers().replace("\"combination\": \"Mod+Shift+E\"", "\"combination\": \"\"")) + performAuthPost(appsUrl(), registerBody()).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("app_manifest_invalid") + } + } + + @Test + fun `parses translation-tools-panel and -empty modules alongside dashboard pages`() { + mockManifest(manifestWithToolsPanel()) + performAuthPost(appsUrl(), registerBody()).andIsOk.andAssertThatJson { + node("modules.project-dashboard-page[0].key").isEqualTo("home") + node("modules.translation-tools-panel[0].key").isEqualTo("activity") + node("modules.translation-tools-panel[0].title").isEqualTo("Activity") + node("modules.translation-tools-panel[0].icon").isEqualTo("📈") + node("modules.translation-tools-panel[0].entry").isEqualTo("/tools-panel") + node("modules.translation-tools-panel-empty[0].key").isEqualTo("languages") + node("modules.translation-tools-panel-empty[0].title").isEqualTo("Languages") + node("modules.translation-tools-panel-empty[0].entry").isEqualTo("/tools-panel-empty") + } + } + + @Test + fun `preview returns parsed manifest with requested scopes without persisting`() { + mockManifest(validManifest()) + performAuthPost("${appsUrl()}/preview", registerBody()).andIsOk.andAssertThatJson { + node("appId").isEqualTo("test-app") + node("name").isEqualTo("Test App") + node("version").isEqualTo("0.1.0") + node("requestedScopes").isArray.containsExactlyInAnyOrder("translations.view", "keys.edit") + } + assertThat(appInstallService.findAll(testData.organization.id)).isEmpty() + } + + @Test + fun `refresh updates granted scopes from new manifest`() { + mockManifest(validManifest()) + performAuthPost(appsUrl(), registerBody()).andIsOk + val installId = appInstallService.findAll(testData.organization.id).single().id + + mockManifest(validManifestV2WithExtraScope()) + performAuthPost("${appsUrl()}/$installId/refresh", emptyMap()).andIsOk.andAssertThatJson { + node("scopes") + .isArray + .containsExactlyInAnyOrder("translations.view", "keys.edit", "screenshots.upload") + } + assertThat(appInstallService.find(installId)!!.grantedScopes) + .containsExactlyInAnyOrder(Scope.TRANSLATIONS_VIEW, Scope.KEYS_EDIT, Scope.SCREENSHOTS_UPLOAD) + } + + @Test + fun `rejects a manifest URL pointing at a private address`() { + performAuthPost( + appsUrl(), + mapOf("manifestUrl" to "http://127.0.0.1/manifest.json"), + ).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("url_not_valid") + } + } + + @Test + fun `rejects manifest with an unknown scope`() { + mockManifest(validManifest().replace("\"keys.edit\"", "\"not.a.real.scope\"")) + performAuthPost(appsUrl(), registerBody()).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("app_manifest_invalid") + } + } + + @Test + fun `register without scopes block stores no granted scopes`() { + mockManifest(manifestWithoutScopes()) + performAuthPost(appsUrl(), registerBody()).andIsOk.andAssertThatJson { + node("scopes").isArray.isEmpty() + } + val install = appInstallService.findAll(testData.organization.id).single() + assertThat(install.grantedScopes).isEmpty() + } + + @Test + fun `rejects duplicate app for the same organization`() { + mockManifest(validManifest()) + performAuthPost(appsUrl(), registerBody()).andIsOk + + performAuthPost(appsUrl(), registerBody()).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("app_already_installed") + } + } + + @Test + fun `rejects invalid manifest JSON`() { + mockManifest("not valid json") + performAuthPost(appsUrl(), registerBody()).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("app_manifest_invalid") + } + } + + @Test + fun `rejects unreachable manifest URL`() { + doThrow(ResourceAccessException("connection refused")) + .whenever(restTemplate) + .getForObject(anyString(), eq(String::class.java)) + + performAuthPost(appsUrl(), registerBody()).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("app_manifest_fetch_failed") + } + } + + @Test + fun `lists registered apps for the organization`() { + mockManifest(validManifest()) + performAuthPost(appsUrl(), registerBody()).andIsOk + + performAuthGet(appsUrl()).andIsOk.andAssertThatJson { + node("_embedded.appInstalls").isArray.hasSize(1) + node("_embedded.appInstalls[0].appId").isEqualTo("test-app") + } + } + + @Test + fun `refresh updates the stored manifest`() { + mockManifest(validManifest()) + performAuthPost(appsUrl(), registerBody()).andIsOk + val installId = appInstallService.findAll(testData.organization.id).single().id + + mockManifest(validManifestV2()) + performAuthPost("${appsUrl()}/$installId/refresh", emptyMap()).andIsOk.andAssertThatJson { + node("version").isEqualTo("0.2.0") + node("modules.project-dashboard-page[0].title").isEqualTo("Home v2") + } + } + + @Test + fun `refresh rejects manifest whose app id changed`() { + mockManifest(validManifest()) + performAuthPost(appsUrl(), registerBody()).andIsOk + val installId = appInstallService.findAll(testData.organization.id).single().id + + mockManifest(validManifest().replace("\"test-app\"", "\"different-app\"")) + performAuthPost("${appsUrl()}/$installId/refresh", emptyMap()).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("app_manifest_invalid") + } + } + + @Test + fun `updates the manifest URL and refetches from the new location`() { + mockManifest(validManifest()) + performAuthPost(appsUrl(), registerBody()).andIsOk + val installId = appInstallService.findAll(testData.organization.id).single().id + + mockManifest(validManifestV2()) + performAuthPatch( + "${appsUrl()}/$installId/manifest-url", + mapOf("manifestUrl" to "https://example.com/new-location/manifest.json"), + ).andIsOk.andAssertThatJson { + node("version").isEqualTo("0.2.0") + node("modules.project-dashboard-page[0].title").isEqualTo("Home v2") + } + + val install = appInstallService.find(installId)!! + assertThat(install.manifestUrl).isEqualTo("https://example.com/new-location/manifest.json") + } + + @Test + fun `manifest URL update rejects manifest whose app id changed`() { + mockManifest(validManifest()) + performAuthPost(appsUrl(), registerBody()).andIsOk + val installId = appInstallService.findAll(testData.organization.id).single().id + + mockManifest(validManifest().replace("\"test-app\"", "\"different-app\"")) + performAuthPatch( + "${appsUrl()}/$installId/manifest-url", + mapOf("manifestUrl" to "https://example.com/new-location/manifest.json"), + ).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("app_manifest_invalid") + } + } + + @Test + fun `removes a registered app`() { + mockManifest(validManifest()) + performAuthPost(appsUrl(), registerBody()).andIsOk + val installId = appInstallService.findAll(testData.organization.id).single().id + + performAuthDelete("${appsUrl()}/$installId").andIsOk + assertThat(appInstallService.findAll(testData.organization.id)).isEmpty() + } + + private fun appsUrl() = "/v2/organizations/${testData.organization.id}/apps" + + private fun registerBody() = mapOf("manifestUrl" to "https://example.com/manifest.json") + + private fun performAuthPatch( + url: String, + body: Any, + ): ResultActions { + loginAsAdminIfNotLogged() + return perform( + AuthorizedRequestFactory + .addToken(patch(url)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)), + ) + } + + private fun mockManifest(json: String) { + doReturn(json).whenever(restTemplate).getForObject(anyString(), eq(String::class.java)) + } + + private fun validManifest(): String = + """ + { + "id": "test-app", + "name": "Test App", + "version": "0.1.0", + "baseUrl": "https://app.example.com", + "scopes": ["translations.view", "keys.edit"], + "modules": { + "project-dashboard-page": [ + {"key": "home", "title": "Home", "icon": "🏠", "entry": "/"} + ] + } + } + """.trimIndent() + + private fun validManifestV2(): String = + """ + { + "id": "test-app", + "name": "Test App", + "version": "0.2.0", + "baseUrl": "https://app.example.com", + "scopes": ["translations.view", "keys.edit"], + "modules": { + "project-dashboard-page": [ + {"key": "home", "title": "Home v2", "icon": "🏠", "entry": "/"} + ] + } + } + """.trimIndent() + + private fun validManifestV2WithExtraScope(): String = + """ + { + "id": "test-app", + "name": "Test App", + "version": "0.2.0", + "baseUrl": "https://app.example.com", + "scopes": ["translations.view", "keys.edit", "screenshots.upload"], + "modules": { + "project-dashboard-page": [ + {"key": "home", "title": "Home v2", "icon": "🏠", "entry": "/"} + ] + } + } + """.trimIndent() + + private fun manifestWithToolsPanel(): String = + """ + { + "id": "test-app", + "name": "Test App", + "version": "0.1.0", + "baseUrl": "https://app.example.com", + "scopes": ["translations.view", "keys.edit"], + "modules": { + "project-dashboard-page": [ + {"key": "home", "title": "Home", "icon": "🏠", "entry": "/"} + ], + "translation-tools-panel": [ + {"key": "activity", "title": "Activity", "icon": "📈", "entry": "/tools-panel"} + ], + "translation-tools-panel-empty": [ + {"key": "languages", "title": "Languages", "icon": "🌐", "entry": "/tools-panel-empty"} + ] + } + } + """.trimIndent() + + private fun manifestWithActions(): String = + """ + { + "id": "test-app", + "name": "Test App", + "version": "0.1.0", + "baseUrl": "https://app.example.com", + "decoratorsUrl": "https://app.example.com/decorators", + "scopes": ["translations.view", "keys.edit"], + "modules": { + "project-dashboard-page": [ + {"key": "home", "title": "Home", "icon": "🏠", "entry": "/"} + ], + "translation-tools-panel": [ + {"key": "activity", "title": "Activity", "icon": "📈", "entry": "/tools-panel"} + ], + "key-edit-tab": [ + {"key": "audit", "title": "Audit", "icon": "🛡️", "entry": "/key-edit-tab/audit"} + ], + "key-action": [ + { + "key": "view-source", + "type": "link", + "icon": "🔗", + "tooltip": "View source", + "urlTemplate": "https://example.com/{keyName}" + }, + { + "key": "open-audit", + "type": "tab", + "icon": "🛡️", + "tooltip": "Audit info", + "dynamic": true, + "tabKey": "audit", + "visibility": "on-hover" + } + ], + "translation-action": [ + { + "key": "show-activity", + "type": "panel", + "icon": "📈", + "tooltip": "Activity", + "dynamic": true, + "panelKey": "activity", + "visibility": "always" + } + ] + } + } + """.trimIndent() + + private fun manifestWithWebhookEvents(vararg events: String): String { + val list = events.joinToString(",") { "\"$it\"" } + return """ + { + "id": "test-app", + "name": "Test App", + "version": "0.1.0", + "baseUrl": "https://app.example.com", + "scopes": ["translations.view", "keys.edit"], + "webhooks": { + "events": [$list], + "url": "/webhook" + }, + "modules": { + "project-dashboard-page": [ + {"key": "home", "title": "Home", "icon": "🏠", "entry": "/"} + ] + } + } + """.trimIndent() + } + + private fun manifestWithModalAndTriggers(): String = + """ + { + "id": "test-app", + "name": "Test App", + "version": "0.1.0", + "baseUrl": "https://app.example.com", + "scopes": ["translations.view", "keys.edit"], + "modules": { + "modal": [ + { + "key": "explain", + "title": "Explain this key", + "icon": "💡", + "entry": "/modal/explain", + "width": 640, + "height": 400 + } + ], + "bulk-action": [ + {"key": "bulk-explain", "title": "Explain selection", "icon": "🎯", "type": "modal", "modalKey": "explain"} + ], + "translations-toolbar-action": [ + {"key": "toolbar-explain", "title": "Explain view", "icon": "⬇️", "type": "modal", "modalKey": "explain"} + ], + "project-menu-action": [ + {"key": "menu-explain", "title": "Configure", "icon": "⚙️", "type": "modal", "modalKey": "explain"} + ], + "shortcut": [ + {"key": "shortcut-explain", "combination": "Mod+Shift+E", "type": "modal", "modalKey": "explain"} + ] + } + } + """.trimIndent() + + private fun manifestWithoutScopes(): String = + """ + { + "id": "test-app", + "name": "Test App", + "version": "0.1.0", + "baseUrl": "https://app.example.com", + "modules": { + "project-dashboard-page": [ + {"key": "home", "title": "Home", "icon": "🏠", "entry": "/"} + ] + } + } + """.trimIndent() +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/project/ProjectAppsControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/project/ProjectAppsControllerTest.kt new file mode 100644 index 00000000000..6ba7792521a --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/project/ProjectAppsControllerTest.kt @@ -0,0 +1,153 @@ +package io.tolgee.api.v2.controllers.project + +import io.tolgee.development.testDataBuilder.data.AppsTestData +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsNotFound +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.node +import io.tolgee.service.apps.AppInstallService +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito.anyString +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.web.client.RestTemplate + +class ProjectAppsControllerTest : AuthorizedControllerTest() { + @Autowired + lateinit var appInstallService: AppInstallService + + @MockitoBean + @Autowired + lateinit var restTemplate: RestTemplate + + lateinit var testData: AppsTestData + + @BeforeEach + fun setup() { + testData = AppsTestData() + testDataService.saveTestData(testData.root) + userAccount = testData.user + registerApp() + } + + @Test + fun `lists apps with enablement initially false`() { + performAuthGet(projectAppsUrl()).andIsOk.andAssertThatJson { + node("_embedded.projectApps").isArray.hasSize(1) + node("_embedded.projectApps[0].appId").isEqualTo("test-app") + node("_embedded.projectApps[0].enabled").isEqualTo(false) + } + } + + @Test + fun `exposes translation-tools-panel modules to the project listing`() { + performAuthGet(projectAppsUrl()).andIsOk.andAssertThatJson { + node("_embedded.projectApps[0].modules.project-dashboard-page[0].key").isEqualTo("home") + node("_embedded.projectApps[0].modules.translation-tools-panel[0].key").isEqualTo("activity") + node("_embedded.projectApps[0].modules.translation-tools-panel[0].title").isEqualTo("Activity") + node("_embedded.projectApps[0].modules.translation-tools-panel[0].entry").isEqualTo("/tools-panel") + } + } + + @Test + fun `enable flips the state to true`() { + val installId = installId() + performAuthPut("${projectAppsUrl()}/$installId", null).andIsOk.andAssertThatJson { + node("appId").isEqualTo("test-app") + node("enabled").isEqualTo(true) + } + performAuthGet(projectAppsUrl()).andIsOk.andAssertThatJson { + node("_embedded.projectApps[0].enabled").isEqualTo(true) + } + } + + @Test + fun `enable is idempotent`() { + val installId = installId() + performAuthPut("${projectAppsUrl()}/$installId", null).andIsOk + performAuthPut("${projectAppsUrl()}/$installId", null).andIsOk + performAuthGet(projectAppsUrl()).andIsOk.andAssertThatJson { + node("_embedded.projectApps").isArray.hasSize(1) + node("_embedded.projectApps[0].enabled").isEqualTo(true) + } + } + + @Test + fun `enable rejects unknown install id`() { + performAuthPut("${projectAppsUrl()}/999999", null).andIsNotFound.andAssertThatJson { + node("code").isEqualTo("app_install_not_found") + } + } + + @Test + fun `disable removes enablement`() { + val installId = installId() + performAuthPut("${projectAppsUrl()}/$installId", null).andIsOk + performAuthDelete("${projectAppsUrl()}/$installId").andIsOk + performAuthGet(projectAppsUrl()).andIsOk.andAssertThatJson { + node("_embedded.projectApps[0].enabled").isEqualTo(false) + } + } + + @Test + fun `disable is idempotent when not enabled`() { + val installId = installId() + performAuthDelete("${projectAppsUrl()}/$installId").andIsOk + performAuthGet(projectAppsUrl()).andIsOk.andAssertThatJson { + node("_embedded.projectApps[0].enabled").isEqualTo(false) + } + } + + @Test + fun `removing the org-level install also clears its project enablements`() { + val installId = installId() + performAuthPut("${projectAppsUrl()}/$installId", null).andIsOk + + performAuthDelete("/v2/organizations/${testData.organization.id}/apps/$installId").andIsOk + + assertThat(appInstallService.findAll(testData.organization.id)).isEmpty() + performAuthGet(projectAppsUrl()).andIsOk.andAssertThatJson { + node("_embedded.projectApps").isAbsent() + } + } + + private fun projectAppsUrl() = "/v2/projects/${testData.project.id}/apps" + + private fun installId() = appInstallService.findAll(testData.organization.id).single().id + + private fun registerApp() { + mockManifest(validManifest()) + performAuthPost( + "/v2/organizations/${testData.organization.id}/apps", + mapOf("manifestUrl" to "https://example.com/manifest.json"), + ).andIsOk + } + + private fun mockManifest(json: String) { + doReturn(json).whenever(restTemplate).getForObject(anyString(), eq(String::class.java)) + } + + private fun validManifest(): String = + """ + { + "id": "test-app", + "name": "Test App", + "version": "0.1.0", + "baseUrl": "https://app.example.com", + "modules": { + "project-dashboard-page": [ + {"key": "home", "title": "Home", "icon": "🏠", "entry": "/"} + ], + "translation-tools-panel": [ + {"key": "activity", "title": "Activity", "icon": "📈", "entry": "/tools-panel"} + ] + } + } + """.trimIndent() +} diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/AppsProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/AppsProperties.kt new file mode 100644 index 00000000000..b8fe53f29dd --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/AppsProperties.kt @@ -0,0 +1,22 @@ +package io.tolgee.configuration.tolgee + +import io.tolgee.configuration.annotations.DocProperty +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "tolgee.apps") +@DocProperty(description = "Configuration for Tolgee Apps (plugins).", displayName = "Apps") +class AppsProperties { + @DocProperty( + description = + "When enabled, Tolgee App manifest URLs may target otherwise-blocked address ranges — " + + "loopback, private/site-local, link-local, IPv6 unique-local, multicast and " + + "wildcard/any-local addresses. Useful for local development and integration testing.\n" + + "\n" + + ":::danger\n" + + "This removes SSRF protection for app manifest fetches. Keep it **disabled** on production " + + "and multi-tenant servers — anyone able to register an app could otherwise reach internal " + + "services.\n" + + ":::\n\n", + ) + var allowLocalAddresses: Boolean = false +} diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt index c3d1be474d7..184e94b6d9d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt @@ -122,6 +122,7 @@ class TolgeeProperties( var telemetry: TelemetryProperties = TelemetryProperties(), var contentDelivery: ContentDeliveryProperties = ContentDeliveryProperties(), var webhook: WebhookProperties = WebhookProperties(), + var apps: AppsProperties = AppsProperties(), var slack: SlackProperties = SlackProperties(), @DocProperty(hidden = true) var plausible: PlausibleProperties = PlausibleProperties(), diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt index 9099e00c58c..f8ac0a28163 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -352,6 +352,13 @@ enum class Message { PLAN_MIGRATION_NOT_FOUND, PLAN_HAS_MIGRATIONS, SOURCE_AND_TARGET_PLAN_MUST_BE_DIFFERENT, + APP_MANIFEST_FETCH_FAILED, + APP_MANIFEST_INVALID, + APP_ALREADY_INSTALLED, + APP_INSTALL_NOT_FOUND, + APP_TOKEN_NOT_ALLOWED_FOR_ENDPOINT, + INVALID_APP_CREDENTIALS, + APP_ACTING_AS_USER_NOT_PROJECT_MEMBER, ; val code: String diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/AppsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/AppsTestData.kt new file mode 100644 index 00000000000..da2ddbb9e0c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/AppsTestData.kt @@ -0,0 +1,38 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.ProjectBuilder +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.development.testDataBuilder.builders.UserAccountBuilder +import io.tolgee.model.Organization +import io.tolgee.model.Project +import io.tolgee.model.UserAccount + +class AppsTestData { + lateinit var user: UserAccount + + /** A user with no membership in [organization] / [project] — used to assert acting-as bounds. */ + lateinit var outsider: UserAccount + lateinit var organization: Organization + lateinit var project: Project + lateinit var userAccountBuilder: UserAccountBuilder + lateinit var projectBuilder: ProjectBuilder + + val root: TestDataBuilder = + TestDataBuilder().apply { + userAccountBuilder = + addUserAccount { + username = "admin" + } + user = userAccountBuilder.self + organization = userAccountBuilder.defaultOrganizationBuilder.self + + outsider = addUserAccount { username = "outsider" }.self + + projectBuilder = + addProject { + name = "test_project" + organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self + } + project = projectBuilder.self + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryAppAccessTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryAppAccessTestData.kt new file mode 100644 index 00000000000..7e1b3297893 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryAppAccessTestData.kt @@ -0,0 +1,74 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.model.Organization +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.model.glossary.Glossary +import io.tolgee.model.glossary.GlossaryTerm + +/** + * Two organizations, each owning a glossary, used to exercise app org-level glossary access: + * an app installed in [organization] (org A) may touch [glossary] but never [otherGlossary] + * (org B). [user] is only an org MEMBER (not a maintainer), so app-token writes that succeed + * prove the install grant — not the user's role — is what authorizes. + */ +class GlossaryAppAccessTestData { + lateinit var user: UserAccount + lateinit var userMaintainer: UserAccount + lateinit var organization: Organization + lateinit var project: Project + lateinit var glossary: Glossary + lateinit var term: GlossaryTerm + lateinit var otherOrganization: Organization + lateinit var otherGlossary: Glossary + + val root: TestDataBuilder = + TestDataBuilder().apply { + val appUser = addUserAccount { username = "app-user" }.self + user = appUser + val maintainer = addUserAccount { username = "glossary-maintainer" }.self + userMaintainer = maintainer + + val orgABuilder = addOrganization { name = "App Org" } + organization = orgABuilder.self + project = addProject(organization) { name = "App Project" }.self + + orgABuilder.build { + addRole { + this.user = appUser + type = OrganizationRoleType.MEMBER + } + addRole { + this.user = maintainer + type = OrganizationRoleType.MAINTAINER + } + glossary = + addGlossary { + name = "Test Glossary" + baseLanguageTag = "en" + }.build { + assignProject(project) + term = + addTerm { description = "A term" } + .build { + addTranslation { + languageTag = "en" + text = "Term" + } + }.self + }.self + } + + val orgBBuilder = addOrganization { name = "Other Org" } + otherOrganization = orgBBuilder.self + orgBBuilder.build { + otherGlossary = + addGlossary { + name = "Other Glossary" + baseLanguageTag = "en" + }.self + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/apps/AppManifest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/apps/AppManifest.kt new file mode 100644 index 00000000000..74b39fb148a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/apps/AppManifest.kt @@ -0,0 +1,156 @@ +package io.tolgee.dtos.apps + +import com.fasterxml.jackson.annotation.JsonProperty + +data class AppManifest( + val id: String, + val name: String, + val version: String, + val baseUrl: String, + val modules: AppManifestModules, + val scopes: List? = null, + val webhooks: AppManifestWebhooks? = null, + val decoratorsUrl: String? = null, +) + +data class AppManifestWebhooks( + val events: List, + val url: String, +) + +data class AppManifestModules( + @JsonProperty("project-dashboard-page") + val projectDashboardPage: List? = null, + @JsonProperty("translation-tools-panel") + val translationToolsPanel: List? = null, + @JsonProperty("translation-tools-panel-empty") + val translationToolsPanelEmpty: List? = null, + @JsonProperty("key-edit-tab") + val keyEditTab: List? = null, + @JsonProperty("key-action") + val keyAction: List? = null, + @JsonProperty("translation-action") + val translationAction: List? = null, + @JsonProperty("modal") + val modal: List? = null, + @JsonProperty("bulk-action") + val bulkAction: List? = null, + @JsonProperty("translations-toolbar-action") + val translationsToolbarAction: List? = null, + @JsonProperty("project-menu-action") + val projectMenuAction: List? = null, + @JsonProperty("shortcut") + val shortcut: List? = null, +) + +data class ProjectDashboardPageModule( + val key: String, + val title: String, + val icon: String, + val entry: String, +) + +data class TranslationToolsPanelModule( + val key: String, + val title: String, + val icon: String, + val entry: String, +) + +data class KeyEditTabModule( + val key: String, + val title: String, + val icon: String, + val entry: String, +) + +data class KeyActionModule( + val key: String, + val type: AppActionType, + val icon: String, + val tooltip: String, + val dynamic: Boolean = false, + val urlTemplate: String? = null, + val tabKey: String? = null, + val modalKey: String? = null, + val visibility: AppActionVisibility? = null, +) + +data class TranslationActionModule( + val key: String, + val type: AppActionType, + val icon: String, + val tooltip: String, + val dynamic: Boolean = false, + val urlTemplate: String? = null, + val panelKey: String? = null, + val modalKey: String? = null, + val visibility: AppActionVisibility? = null, +) + +data class ModalModule( + val key: String, + val title: String, + val entry: String, + val icon: String? = null, + val width: Int? = null, + val height: Int? = null, +) + +data class BulkActionModule( + val key: String, + val title: String, + val type: AppActionType, + val icon: String? = null, + val urlTemplate: String? = null, + val modalKey: String? = null, +) + +data class TranslationsToolbarActionModule( + val key: String, + val title: String, + val type: AppActionType, + val icon: String? = null, + val urlTemplate: String? = null, + val modalKey: String? = null, +) + +data class ProjectMenuActionModule( + val key: String, + val title: String, + val type: AppActionType, + val icon: String? = null, + val urlTemplate: String? = null, + val modalKey: String? = null, +) + +data class ShortcutModule( + val key: String, + val combination: String, + val type: AppActionType, + val title: String? = null, + val urlTemplate: String? = null, + val modalKey: String? = null, +) + +enum class AppActionType { + @JsonProperty("link") + LINK, + + @JsonProperty("panel") + PANEL, + + @JsonProperty("tab") + TAB, + + @JsonProperty("modal") + MODAL, +} + +enum class AppActionVisibility { + @JsonProperty("always") + ALWAYS, + + @JsonProperty("on-hover") + ON_HOVER, +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/RegisterAppRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/RegisterAppRequest.kt new file mode 100644 index 00000000000..a9f693c3fec --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/RegisterAppRequest.kt @@ -0,0 +1,5 @@ +package io.tolgee.dtos.request + +class RegisterAppRequest( + val manifestUrl: String, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/apps/AppEnabledForProject.kt b/backend/data/src/main/kotlin/io/tolgee/model/apps/AppEnabledForProject.kt new file mode 100644 index 00000000000..01e2ffd1089 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/apps/AppEnabledForProject.kt @@ -0,0 +1,36 @@ +package io.tolgee.model.apps + +import io.tolgee.model.Project +import io.tolgee.model.StandardAuditModel +import io.tolgee.model.UserAccount +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.Index +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "app_enabled_for_project", + uniqueConstraints = [ + UniqueConstraint( + name = "app_enabled_for_project_unique", + columnNames = ["app_install_id", "project_id"], + ), + ], + indexes = [ + Index(columnList = "app_install_id"), + Index(columnList = "project_id"), + ], +) +class AppEnabledForProject : StandardAuditModel() { + @ManyToOne(fetch = FetchType.LAZY) + lateinit var appInstall: AppInstall + + @ManyToOne(fetch = FetchType.LAZY) + lateinit var project: Project + + @ManyToOne(fetch = FetchType.LAZY) + lateinit var author: UserAccount +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/apps/AppInstall.kt b/backend/data/src/main/kotlin/io/tolgee/model/apps/AppInstall.kt new file mode 100644 index 00000000000..e328e146469 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/apps/AppInstall.kt @@ -0,0 +1,85 @@ +package io.tolgee.model.apps + +import io.tolgee.model.Organization +import io.tolgee.model.StandardAuditModel +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.Scope +import jakarta.persistence.CollectionTable +import jakarta.persistence.Column +import jakarta.persistence.ElementCollection +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.Index +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "app_install", + uniqueConstraints = [ + UniqueConstraint( + name = "app_install_organization_id_app_id_unique", + columnNames = ["organization_id", "app_id"], + ), + ], + indexes = [ + Index(columnList = "organization_id"), + Index(columnList = "author_id"), + ], +) +class AppInstall : StandardAuditModel() { + @ManyToOne(fetch = FetchType.LAZY) + lateinit var organization: Organization + + @ManyToOne(fetch = FetchType.LAZY) + lateinit var author: UserAccount + + lateinit var manifestUrl: String + + lateinit var appId: String + + lateinit var name: String + + lateinit var version: String + + lateinit var baseUrl: String + + @Column(columnDefinition = "TEXT") + lateinit var manifestJson: String + + @Enumerated(EnumType.STRING) + @ElementCollection(targetClass = Scope::class, fetch = FetchType.EAGER) + @CollectionTable( + name = "app_install_granted_scope", + joinColumns = [JoinColumn(name = "app_install_id")], + ) + @Column(name = "scope") + var grantedScopes: MutableSet = mutableSetOf() + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "app_install_webhook_subscription", + joinColumns = [JoinColumn(name = "app_install_id")], + ) + @Column(name = "event") + var webhookSubscriptions: MutableSet = mutableSetOf() + + @Column(name = "webhook_url", length = 2048) + var webhookUrl: String? = null + + @Column(name = "client_id", length = 64, unique = true) + var clientId: String? = null + + @Column(name = "client_secret_hash", length = 128, unique = true) + var clientSecretHash: String? = null + + @Column(name = "client_secret_prefix", length = 16) + var clientSecretPrefix: String? = null + + @Column(name = "webhook_secret", length = 128) + var webhookSecret: String? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt index 063b4ca09e4..054e2a88292 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt @@ -45,6 +45,12 @@ enum class Scope( ALL_VIEW("all.view"), BRANCH_MANAGEMENT("branch.management"), BRANCH_PROTECTED_MODIFY("branch.protected-modify"), + + // Organization-level scopes — grantable to apps via their manifest, checked by the + // org authorization interceptor. NOT part of the project hierarchy below (a project + // ADMIN does not get them, and project-permission checks never see them). + GLOSSARY_VIEW("glossary.view"), + GLOSSARY_EDIT("glossary.edit"), ; fun expand() = Scope.expand(this) @@ -167,6 +173,24 @@ enum class Scope( ), ) + /** + * Organization-level scope trees, kept separate from the project [hierarchy] so that + * project ADMIN doesn't implicitly grant them and project-permission checks never see + * them. Each root is its own tree so [expand] still resolves it (a scope absent from + * every tree would expand to nothing). Apps request these in their manifest and the + * org authorization interceptor checks them. + */ + val organizationHierarchies = + listOf( + HierarchyItem(GLOSSARY_EDIT, listOf(HierarchyItem(GLOSSARY_VIEW))), + ) + + val organizationLevelScopes: Set by lazy { + organizationHierarchies.flatMap { expand(it.scope).toList() }.toSet() + } + + fun isOrganizationLevel(scope: Scope): Boolean = organizationLevelScopes.contains(scope) + private fun expand(item: HierarchyItem): MutableSet { val descendants = item.requires @@ -191,7 +215,8 @@ enum class Scope( } private fun getScopeHierarchyItems(scope: Scope): Array { - return getScopeHierarchyItems(root = hierarchy, scope).toTypedArray() + val roots = listOf(hierarchy) + organizationHierarchies + return roots.flatMap { getScopeHierarchyItems(root = it, scope) }.toTypedArray() } fun expand(scope: Scope): Array { diff --git a/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt b/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt index 14babfabf16..dcd8ebcf3be 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/webhook/WebhookConfig.kt @@ -6,10 +6,12 @@ import io.tolgee.activity.annotation.ActivityLoggedEntity import io.tolgee.activity.annotation.ActivityLoggedProp import io.tolgee.model.Project import io.tolgee.model.StandardAuditModel +import io.tolgee.model.apps.AppInstall import io.tolgee.model.automations.AutomationAction import jakarta.persistence.Entity import jakarta.persistence.FetchType import jakarta.persistence.Index +import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.OneToMany import jakarta.persistence.Table @@ -21,6 +23,7 @@ import java.util.Date @Table( indexes = [ Index(columnList = "project_id"), + Index(columnList = "app_install_id"), ], ) class WebhookConfig( @@ -52,4 +55,8 @@ class WebhookConfig( @ActivityIgnoredProp var autoDisabled: Boolean = false + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "app_install_id") + var appInstall: AppInstall? = null } diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/WebhookConfigRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/WebhookConfigRepository.kt index 922bf3bef10..6e305cbcb2a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/WebhookConfigRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/WebhookConfigRepository.kt @@ -1,5 +1,6 @@ package io.tolgee.repository +import io.tolgee.model.apps.AppInstall import io.tolgee.model.webhook.WebhookConfig import org.springframework.context.annotation.Lazy import org.springframework.data.domain.Page @@ -23,6 +24,17 @@ interface WebhookConfigRepository : JpaRepository { projectId: Long, ): WebhookConfig? + @Query( + """ + from WebhookConfig wc + where wc.project.id = :projectId and wc.appInstall is null + """, + ) + fun findByProjectIdAndAppInstallIsNull( + projectId: Long, + pageable: Pageable, + ): Page + @Query( """ from WebhookConfig wc @@ -33,4 +45,25 @@ interface WebhookConfigRepository : JpaRepository { projectId: Long, pageable: Pageable, ): Page + + @Query( + """ + from WebhookConfig wc + left join fetch wc.automationActions aa + where wc.appInstall = :appInstall and wc.project.id = :projectId + """, + ) + fun findByAppInstallAndProjectId( + appInstall: AppInstall, + projectId: Long, + ): WebhookConfig? + + @Query( + """ + from WebhookConfig wc + left join fetch wc.automationActions aa + where wc.appInstall = :appInstall + """, + ) + fun findAllByAppInstall(appInstall: AppInstall): List } diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/apps/AppEnabledForProjectRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/apps/AppEnabledForProjectRepository.kt new file mode 100644 index 00000000000..812a6a44f67 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/repository/apps/AppEnabledForProjectRepository.kt @@ -0,0 +1,21 @@ +package io.tolgee.repository.apps + +import io.tolgee.model.apps.AppEnabledForProject +import org.springframework.context.annotation.Lazy +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +@Lazy +interface AppEnabledForProjectRepository : JpaRepository { + fun findByProjectIdAndAppInstallId( + projectId: Long, + appInstallId: Long, + ): AppEnabledForProject? + + fun findAllByProjectId(projectId: Long): List + + fun findAllByAppInstallId(appInstallId: Long): List + + fun deleteByAppInstallId(appInstallId: Long) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/apps/AppInstallRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/apps/AppInstallRepository.kt new file mode 100644 index 00000000000..e05f8381e31 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/repository/apps/AppInstallRepository.kt @@ -0,0 +1,26 @@ +package io.tolgee.repository.apps + +import io.tolgee.model.apps.AppInstall +import org.springframework.context.annotation.Lazy +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +@Lazy +interface AppInstallRepository : JpaRepository { + fun findAllByOrganizationId(organizationId: Long): List + + fun findByOrganizationIdAndId( + organizationId: Long, + id: Long, + ): AppInstall? + + fun findByOrganizationIdAndAppId( + organizationId: Long, + appId: String, + ): AppInstall? + + fun findByClientSecretHash(clientSecretHash: String): AppInstall? + + fun findByClientId(clientId: String): AppInstall? +} diff --git a/backend/data/src/main/kotlin/io/tolgee/security/authentication/AppAuthentication.kt b/backend/data/src/main/kotlin/io/tolgee/security/authentication/AppAuthentication.kt new file mode 100644 index 00000000000..6757cc2b5f9 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/security/authentication/AppAuthentication.kt @@ -0,0 +1,29 @@ +package io.tolgee.security.authentication + +import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.model.apps.AppInstall + +/** + * Authentication populated when a request bears app credentials, either: + * - a user-context JWT (audience `tg.app`) — `projectId` is bound to the JWT and `userAccount` + * is the iframe user, OR + * - an `X-API-Key: tgapps_…` / `Authorization: Basic …` app secret — `projectId` is null + * (downstream code resolves it from the URL and verifies enablement per request), + * `userAccount` is the install's author for audit purposes, and `actingAsUserAccount` is + * optionally set from the `X-Tolgee-Act-As-User-Id` header. + */ +class AppAuthentication( + credentials: Any?, + userAccount: UserAccountDto, + val appInstall: AppInstall, + val projectId: Long?, + val isInstallContext: Boolean, + actingAsUserAccount: UserAccountDto? = null, +) : TolgeeAuthentication( + credentials = credentials, + deviceId = null, + userAccount = userAccount, + actingAsUserAccount = actingAsUserAccount, + isReadOnly = false, + isSuperToken = false, + ) diff --git a/backend/data/src/main/kotlin/io/tolgee/security/authentication/AppTokenService.kt b/backend/data/src/main/kotlin/io/tolgee/security/authentication/AppTokenService.kt new file mode 100644 index 00000000000..d9ac2dccecc --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/security/authentication/AppTokenService.kt @@ -0,0 +1,122 @@ +package io.tolgee.security.authentication + +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.JwtParser +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.MalformedJwtException +import io.jsonwebtoken.UnsupportedJwtException +import io.jsonwebtoken.security.SignatureException +import io.tolgee.component.CurrentDateProvider +import io.tolgee.configuration.tolgee.AuthenticationProperties +import io.tolgee.constants.Message +import io.tolgee.exceptions.AuthenticationException +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Service +import java.security.Key +import java.util.Date + +/** + * Mints and validates user-context JWTs for Tolgee Apps. + * + * The token is a thin pointer: it carries identity claims (installId, projectId, userId) + * but no permissions. Permissions are resolved from the database on every request via + * the install's `grantedScopes` and the user's project permissions, so revocation works + * the moment any of those change. + * + * Reuses [JwtService]'s signing key for the PoC. A separate signing key is a planned + * follow-up (see Tolgee Apps architecture notes). + */ +@Service +class AppTokenService( + @Qualifier("jwt_signing_key") + private val signingKey: Key, + private val authenticationProperties: AuthenticationProperties, + private val currentDateProvider: CurrentDateProvider, +) { + private val jwtParser: JwtParser = + Jwts + .parserBuilder() + .setClock { currentDateProvider.date } + .setSigningKey(signingKey) + .build() + + /** + * Mints a user-context app token. The token authorizes API calls made on behalf of + * the given user, within the given project, scoped to the given install. + */ + fun mintUserContextToken( + installId: Long, + userId: Long, + projectId: Long, + ): String { + val now = currentDateProvider.date + val expiration = Date(now.time + authenticationProperties.jwtExpiration) + + return Jwts + .builder() + .signWith(signingKey) + .setIssuedAt(now) + .setAudience(JWT_APP_TOKEN_AUDIENCE) + .setSubject(userId.toString()) + .claim(JWT_APP_TOKEN_INSTALL_ID_CLAIM, installId) + .claim(JWT_APP_TOKEN_PROJECT_ID_CLAIM, projectId) + .setExpiration(expiration) + .compact() + } + + /** + * Parses and verifies the token. Checks signature, audience, and expiry only — the + * existence / non-revocation of the install, user, project and per-project enablement + * is validated by the authentication filter against current DB state on every request. + */ + fun validateToken(token: String): AppTokenClaims { + val jws = + try { + jwtParser.parseClaimsJws(token) + } catch (ex: Exception) { + when (ex) { + is SignatureException, + is MalformedJwtException, + is UnsupportedJwtException, + is IllegalArgumentException, + -> throw AuthenticationException(Message.INVALID_JWT_TOKEN) + is ExpiredJwtException -> throw AuthenticationException(Message.EXPIRED_JWT_TOKEN) + else -> throw ex + } + } + + if (jws.body.audience != JWT_APP_TOKEN_AUDIENCE) { + throw AuthenticationException(Message.INVALID_JWT_TOKEN) + } + + val userId = + jws.body.subject?.toLongOrNull() + ?: throw AuthenticationException(Message.INVALID_JWT_TOKEN) + val installId = + (jws.body[JWT_APP_TOKEN_INSTALL_ID_CLAIM] as? Number)?.toLong() + ?: throw AuthenticationException(Message.INVALID_JWT_TOKEN) + val projectId = + (jws.body[JWT_APP_TOKEN_PROJECT_ID_CLAIM] as? Number)?.toLong() + ?: throw AuthenticationException(Message.INVALID_JWT_TOKEN) + + return AppTokenClaims( + installId = installId, + userId = userId, + projectId = projectId, + issuedAt = jws.body.issuedAt, + ) + } + + companion object { + const val JWT_APP_TOKEN_AUDIENCE = "tg.app" + const val JWT_APP_TOKEN_INSTALL_ID_CLAIM = "tg.app.inst" + const val JWT_APP_TOKEN_PROJECT_ID_CLAIM = "tg.app.proj" + } +} + +data class AppTokenClaims( + val installId: Long, + val userId: Long, + val projectId: Long, + val issuedAt: Date, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFacade.kt b/backend/data/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFacade.kt index 52a18b97d67..4a8f6b8cc73 100644 --- a/backend/data/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFacade.kt +++ b/backend/data/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFacade.kt @@ -98,6 +98,14 @@ class AuthenticationFacade( val isProjectApiKeyAuth: Boolean get() = if (isAuthenticated) authentication.credentials is ApiKeyDto else false + val isAppAuth: Boolean + get() = SecurityContextHolder.getContext().authentication is AppAuthentication + + val appAuthentication: AppAuthentication + get() = + SecurityContextHolder.getContext().authentication as? AppAuthentication + ?: throw AuthenticationException(Message.UNAUTHENTICATED) + val isPersonalAccessTokenAuth: Boolean get() = if (isAuthenticated) authentication.credentials is PatDto else false diff --git a/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthentication.kt b/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthentication.kt index f3e8f0cc4f4..c8c813674a1 100644 --- a/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthentication.kt +++ b/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthentication.kt @@ -25,7 +25,7 @@ import org.springframework.security.core.Authentication import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority -class TolgeeAuthentication( +open class TolgeeAuthentication( private val credentials: Any?, /** * Device id - unique for each token. For activity logging. diff --git a/backend/data/src/main/kotlin/io/tolgee/service/apps/AppEnablementService.kt b/backend/data/src/main/kotlin/io/tolgee/service/apps/AppEnablementService.kt new file mode 100644 index 00000000000..779481fbeef --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/apps/AppEnablementService.kt @@ -0,0 +1,92 @@ +package io.tolgee.service.apps + +import io.tolgee.constants.Message +import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.apps.AppEnabledForProject +import io.tolgee.model.apps.AppInstall +import io.tolgee.repository.apps.AppEnabledForProjectRepository +import io.tolgee.repository.apps.AppInstallRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AppEnablementService( + private val appEnabledForProjectRepository: AppEnabledForProjectRepository, + private val appInstallRepository: AppInstallRepository, + private val appManagedAutomationService: AppManagedAutomationService, +) { + @Transactional + fun enable( + project: Project, + installId: Long, + author: UserAccount, + ): AppInstall { + val orgId = project.organizationOwner.id + val install = + appInstallRepository.findByOrganizationIdAndId(orgId, installId) + ?: throw NotFoundException(Message.APP_INSTALL_NOT_FOUND) + + appEnabledForProjectRepository + .findByProjectIdAndAppInstallId(project.id, install.id) + ?.let { return install } + + appEnabledForProjectRepository.save( + AppEnabledForProject().apply { + this.appInstall = install + this.project = project + this.author = author + }, + ) + appManagedAutomationService.onEnable(install, project) + return install + } + + @Transactional + fun disable( + projectId: Long, + appInstallId: Long, + ) { + val existing = + appEnabledForProjectRepository.findByProjectIdAndAppInstallId(projectId, appInstallId) ?: return + appManagedAutomationService.onDisable(existing.appInstall, existing.project) + appEnabledForProjectRepository.delete(existing) + } + + @Transactional(readOnly = true) + fun listAppsForProject(project: Project): List> { + val installs = appInstallRepository.findAllByOrganizationId(project.organizationOwner.id) + val enabledIds = + appEnabledForProjectRepository + .findAllByProjectId(project.id) + .map { it.appInstall.id } + .toSet() + return installs.map { it to (it.id in enabledIds) } + } + + @Transactional(readOnly = true) + fun listEnabledAppsForProject(project: Project): List { + return appEnabledForProjectRepository + .findAllByProjectId(project.id) + .map { it.appInstall } + } + + @Transactional(readOnly = true) + fun isEnabledForProject( + projectId: Long, + appInstallId: Long, + ): Boolean { + return appEnabledForProjectRepository.findByProjectIdAndAppInstallId(projectId, appInstallId) != null + } + + @Transactional + fun removeAllForAppInstall(appInstallId: Long) { + appEnabledForProjectRepository + .findAllByAppInstallId(appInstallId) + .forEach { enablement -> + appManagedAutomationService.onDisable(enablement.appInstall, enablement.project) + appEnabledForProjectRepository.delete(enablement) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/apps/AppInstallService.kt b/backend/data/src/main/kotlin/io/tolgee/service/apps/AppInstallService.kt new file mode 100644 index 00000000000..b6de45fe417 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/apps/AppInstallService.kt @@ -0,0 +1,205 @@ +package io.tolgee.service.apps + +import io.tolgee.component.KeyGenerator +import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.exceptions.BadRequestException +import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.Organization +import io.tolgee.model.UserAccount +import io.tolgee.model.apps.AppInstall +import io.tolgee.model.enums.Scope +import io.tolgee.repository.apps.AppInstallRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AppInstallService( + private val appInstallRepository: AppInstallRepository, + private val appManifestFetcher: AppManifestFetcher, + private val appEnablementService: AppEnablementService, + private val keyGenerator: KeyGenerator, + private val appManagedAutomationService: AppManagedAutomationService, +) { + data class RegisterResult( + val install: AppInstall, + val plaintextClientSecret: String, + ) + + data class AppCredentialResolution( + val install: AppInstall, + val authorPrincipal: UserAccountDto, + ) + + @Transactional + fun register( + organization: Organization, + manifestUrl: String, + author: UserAccount, + ): RegisterResult { + val fetched = appManifestFetcher.fetch(manifestUrl) + + if (appInstallRepository.findByOrganizationIdAndAppId(organization.id, fetched.manifest.id) != null) { + throw BadRequestException(Message.APP_ALREADY_INSTALLED) + } + + val plaintextClientId = CLIENT_ID_PREFIX + keyGenerator.generate(128) + val plaintextClientSecret = CLIENT_SECRET_PREFIX + keyGenerator.generate(256) + val plaintextWebhookSecret = WEBHOOK_SECRET_PREFIX + keyGenerator.generate(256) + + val install = + AppInstall().apply { + this.organization = organization + this.author = author + this.manifestUrl = manifestUrl + this.appId = fetched.manifest.id + this.name = fetched.manifest.name + this.version = fetched.manifest.version + this.baseUrl = fetched.manifest.baseUrl + this.manifestJson = fetched.rawJson + this.grantedScopes = fetched.scopes.toMutableSet() + this.webhookSubscriptions = fetched.webhookEvents.toMutableSet() + this.webhookUrl = fetched.resolvedWebhookUrl + this.clientId = plaintextClientId + this.clientSecretHash = keyGenerator.hash(plaintextClientSecret) + this.clientSecretPrefix = plaintextClientSecret.take(CLIENT_SECRET_PREFIX_DISPLAY_LENGTH) + this.webhookSecret = plaintextWebhookSecret + } + + val saved = appInstallRepository.save(install) + return RegisterResult(install = saved, plaintextClientSecret = plaintextClientSecret) + } + + @Transactional(readOnly = true) + fun findAll(organizationId: Long): List { + return appInstallRepository.findAllByOrganizationId(organizationId) + } + + @Transactional(readOnly = true) + fun find(installId: Long): AppInstall? { + return appInstallRepository.findById(installId).orElse(null) + } + + @Transactional(readOnly = true) + fun findByClientSecretHash(clientSecretHash: String): AppInstall? { + return appInstallRepository.findByClientSecretHash(clientSecretHash) + } + + @Transactional(readOnly = true) + fun findByClientId(clientId: String): AppInstall? { + return appInstallRepository.findByClientId(clientId) + } + + @Transactional(readOnly = true) + fun resolveByClientSecretHash(clientSecretHash: String): AppCredentialResolution? { + val install = appInstallRepository.findByClientSecretHash(clientSecretHash) ?: return null + return AppCredentialResolution(install, UserAccountDto.fromEntity(install.author)) + } + + @Transactional(readOnly = true) + fun resolveByClientId(clientId: String): AppCredentialResolution? { + val install = appInstallRepository.findByClientId(clientId) ?: return null + return AppCredentialResolution(install, UserAccountDto.fromEntity(install.author)) + } + + fun previewManifest(manifestUrl: String): AppManifestFetcher.FetchResult { + return appManifestFetcher.fetch(manifestUrl) + } + + @Transactional + fun refresh( + organizationId: Long, + installId: Long, + ): AppInstall { + val install = + appInstallRepository.findByOrganizationIdAndId(organizationId, installId) + ?: throw NotFoundException(Message.APP_INSTALL_NOT_FOUND) + + val fetched = appManifestFetcher.fetch(install.manifestUrl) + + if (fetched.manifest.id != install.appId) { + throw BadRequestException(Message.APP_MANIFEST_INVALID) + } + + applyManifestSnapshot(install, fetched, allowScopeWidening = true) + + val saved = appInstallRepository.save(install) + appManagedAutomationService.onInstallRefresh(saved) + return saved + } + + /** + * @param allowScopeWidening whether the re-fetched manifest may grant scopes beyond those + * already consented to. True only for owner-initiated calls (the org owner is the consent + * authority). Must be false for plugin-initiated calls (`tgapps_…` credentials): a plugin + * could otherwise self-grant arbitrary scopes by repointing at a manifest declaring more. + */ + @Transactional + fun updateManifestUrl( + organizationId: Long, + installId: Long, + manifestUrl: String, + allowScopeWidening: Boolean, + ): AppInstall { + val install = + appInstallRepository.findByOrganizationIdAndId(organizationId, installId) + ?: throw NotFoundException(Message.APP_INSTALL_NOT_FOUND) + + val fetched = appManifestFetcher.fetch(manifestUrl) + + if (fetched.manifest.id != install.appId) { + throw BadRequestException(Message.APP_MANIFEST_INVALID) + } + + install.manifestUrl = manifestUrl + applyManifestSnapshot(install, fetched, allowScopeWidening) + + val saved = appInstallRepository.save(install) + appManagedAutomationService.onInstallRefresh(saved) + return saved + } + + private fun applyManifestSnapshot( + install: AppInstall, + fetched: AppManifestFetcher.FetchResult, + allowScopeWidening: Boolean, + ) { + install.name = fetched.manifest.name + install.version = fetched.manifest.version + install.baseUrl = fetched.manifest.baseUrl + install.manifestJson = fetched.rawJson + install.grantedScopes = resolveGrantedScopes(install.grantedScopes, fetched.scopes, allowScopeWidening) + install.webhookSubscriptions = fetched.webhookEvents.toMutableSet() + install.webhookUrl = fetched.resolvedWebhookUrl + } + + private fun resolveGrantedScopes( + current: Set, + fetched: Set, + allowScopeWidening: Boolean, + ): MutableSet { + if (allowScopeWidening) return fetched.toMutableSet() + // Narrow-only: drop scopes the manifest no longer declares, but never add a scope the owner + // hasn't consented to. Newly declared scopes stay withheld until an owner re-consents. + return fetched.intersect(current).toMutableSet() + } + + @Transactional + fun remove( + organizationId: Long, + installId: Long, + ) { + val install = + appInstallRepository.findByOrganizationIdAndId(organizationId, installId) + ?: throw NotFoundException(Message.APP_INSTALL_NOT_FOUND) + appEnablementService.removeAllForAppInstall(installId) + appInstallRepository.delete(install) + } + + companion object { + const val CLIENT_ID_PREFIX = "tgapp_" + const val CLIENT_SECRET_PREFIX = "tgapps_" + const val WEBHOOK_SECRET_PREFIX = "tgappw_" + const val CLIENT_SECRET_PREFIX_DISPLAY_LENGTH = 10 + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/apps/AppManagedAutomationService.kt b/backend/data/src/main/kotlin/io/tolgee/service/apps/AppManagedAutomationService.kt new file mode 100644 index 00000000000..e6f5962188e --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/apps/AppManagedAutomationService.kt @@ -0,0 +1,116 @@ +package io.tolgee.service.apps + +import io.tolgee.activity.data.ActivityType +import io.tolgee.model.Project +import io.tolgee.model.apps.AppInstall +import io.tolgee.model.automations.Automation +import io.tolgee.model.automations.AutomationAction +import io.tolgee.model.automations.AutomationActionType +import io.tolgee.model.automations.AutomationTrigger +import io.tolgee.model.automations.AutomationTriggerType +import io.tolgee.model.webhook.WebhookConfig +import io.tolgee.repository.WebhookConfigRepository +import io.tolgee.repository.apps.AppEnabledForProjectRepository +import io.tolgee.service.automations.AutomationService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AppManagedAutomationService( + private val automationService: AutomationService, + private val webhookConfigRepository: WebhookConfigRepository, + private val appEnabledForProjectRepository: AppEnabledForProjectRepository, +) { + @Transactional + fun onEnable( + install: AppInstall, + project: Project, + ) { + if (install.webhookSubscriptions.isEmpty() || install.webhookUrl == null) { + return + } + val webhookConfig = + WebhookConfig(project = project).apply { + this.url = install.webhookUrl!! + this.webhookSecret = install.webhookSecret ?: "" + this.appInstall = install + this.enabled = true + } + webhookConfigRepository.save(webhookConfig) + + val automation = Automation(project) + install.webhookSubscriptions.forEach { event -> + automation.triggers.add(triggerFor(event, automation)) + } + automation.actions.add( + AutomationAction(automation).apply { + this.type = AutomationActionType.WEBHOOK + this.webhookConfig = webhookConfig + }, + ) + webhookConfig.automationActions.addAll(automation.actions) + automationService.save(automation) + } + + @Transactional + fun onDisable( + install: AppInstall, + project: Project, + ) { + val webhookConfig = + webhookConfigRepository.findByAppInstallAndProjectId(install, project.id) ?: return + automationService.deleteForWebhookConfig(webhookConfig) + webhookConfigRepository.delete(webhookConfig) + } + + @Transactional + fun onInstallRefresh(install: AppInstall) { + val configs = webhookConfigRepository.findAllByAppInstall(install) + if (install.webhookSubscriptions.isEmpty() || install.webhookUrl == null) { + configs.forEach { config -> + automationService.deleteForWebhookConfig(config) + webhookConfigRepository.delete(config) + } + return + } + configs.forEach { config -> + config.url = install.webhookUrl!! + webhookConfigRepository.save(config) + val automation = automationForWebhookConfig(config) ?: return@forEach + rebuildTriggers(automation, install.webhookSubscriptions) + automationService.save(automation) + } + val configuredProjectIds = configs.map { it.project.id }.toSet() + val enablements = appEnabledForProjectRepository.findAllByAppInstallId(install.id) + enablements + .filter { it.project.id !in configuredProjectIds } + .forEach { onEnable(install, it.project) } + } + + private fun automationForWebhookConfig(config: WebhookConfig): Automation? { + val automations = config.automationActions.mapNotNull { it.automation }.distinct() + return automations.singleOrNull() + } + + private fun rebuildTriggers( + automation: Automation, + events: Set, + ) { + automation.triggers.clear() + events.forEach { automation.triggers.add(triggerFor(it, automation)) } + } + + private fun triggerFor( + event: String, + automation: Automation, + ): AutomationTrigger { + val activityType = + runCatching { ActivityType.valueOf(event) }.getOrNull() + ?: error("Unknown app event '$event' — manifest validation should have rejected this earlier") + return AutomationTrigger(automation).apply { + this.type = AutomationTriggerType.ACTIVITY + this.activityType = activityType + this.debounceDurationInMs = 0 + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/apps/AppManifestFetcher.kt b/backend/data/src/main/kotlin/io/tolgee/service/apps/AppManifestFetcher.kt new file mode 100644 index 00000000000..b8ee0209761 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/apps/AppManifestFetcher.kt @@ -0,0 +1,275 @@ +package io.tolgee.service.apps + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.tolgee.activity.data.ActivityType +import io.tolgee.configuration.tolgee.AppsProperties +import io.tolgee.constants.Message +import io.tolgee.dtos.apps.AppActionType +import io.tolgee.dtos.apps.AppManifest +import io.tolgee.exceptions.BadRequestException +import io.tolgee.model.enums.Scope +import io.tolgee.util.UrlSecurity +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClientException +import org.springframework.web.client.RestTemplate +import java.net.URI + +@Component +class AppManifestFetcher( + private val restTemplate: RestTemplate, + private val objectMapper: ObjectMapper, + private val urlSecurity: UrlSecurity, + private val appsProperties: AppsProperties, +) { + data class FetchResult( + val manifest: AppManifest, + val rawJson: String, + val scopes: Set, + val webhookEvents: Set, + val resolvedWebhookUrl: String?, + ) + + fun fetch(url: String): FetchResult { + urlSecurity.validateUrl(url, allowLocalAddresses = appsProperties.allowLocalAddresses) + + val rawJson = + try { + restTemplate.getForObject(url, String::class.java) + ?: throw BadRequestException(Message.APP_MANIFEST_FETCH_FAILED) + } catch (e: RestClientException) { + throw BadRequestException(Message.APP_MANIFEST_FETCH_FAILED, listOf(e.message ?: "")) + } + + val manifest = + try { + objectMapper.readValue(rawJson) + } catch (e: Exception) { + throw BadRequestException(Message.APP_MANIFEST_INVALID, listOf(e.message ?: "")) + } + + val scopes = + try { + Scope.parse(manifest.scopes) + } catch (e: BadRequestException) { + throw BadRequestException( + Message.APP_MANIFEST_INVALID, + listOf("unknown scope: ${e.params?.firstOrNull() ?: ""}"), + ) + } + + val webhookEvents = parseWebhookEvents(manifest) + val resolvedWebhookUrl = resolveWebhookUrl(manifest) + validateActions(manifest) + + return FetchResult( + manifest = manifest, + rawJson = rawJson, + scopes = scopes, + webhookEvents = webhookEvents, + resolvedWebhookUrl = resolvedWebhookUrl, + ) + } + + private fun parseWebhookEvents(manifest: AppManifest): Set { + val declared = manifest.webhooks?.events ?: return emptySet() + declared.forEach { event -> + if (event !in SUPPORTED_APP_EVENTS) { + throw BadRequestException( + Message.APP_MANIFEST_INVALID, + listOf("unknown webhook event: $event"), + ) + } + } + return declared.toSet() + } + + /** + * Returns true if the given manifest event name maps to a known + * [ActivityType]. Plugins may subscribe to any activity type by its + * enum name (e.g. `SET_TRANSLATIONS`, `CREATE_KEY`, `BATCH_KEY_RESTORE`). + */ + private val SUPPORTED_APP_EVENTS: Set + get() = ActivityType.entries.map { it.name }.toSet() + + private fun validateActions(manifest: AppManifest) { + val tabKeys = + manifest.modules.keyEditTab + ?.map { it.key } + ?.toSet() + .orEmpty() + val panelKeys = + manifest.modules.translationToolsPanel + ?.map { it.key } + ?.toSet() + .orEmpty() + val modalKeys = + manifest.modules.modal + ?.map { it.key } + ?.toSet() + .orEmpty() + + manifest.modules.keyAction?.forEach { action -> + when (action.type) { + AppActionType.LINK -> + requireUrlTemplateForLink( + location = "key-action", + actionKey = action.key, + urlTemplate = action.urlTemplate, + dynamic = action.dynamic, + ) + AppActionType.TAB -> + requireRef( + location = "key-action", + actionKey = action.key, + refKind = "key-edit-tab", + refField = "tabKey", + ref = action.tabKey, + known = tabKeys, + ) + AppActionType.MODAL -> + requireRef( + location = "key-action", + actionKey = action.key, + refKind = "modal", + refField = "modalKey", + ref = action.modalKey, + known = modalKeys, + ) + AppActionType.PANEL -> + throw BadRequestException( + Message.APP_MANIFEST_INVALID, + listOf("key-action '${action.key}' cannot use type panel"), + ) + } + } + + manifest.modules.translationAction?.forEach { action -> + when (action.type) { + AppActionType.LINK -> + requireUrlTemplateForLink( + location = "translation-action", + actionKey = action.key, + urlTemplate = action.urlTemplate, + dynamic = action.dynamic, + ) + AppActionType.PANEL -> + requireRef( + location = "translation-action", + actionKey = action.key, + refKind = "translation-tools-panel", + refField = "panelKey", + ref = action.panelKey, + known = panelKeys, + ) + AppActionType.MODAL -> + requireRef( + location = "translation-action", + actionKey = action.key, + refKind = "modal", + refField = "modalKey", + ref = action.modalKey, + known = modalKeys, + ) + AppActionType.TAB -> + throw BadRequestException( + Message.APP_MANIFEST_INVALID, + listOf("translation-action '${action.key}' cannot use type tab"), + ) + } + } + + manifest.modules.bulkAction?.forEach { + triggerAction("bulk-action", it.key, it.type, it.urlTemplate, it.modalKey, modalKeys) + } + manifest.modules.translationsToolbarAction?.forEach { + triggerAction("translations-toolbar-action", it.key, it.type, it.urlTemplate, it.modalKey, modalKeys) + } + manifest.modules.projectMenuAction?.forEach { + triggerAction("project-menu-action", it.key, it.type, it.urlTemplate, it.modalKey, modalKeys) + } + manifest.modules.shortcut?.forEach { shortcut -> + if (shortcut.combination.isBlank()) { + throw BadRequestException( + Message.APP_MANIFEST_INVALID, + listOf("shortcut '${shortcut.key}' requires a non-blank combination"), + ) + } + triggerAction("shortcut", shortcut.key, shortcut.type, shortcut.urlTemplate, shortcut.modalKey, modalKeys) + } + } + + private fun requireUrlTemplateForLink( + location: String, + actionKey: String, + urlTemplate: String?, + dynamic: Boolean, + ) { + if (!dynamic && urlTemplate.isNullOrBlank()) { + throw BadRequestException( + Message.APP_MANIFEST_INVALID, + listOf("$location '$actionKey' of type link requires urlTemplate when not dynamic"), + ) + } + } + + private fun requireRef( + location: String, + actionKey: String, + refKind: String, + refField: String, + ref: String?, + known: Set, + ) { + if (ref.isNullOrBlank()) { + throw BadRequestException( + Message.APP_MANIFEST_INVALID, + listOf("$location '$actionKey' of type $refKind requires $refField"), + ) + } + if (ref !in known) { + throw BadRequestException( + Message.APP_MANIFEST_INVALID, + listOf("$location '$actionKey' references unknown $refKind '$ref'"), + ) + } + } + + /** + * Validates a trigger action (bulk-action, translations-toolbar-action, + * project-menu-action, shortcut). Trigger actions support only `link` and + * `modal` types. + */ + private fun triggerAction( + location: String, + actionKey: String, + type: AppActionType, + urlTemplate: String?, + modalKey: String?, + modalKeys: Set, + ) { + when (type) { + AppActionType.LINK -> requireUrlTemplateForLink(location, actionKey, urlTemplate, dynamic = false) + AppActionType.MODAL -> requireRef(location, actionKey, "modal", "modalKey", modalKey, modalKeys) + AppActionType.PANEL, + AppActionType.TAB, + -> + throw BadRequestException( + Message.APP_MANIFEST_INVALID, + listOf("$location '$actionKey' cannot use type ${type.name.lowercase()}"), + ) + } + } + + private fun resolveWebhookUrl(manifest: AppManifest): String? { + val webhookUrl = manifest.webhooks?.url ?: return null + return try { + URI(manifest.baseUrl).resolve(webhookUrl).toString() + } catch (e: Exception) { + throw BadRequestException( + Message.APP_MANIFEST_INVALID, + listOf("invalid webhook url: ${e.message ?: webhookUrl}"), + ) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt index c655bccb530..45ac831b868 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt @@ -86,8 +86,33 @@ class SecurityService( .expand( getProjectPermissionScopesNoApiKey(projectId, authenticationFacade.authenticatedUser.id), ).toSet() - val apiKey = activeApiKey ?: return projectScopes + if (authenticationFacade.isAppAuth) { + val appAuth = authenticationFacade.appAuthentication + if (appAuth.projectId != null && appAuth.projectId != projectId) { + return emptySet() + } + val installScopes = Scope.expand(appAuth.appInstall.grantedScopes).toSet() + + val actingAs = appAuth.actingAsUserAccount + if (actingAs != null) { + val actingAsScopes = + Scope + .expand(getProjectPermissionScopesNoApiKey(projectId, actingAs.id)) + .toSet() + return installScopes.intersect(actingAsScopes) + } + + if (appAuth.isInstallContext) { + // App-secret without acting-as header — install scopes only, no user intersection. + return installScopes + } + + // User-context JWT (scope 5): intersect with the iframe user's project scopes. + return installScopes.intersect(projectScopes) + } + + val apiKey = activeApiKey ?: return projectScopes return Scope.expand(apiKey.scopes).toSet().intersect(projectScopes.toSet()) } diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index cc59a4c32ac..2c96c81d76d 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -5611,4 +5611,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/ScopeTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/ScopeTest.kt new file mode 100644 index 00000000000..19f119a7a42 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/ScopeTest.kt @@ -0,0 +1,57 @@ +package io.tolgee.unit + +import io.tolgee.model.enums.Scope +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class ScopeTest { + @Test + fun `GLOSSARY_EDIT expands to include GLOSSARY_VIEW`() { + Scope + .expand(Scope.GLOSSARY_EDIT) + .toSet() + .assert + .containsExactlyInAnyOrder(Scope.GLOSSARY_EDIT, Scope.GLOSSARY_VIEW) + } + + @Test + fun `GLOSSARY_VIEW expands to only itself`() { + Scope + .expand(Scope.GLOSSARY_VIEW) + .toSet() + .assert + .containsExactlyInAnyOrder(Scope.GLOSSARY_VIEW) + } + + @Test + fun `project ADMIN does not include the org-level glossary scopes`() { + val admin = Scope.expand(Scope.ADMIN).toSet() + admin.assert.doesNotContain(Scope.GLOSSARY_VIEW, Scope.GLOSSARY_EDIT) + } + + @Test + fun `glossary scopes do not expand into project scopes`() { + val expanded = Scope.expand(Scope.GLOSSARY_EDIT).toSet() + expanded.assert.doesNotContain(Scope.TRANSLATIONS_VIEW, Scope.KEYS_VIEW, Scope.ADMIN) + } + + @Test + fun `organizationLevelScopes contains exactly the glossary scopes`() { + Scope.organizationLevelScopes.assert + .containsExactlyInAnyOrder(Scope.GLOSSARY_VIEW, Scope.GLOSSARY_EDIT) + } + + @Test + fun `isOrganizationLevel classifies scopes correctly`() { + Scope.isOrganizationLevel(Scope.GLOSSARY_EDIT).assert.isTrue() + Scope.isOrganizationLevel(Scope.GLOSSARY_VIEW).assert.isTrue() + Scope.isOrganizationLevel(Scope.TRANSLATIONS_VIEW).assert.isFalse() + Scope.isOrganizationLevel(Scope.ADMIN).assert.isFalse() + } + + @Test + fun `glossary scopes are parseable from their string values`() { + Scope.fromValue("glossary.view").assert.isEqualTo(Scope.GLOSSARY_VIEW) + Scope.fromValue("glossary.edit").assert.isEqualTo(Scope.GLOSSARY_EDIT) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/apps/AppManifestFetcherTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/apps/AppManifestFetcherTest.kt new file mode 100644 index 00000000000..ff02e5b5370 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/apps/AppManifestFetcherTest.kt @@ -0,0 +1,64 @@ +package io.tolgee.unit.apps + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.tolgee.configuration.tolgee.AppsProperties +import io.tolgee.configuration.tolgee.InternalProperties +import io.tolgee.constants.Message +import io.tolgee.exceptions.BadRequestException +import io.tolgee.service.apps.AppManifestFetcher +import io.tolgee.testing.assert +import io.tolgee.util.UrlSecurity +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.anyString +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.web.client.RestTemplate + +class AppManifestFetcherTest { + private val manifestJson = + """ + { + "id": "test-app", + "name": "Test App", + "version": "0.1.0", + "baseUrl": "https://app.example.com", + "scopes": ["translations.view"], + "modules": { + "project-dashboard-page": [ + {"key": "home", "title": "Home", "icon": "🏠", "entry": "/"} + ] + } + } + """.trimIndent() + + private fun fetcher(allowLocalAddresses: Boolean): AppManifestFetcher { + val restTemplate = mock() + doReturn(manifestJson).whenever(restTemplate).getForObject(anyString(), eq(String::class.java)) + return AppManifestFetcher( + restTemplate, + jacksonObjectMapper(), + UrlSecurity(InternalProperties()), + AppsProperties().apply { this.allowLocalAddresses = allowLocalAddresses }, + ) + } + + @Test + fun `rejects a local manifest URL by default`() { + val exception = + assertThrows { + fetcher(allowLocalAddresses = false).fetch("http://localhost:5181/manifest.json") + } + exception.code.assert.isEqualTo(Message.URL_NOT_VALID.code) + } + + @Test + fun `allows a local manifest URL when apps allowLocalAddresses is true`() { + assertDoesNotThrow { + fetcher(allowLocalAddresses = true).fetch("http://localhost:5181/manifest.json") + } + } +} diff --git a/backend/security/src/main/kotlin/io/tolgee/security/ProjectContextService.kt b/backend/security/src/main/kotlin/io/tolgee/security/ProjectContextService.kt index 7db60a95726..fbfad545f13 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/ProjectContextService.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/ProjectContextService.kt @@ -68,6 +68,18 @@ class ProjectContextService( userId, ) + if (authenticationFacade.isAppAuth) { + // The user demonstrably has the project (they reached it through the + // plugin sidebar of a project they're a member of). The empty effective + // scope set means the install's grantedScopes don't intersect with the + // user's project scopes. Surface that as 403 with the required scopes — + // hiding-project-existence is the wrong threat model here. + throw PermissionException( + Message.OPERATION_NOT_PERMITTED, + requiredScopes?.map { it.value } ?: emptyList(), + ) + } + if (!canBypassForReadOnly()) { // Security consideration: if the user cannot see the project, pretend it does not exist. throw ProjectNotFoundException(project.id) @@ -131,7 +143,7 @@ class ProjectContextService( } private val canUseAdminPermissions - get() = !authenticationFacade.isProjectApiKeyAuth + get() = !authenticationFacade.isProjectApiKeyAuth && !authenticationFacade.isAppAuth private fun canBypass(isWriteOperation: Boolean): Boolean { if (!canUseAdminPermissions) return false diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt index 5263dc4d452..edb36f229d8 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt @@ -17,17 +17,22 @@ package io.tolgee.security.authentication import io.tolgee.component.CurrentDateProvider +import io.tolgee.component.KeyGenerator import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.exceptions.AuthExpiredException import io.tolgee.exceptions.AuthenticationException +import io.tolgee.exceptions.PermissionException import io.tolgee.security.BILLING_API_KEY_PREFIX import io.tolgee.security.PAT_PREFIX import io.tolgee.security.ratelimit.RateLimitService import io.tolgee.security.thirdParty.SsoDelegate +import io.tolgee.service.apps.AppEnablementService +import io.tolgee.service.apps.AppInstallService import io.tolgee.service.security.ApiKeyService import io.tolgee.service.security.PatService +import io.tolgee.service.security.PermissionService import io.tolgee.service.security.UserAccountService import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest @@ -36,6 +41,7 @@ import org.springframework.context.annotation.Lazy import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter +import java.util.Base64 @Component @Lazy @@ -48,6 +54,12 @@ class AuthenticationFilter( @Lazy private val jwtService: JwtService, @Lazy + private val appTokenService: AppTokenService, + @Lazy + private val appInstallService: AppInstallService, + @Lazy + private val appEnablementService: AppEnablementService, + @Lazy private val userAccountService: UserAccountService, @Lazy private val apiKeyService: ApiKeyService, @@ -55,7 +67,18 @@ class AuthenticationFilter( private val patService: PatService, @Lazy private val ssoDelegate: SsoDelegate, + @Lazy + private val keyGenerator: KeyGenerator, + @Lazy + private val permissionService: PermissionService, ) : OncePerRequestFilter() { + companion object { + private const val APP_SECRET_PREFIX = "tgapps_" + private const val APP_CLIENT_ID_PREFIX = "tgapp_" + private const val ACTING_AS_USER_HEADER = "X-Tolgee-Act-As-User-Id" + private val PROJECT_URL_REGEX = Regex("/v2/projects/(\\d+)") + } + private val authenticationProperties get() = tolgeeProperties.authentication private val internalProperties @@ -88,13 +111,31 @@ class AuthenticationFilter( val authorization = request.getHeader("Authorization") if (authorization != null) { if (authorization.startsWith("Bearer ")) { - val auth = jwtService.validateToken(authorization.substring(7)) + val token = authorization.substring(7) + + // Try app-token (audience `tg.app`) first. If validation throws because the token + // is not an app token (wrong audience), fall back to the user JWT path. Any other + // failure (bad signature, expired, missing entities) surfaces as an auth error + // and we do not fall through. + val appAuth = tryAppTokenAuth(token) + if (appAuth != null) { + checkIfSsoUserStillValid(appAuth.principal) + SecurityContextHolder.getContext().authentication = appAuth + return + } + + val auth = jwtService.validateToken(token) checkIfSsoUserStillValid(auth.principal) SecurityContextHolder.getContext().authentication = auth return } + if (authorization.startsWith("Basic ")) { + appBasicAuth(request, authorization.substring(6)) + return + } + throw AuthenticationException(Message.INVALID_JWT_TOKEN) } @@ -109,6 +150,11 @@ class AuthenticationFilter( return } + if (apiKey.startsWith(APP_SECRET_PREFIX)) { + appSecretAuth(request, apiKey) + return + } + // Attempt PAK auth even if it doesn't have the prefix // Might be a legacy key pakAuth(apiKey) @@ -130,6 +176,47 @@ class AuthenticationFilter( } } + /** + * Returns an [AppAuthentication] if the token parses as an app token and the live + * entity resolution (install + user + tokensValidNotBefore + per-project enablement) + * succeeds. Returns null when the token is not an app token (so the caller falls + * back to user JWT validation). Throws [AuthenticationException] for app tokens that + * are well-formed but reference revoked or missing entities. + */ + private fun tryAppTokenAuth(token: String): AppAuthentication? { + val claims = + try { + appTokenService.validateToken(token) + } catch (_: AuthenticationException) { + // wrong audience, bad signature, expired or malformed — let the caller try user JWT + return null + } + + val install = + appInstallService.find(claims.installId) + ?: throw AuthenticationException(Message.INVALID_JWT_TOKEN) + + val user = + userAccountService.findDto(claims.userId) + ?: throw AuthenticationException(Message.INVALID_JWT_TOKEN) + + if (user.tokensValidNotBefore != null && claims.issuedAt.before(user.tokensValidNotBefore)) { + throw AuthExpiredException(Message.EXPIRED_JWT_TOKEN) + } + + if (!appEnablementService.isEnabledForProject(claims.projectId, claims.installId)) { + throw AuthenticationException(Message.INVALID_JWT_TOKEN) + } + + return AppAuthentication( + credentials = token, + appInstall = install, + userAccount = user, + projectId = claims.projectId, + isInstallContext = false, + ) + } + private fun checkIfSsoUserStillValid(userDto: UserAccountDto) { when (internalProperties.verifySsoAccountAvailableBypass) { true -> { @@ -210,6 +297,111 @@ class AuthenticationFilter( ) } + private fun appSecretAuth( + request: HttpServletRequest, + key: String, + ) { + val resolution = + appInstallService.resolveByClientSecretHash(keyGenerator.hash(key)) + ?: throw AuthenticationException(Message.INVALID_APP_CREDENTIALS) + populateAppAuth(request, resolution, credentials = key) + } + + private fun appBasicAuth( + request: HttpServletRequest, + encoded: String, + ) { + val decoded = + try { + String(Base64.getDecoder().decode(encoded)) + } catch (_: IllegalArgumentException) { + throw AuthenticationException(Message.INVALID_APP_CREDENTIALS) + } + val separator = decoded.indexOf(':') + if (separator < 0) { + throw AuthenticationException(Message.INVALID_APP_CREDENTIALS) + } + val clientId = decoded.substring(0, separator) + val clientSecret = decoded.substring(separator + 1) + if (!clientId.startsWith(APP_CLIENT_ID_PREFIX) || !clientSecret.startsWith(APP_SECRET_PREFIX)) { + throw AuthenticationException(Message.INVALID_APP_CREDENTIALS) + } + + val resolution = + appInstallService.resolveByClientId(clientId) + ?: throw AuthenticationException(Message.INVALID_APP_CREDENTIALS) + val providedHash = keyGenerator.hash(clientSecret) + val storedHash = resolution.install.clientSecretHash + if (storedHash == null || !constantTimeEquals(providedHash, storedHash)) { + throw AuthenticationException(Message.INVALID_APP_CREDENTIALS) + } + populateAppAuth(request, resolution, credentials = clientId) + } + + private fun populateAppAuth( + request: HttpServletRequest, + resolution: AppInstallService.AppCredentialResolution, + credentials: Any?, + ) { + val install = resolution.install + val projectId = extractProjectIdFromUrl(request) + val actingAsUser = resolveActingAsUser(request, projectId) + + if (projectId != null && !appEnablementService.isEnabledForProject(projectId, install.id)) { + throw AuthenticationException(Message.INVALID_APP_CREDENTIALS) + } + + SecurityContextHolder.getContext().authentication = + AppAuthentication( + credentials = credentials, + userAccount = resolution.authorPrincipal, + appInstall = install, + projectId = projectId, + isInstallContext = true, + actingAsUserAccount = actingAsUser, + ) + } + + private fun resolveActingAsUser( + request: HttpServletRequest, + projectId: Long?, + ): UserAccountDto? { + val raw = request.getHeader(ACTING_AS_USER_HEADER) ?: return null + // Acting-as is only meaningful (and only scope-checked) within a project context. Without a + // project in the URL there is nothing to bound the acted-as user's permissions against, so we + // refuse rather than impersonate an arbitrary user on org/user-level endpoints. + if (projectId == null) { + throw PermissionException(Message.APP_ACTING_AS_USER_NOT_PROJECT_MEMBER) + } + val userId = + raw.toLongOrNull() ?: throw AuthenticationException(Message.INVALID_APP_CREDENTIALS) + val user = + userAccountService.findDto(userId) + ?: throw PermissionException(Message.APP_ACTING_AS_USER_NOT_PROJECT_MEMBER) + val scopes = permissionService.getProjectPermissionScopesNoApiKey(projectId, user.id) + if (scopes.isNullOrEmpty()) { + throw PermissionException(Message.APP_ACTING_AS_USER_NOT_PROJECT_MEMBER) + } + return user + } + + private fun extractProjectIdFromUrl(request: HttpServletRequest): Long? { + val match = PROJECT_URL_REGEX.find(request.requestURI) ?: return null + return match.groupValues.getOrNull(1)?.toLongOrNull() + } + + private fun constantTimeEquals( + a: String, + b: String, + ): Boolean { + if (a.length != b.length) return false + var result = 0 + for (i in a.indices) { + result = result or (a[i].code xor b[i].code) + } + return result == 0 + } + private val initialUser by lazy { val account = userAccountService.findInitialUser() diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authorization/AllowAppAccessWithOrgScope.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/AllowAppAccessWithOrgScope.kt new file mode 100644 index 00000000000..c29684ea46f --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/authorization/AllowAppAccessWithOrgScope.kt @@ -0,0 +1,18 @@ +package io.tolgee.security.authorization + +import io.tolgee.model.enums.Scope + +/** + * Opts an organization-level endpoint in to access by an installed Tolgee App. + * + * Apps are otherwise rejected from org-level endpoints. With this annotation, an app token is + * allowed when its install belongs to the **target organization** and the install's granted scopes + * include the required org-level [scopes]. The org owner consents to those scopes at install time, + * so the install grant alone authorizes the call — no user organization role is required. + * + * The [scopes] must be organization-level scopes (see [Scope.organizationLevelScopes]). + */ +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.ANNOTATION_CLASS) +annotation class AllowAppAccessWithOrgScope( + vararg val scopes: Scope, +) diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt index 73522b37d4d..57d85b24af4 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt @@ -16,11 +16,13 @@ package io.tolgee.security.authorization +import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.isAdmin import io.tolgee.dtos.cacheable.isSupporterOrAdmin import io.tolgee.exceptions.NotFoundException import io.tolgee.exceptions.PermissionException import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.model.enums.Scope import io.tolgee.security.OrganizationHolder import io.tolgee.security.RequestContextService import io.tolgee.security.authentication.AuthenticationFacade @@ -54,6 +56,12 @@ class OrganizationAuthorizationInterceptor( response: HttpServletResponse, handler: HandlerMethod, ): Boolean { + if (authenticationFacade.isAppAuth) { + // Apps are rejected from org-level endpoints unless the endpoint explicitly opts in via + // @AllowAppAccessWithOrgScope and the app's install (in this org) holds the required scope. + return handleAppAuthorization(request, handler) + } + val userId = authenticationFacade.authenticatedUser.id val organization = requestContextService.getTargetOrganization(request) @@ -119,6 +127,40 @@ class OrganizationAuthorizationInterceptor( return true } + /** + * Authorizes an app token on an org-level endpoint. Allowed only when the endpoint opts in via + * [AllowAppAccessWithOrgScope], the app's install belongs to the target organization, and the + * install's granted scopes include the required org-level scope(s). The org owner consented to + * those scopes at install time, so no user organization role is checked (install grant suffices). + */ + private fun handleAppAuthorization( + request: HttpServletRequest, + handler: HandlerMethod, + ): Boolean { + val annotation = + AnnotationUtils.getAnnotation(handler.method, AllowAppAccessWithOrgScope::class.java) + ?: throw PermissionException(Message.APP_TOKEN_NOT_ALLOWED_FOR_ENDPOINT) + + val organization = + requestContextService.getTargetOrganization(request) + ?: throw PermissionException(Message.APP_TOKEN_NOT_ALLOWED_FOR_ENDPOINT) + + val appInstall = authenticationFacade.appAuthentication.appInstall + if (appInstall.organization.id != organization.id) { + // An app may only act on its own organization. + throw PermissionException(Message.APP_TOKEN_NOT_ALLOWED_FOR_ENDPOINT) + } + + val granted = Scope.expand(appInstall.grantedScopes).toSet() + val missing = annotation.scopes.toList().filterNot { granted.contains(it) } + if (missing.isNotEmpty()) { + throw PermissionException(missing) + } + + organizationHolder.organization = organization + return true + } + private fun getRequiredRole( request: HttpServletRequest, handler: HandlerMethod, diff --git a/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationDisabledFilterTest.kt b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationDisabledFilterTest.kt index 8bdaabfe07e..6797855181d 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationDisabledFilterTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationDisabledFilterTest.kt @@ -51,7 +51,21 @@ class AuthenticationDisabledFilterTest { private val userAccount = mock(UserAccount::class.java) private val authenticationDisabledFilter = - AuthenticationFilter(tolgeeProperties, mock(), mock(), mock(), userAccountService, mock(), mock(), mock()) + AuthenticationFilter( + tolgeeProperties = tolgeeProperties, + currentDateProvider = mock(), + rateLimitService = mock(), + jwtService = mock(), + appTokenService = mock(), + appInstallService = mock(), + appEnablementService = mock(), + userAccountService = userAccountService, + apiKeyService = mock(), + patService = mock(), + ssoDelegate = mock(), + keyGenerator = mock(), + permissionService = mock(), + ) @BeforeEach fun setupMocksAndSecurityCtx() { diff --git a/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationFilterTest.kt b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationFilterTest.kt index 9761016d9ed..49ab68ef62f 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationFilterTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationFilterTest.kt @@ -17,6 +17,7 @@ package io.tolgee.security.authentication import io.tolgee.component.CurrentDateProvider +import io.tolgee.component.KeyGenerator import io.tolgee.configuration.tolgee.AuthenticationProperties import io.tolgee.configuration.tolgee.InternalProperties import io.tolgee.configuration.tolgee.TolgeeProperties @@ -30,8 +31,11 @@ import io.tolgee.security.ratelimit.RateLimitPolicy import io.tolgee.security.ratelimit.RateLimitService import io.tolgee.security.ratelimit.RateLimitedException import io.tolgee.security.thirdParty.SsoDelegate +import io.tolgee.service.apps.AppEnablementService +import io.tolgee.service.apps.AppInstallService import io.tolgee.service.security.ApiKeyService import io.tolgee.service.security.PatService +import io.tolgee.service.security.PermissionService import io.tolgee.service.security.UserAccountService import io.tolgee.testing.assertions.Assertions.assertThat import org.junit.jupiter.api.AfterEach @@ -89,16 +93,31 @@ class AuthenticationFilterTest { private val ssoDelegate = Mockito.mock(SsoDelegate::class.java) + private val appTokenService = Mockito.mock(AppTokenService::class.java) + + private val appInstallService = Mockito.mock(AppInstallService::class.java) + + private val appEnablementService = Mockito.mock(AppEnablementService::class.java) + + private val keyGenerator = Mockito.mock(KeyGenerator::class.java) + + private val permissionService = Mockito.mock(PermissionService::class.java) + private val authenticationFilter = AuthenticationFilter( - tolgeeProperties, - currentDateProvider, - rateLimitService, - jwtService, - userAccountService, - pakService, - patService, - ssoDelegate, + tolgeeProperties = tolgeeProperties, + currentDateProvider = currentDateProvider, + rateLimitService = rateLimitService, + jwtService = jwtService, + appTokenService = appTokenService, + appInstallService = appInstallService, + appEnablementService = appEnablementService, + userAccountService = userAccountService, + apiKeyService = pakService, + patService = patService, + ssoDelegate = ssoDelegate, + keyGenerator = keyGenerator, + permissionService = permissionService, ) private val authenticationFacade = @@ -118,6 +137,13 @@ class AuthenticationFilterTest { Mockito.`when`(authProperties.enabled).thenReturn(true) Mockito.`when`(internalProperties.verifySsoAccountAvailableBypass).thenReturn(null) + // These tests use user JWTs / PAK / PAT, never app tokens. validateToken throws + // for non-app tokens (the filter catches it and falls back to user JWT auth); + // an unstubbed mock would instead return null and NPE on claims.installId. + Mockito + .`when`(appTokenService.validateToken(any())) + .thenThrow(AuthenticationException(Message.INVALID_JWT_TOKEN)) + Mockito .`when`(rateLimitService.getIpAuthRateLimitPolicy(any())) .thenReturn( diff --git a/docs/dev-notes/APPS_TODO.md b/docs/dev-notes/APPS_TODO.md new file mode 100644 index 00000000000..3b33c93bc0f --- /dev/null +++ b/docs/dev-notes/APPS_TODO.md @@ -0,0 +1,42 @@ +APPS_TODO + +- [ ] Handle the situation of missing scope -> ask for consent for additional scopes +- [ ] Cache the app scopes, so every request doesn't need to trigger db query +- [ ] Proper oAuth for apps? + +## Security follow-ups (logged during pre-preview review) + +These are known, accepted-for-now gaps surfaced while hardening the apps backend before +the preview deployment. Tracked here rather than fixed because they are lower-risk or need +a larger design decision. + +- [ ] **`webhookSecret` stored in plaintext** (`AppInstallService.register`). The clientSecret + is hashed, but the webhook secret is kept in plaintext because HMAC signing of outgoing + webhooks needs the raw value. Should be encrypted at rest (e.g. column encryption) rather + than stored as cleartext. +- [ ] **Shared JWT signing key** — `AppTokenService` reuses the platform's `jwt_signing_key` + to sign app context tokens. The `aud=tg.app` claim separates them from user JWTs, but a + dedicated key (and ideally asymmetric / JWKS) is the right end state so app tokens can't be + confused with user tokens if the audience check ever regresses. +- [ ] **Webhook delivery URL is not SSRF-validated.** The manifest *fetch* is now guarded by + `UrlSecurity` (`AppManifestFetcher.fetch`), but the resolved webhook URL (where Tolgee POSTs + activity) is stored and delivered to without the same private-network check. Validate it at + send time in the apps webhook path (kept out of the manifest fetcher because the test + manifests use a non-resolving `app.example.com` baseUrl). +- [ ] **Adding scopes after install requires owner action.** Plugin-initiated manifest updates + (`/v2/apps/self/manifest-url`) are now narrow-only: they can drop scopes but never widen + them. To grant a newly-declared scope, an org owner must refresh from the UI (owner path + still widens). A proper incremental-consent flow would make this self-service safely. + +## Local development note + +`AppManifestFetcher` now rejects manifest URLs that resolve to private/loopback/link-local +addresses (SSRF protection). Local app development registers a `localhost` manifest URL, which +the guard would block. `application-dev.yaml` is gitignored (per-developer), so add this to +your local one when developing apps against a local Tolgee: + +```yaml +tolgee: + internal: + disable-url-ssrf-protection: true +``` diff --git a/docs/dev-notes/CREATE_TOLGEE_APP_MILESTONE_1_PLAN.md b/docs/dev-notes/CREATE_TOLGEE_APP_MILESTONE_1_PLAN.md new file mode 100644 index 00000000000..89fd47f417a --- /dev/null +++ b/docs/dev-notes/CREATE_TOLGEE_APP_MILESTONE_1_PLAN.md @@ -0,0 +1,208 @@ +# Plan: `create-tolgee-app` generator + dev environment (milestone 1) + +> **Draft.** Captured for later execution; preparation work needed first +> (see "Open prep work" at the bottom). + +## Context + +Tolgee Apps PoC is functional but onboarding a new plugin author is hostile: +they have to hand-write `manifest.json`, glue postMessage by reading source, +guess at the JWT shape, copy/paste a tunnel URL into Tolgee on every dev +restart, and reverse-engineer the webhook signature scheme by reading Kotlin. + +This milestone delivers a `create-tolgee-app` CLI that scaffolds a working +plugin in one prompt-driven flow, plus a `npm run dev` in the generated +project that "just works" against `app.tolgee.io` — public tunnel, auto- +re-pointing the manifest URL on every restart, hot reload on manifest edits. + +**Deferred to later milestones:** deploy targets (milestone 2), test +scaffolding (milestone 3), multi-framework templates, SDK extraction, +asymmetric JWT / JWKS, `tolgee-app doctor`, `add-module` subcommand. + +The full research lives at `CREATE_TOLGEE_APP_RESEARCH.md` (sibling file). + +## Constraints discovered during exploration + +- **No SDK package exists.** The template inlines a small `tolgeeApp.ts` + with the postMessage `ready`/`init` handshake (copied + cleaned from + `dev-plugin/src/App.tsx`). +- **No "swap manifest URL" endpoint.** Refresh re-fetches from the + *existing* URL; with Cloudflare Quick Tunnels giving a new URL on + every restart, this is the friction point. Milestone adds the + endpoint. +- **JWT is HMAC-signed** with the platform's shared signing key + (`AppTokenService.kt`). Claims: `aud="tg.app"`, `sub=userId`, + `tg.app.inst`, `tg.app.proj`. No JWKS today. Template includes a + decoder that *reads* the claims for context but does not verify + signature itself (verification would need the platform secret, which + plugins don't have); plugins instead authenticate calls back to + Tolgee with the JWT as a bearer token and let Tolgee verify it. +- **Webhook signature** is a JSON-blob header + `Tolgee-Signature: {"timestamp": , "signature": ""}` where + the signed payload is `${timestamp}.${rawBody}` HMAC-SHA256. Per- + install `webhookSecret` lives on the install record. Template includes + a verifier matching this exact scheme. + +## Deliverables + +### A. Backend: one new endpoint + +`PATCH /v2/organizations/{organizationId}/apps/{installId}/manifest-url` + +- Body: `{ manifestUrl: String }` +- Auth: `RequiresOrganizationRole(OWNER)` (matches the rest of + `OrganizationAppsController`) +- Behavior: fetch the new manifest via `AppManifestFetcher`, assert + `appId` is unchanged (same rule as `refresh()`), update the existing + install in place (manifestUrl + all snapshot fields), return the + updated `AppInstallModel`. +- Reuses `AppInstallService` — pattern mirrors the existing + `refresh(installId)` method; this is `updateManifestUrl(installId, newUrl)`. + +**Files to add/modify** (representative): +- `backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationAppsController.kt` — new `@PatchMapping("/{installId}/manifest-url")` controller method. +- `backend/data/src/main/kotlin/io/tolgee/service/apps/AppInstallService.kt` — new `updateManifestUrl()` service method, lifted from `refresh()`. +- `backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationAppsControllerTest.kt` — test for the happy path + the `appId mismatch` error case. +- Regenerate `webapp/src/service/apiSchema.generated.ts`. + +### B. CLI package: `create-tolgee-app/` + +Location: `tolgee-platform/create-tolgee-app/` (same worktree, alongside +`dev-plugin/`). Eventual move to `tolgee-js` on publish. + +Stack: +- TypeScript source, bundled with **tsup** to a single `dist/index.js` shebang script +- **@clack/prompts** for the interactive wizard (modern, what Astro/Nuxt use) +- **giget**-free: template lives as `template/` inside the package and is copied with simple file-name + variable substitution (mustache-style `{{appId}}`) +- Executable name in `package.json` `bin`: `create-tolgee-app` → resolves to `npm create tolgee-app@latest` + +Wizard questions (in order): +1. App name (kebab-case, becomes `id` in manifest, becomes target dir name) +2. Modules to scaffold (multi-select from the 10 known module types — `project-dashboard-page`, `translation-tools-panel`, `key-edit-tab`, `key-action`, `translation-action`, `modal`, `bulk-action`, `translations-toolbar-action`, `project-menu-action`, `shortcut`) +3. Webhook events (multi-select from a curated list of common `ActivityType` values; "none" is OK) +4. Tolgee URL (default `https://app.tolgee.io`, free-text override for self-hosted) +5. Install deps now? (y/n, detects pm from `npm_config_user_agent`) +6. Git init + initial commit? (y/n) + +Critical files in `create-tolgee-app/`: +- `src/index.ts` — entry, runs wizard, copies template +- `src/copy.ts` — recursive copy with variable substitution +- `src/registry.ts` — module/event registry (single source of truth shared by template's manifest builder + the wizard) +- `template/` — see next section +- `package.json` with `bin`, `tsup` build, `engines.node >=20` +- `README.md` + +### C. Generated project template + +Mirrors `dev-plugin/` minus PoC-specific routes (no `emoji`, no `state` API). Stack: Vite + React + TypeScript + Express, same ports (5180 frontend, 5181 backend). + +Skeleton: +``` +/ + manifest.json ← populated from wizard answers + package.json ← scripts: dev, build, register, refresh + tsconfig.json + .env.example ← TOLGEE_URL, ORG_ID, INSTALL_ID, CLIENT_SECRET, WEBHOOK_SECRET, TOLGEE_PAT + .gitignore ← .env.local, .tolgee-app.json, node_modules + README.md ← 5-line quickstart + what each env var means + vite.config.ts + src/ + main.tsx ← React root for module entries + tolgeeApp.ts ← postMessage glue (ready/init handshake, token + selection accessors) + modules//index.tsx ← one folder per selected module with a stub

{title}

+ server/ + index.ts ← Express app, mounts routes from below + signature.ts ← Tolgee-Signature header verifier (HMAC over "${timestamp}.${rawBody}") + routes/ + webhook.ts ← POST /webhook, signature-verified, switch on activityData.type + decorators.ts ← POST /decorators with CORS preflight for TOLGEE_URL + scripts/ + dev.ts ← orchestrator (see part D) + register.ts ← interactive: prompts for PAT, posts to /v2/organizations/{org}/apps + refresh.ts ← POSTs to /apps/{installId}/refresh (manual trigger) +``` + +### D. Dev orchestrator (`scripts/dev.ts` in generated project) + +Single command (`npm run dev`) that: + +1. Loads `.env.local`, validates required vars with `zod` (friendly error if missing). +2. Spawns Vite (port 5180) and Express (port 5181) via the existing `concurrently` pattern. +3. Starts a **Cloudflare Quick Tunnel** via the `cloudflared` npm package + (`JacobLinCool/node-cloudflared`); captures the assigned + `https://.trycloudflare.com` URL, points it at port 5180. +4. If `.tolgee-app.json` (gitignored, persisted by `npm run register`) + contains an `installId`: + - PATCHes `/v2/organizations/{orgId}/apps/{installId}/manifest-url` + with `{ manifestUrl: "/manifest.json" }` using the stored + PAT. Endpoint is the one added in part A. +5. If no `installId` yet: print a colorized one-liner instructing + `npm run register`, exit gracefully (don't block — they can still + develop locally without Tolgee while iterating UI). +6. Logs all incoming requests (path, method, latency) to stdout for + visibility. +7. Watches `manifest.json` — on change, POSTs `/apps/{installId}/refresh` + so Tolgee re-fetches it without a tunnel-URL swap. +8. Tears down the tunnel on `SIGINT`. + +`scripts/register.ts` is a separate one-time bootstrap that: +- Prompts for a Tolgee PAT (or reads from `TOLGEE_PAT` env) +- Asks which organization to install into (lists via `GET /v2/organizations`) +- POSTs `manifestUrl` to `/v2/organizations/{org}/apps` +- Writes `{ installId, organizationId, clientSecret, webhookSecret }` to `.tolgee-app.json` + +### E. Tunnel integration + +- Dependency: `cloudflared` npm package (auto-installs binary, no homebrew step) +- No account / no signup required for Quick Tunnels +- Document a `TOLGEE_DEV_TUNNEL_URL=https://stable.example.com` env override for users who set up their own Named Tunnel — the orchestrator skips Cloudflare boot when set + +## Out of scope (deferred) + +- `npm run deploy` to Cloudflare/Fly — milestone 2 +- Playwright smoke test + JWT contract tests — milestone 3 +- Multi-framework templates (Hono, Next.js, Vanilla TS) +- SDK extraction (`@tolgee/apps-sdk` published package) +- Asymmetric JWT / JWKS endpoint +- `tolgee-app doctor`, `add-module`, mock-Tolgee +- Auto-discovery of `organizationId` (PAT might map to multiple orgs — milestone 1 just asks) +- Webhook replay protection (timestamp window check) — note in README + +## Verification + +End-to-end smoke (in order): + +1. Build the CLI: + `cd tolgee-platform/create-tolgee-app && npm install && npm run build` +2. Run it locally: + `node dist/index.js my-test-plugin` — verify wizard, verify project tree. +3. In the generated project, `npm install` then `npm run register`: + verify it asks for a PAT, lists orgs, posts a register call, + writes `.tolgee-app.json`. +4. `npm run dev` — verify all three processes start, verify + `cloudflared` prints a `*.trycloudflare.com` URL, verify the PATCH + to `manifest-url` succeeds, verify Tolgee's `GET /v2/projects/{id}/apps` + now lists the install with the new tunnel URL. +5. In Tolgee UI: enable the test plugin on a project, navigate to a + module the wizard scaffolded — verify iframe loads, verify + `tolgee-app:init` handshake completes (console log inside the + stub module). +6. Edit `manifest.json` (e.g. change a module title), save — + verify the dev orchestrator POSTs refresh, verify the Tolgee UI + picks it up on reload. +7. Restart `npm run dev` (Ctrl-C then re-run) — verify the new + tunnel URL gets PATCHed in *without* requiring re-register. This + is the milestone's headline DX win. +8. Send a webhook by editing a translation in Tolgee — verify the + signature verifier accepts it, verify the typed handler stub fires. + +Backend tests: +- `./gradlew :server-app:test --tests "io.tolgee.api.v2.controllers.organization.OrganizationAppsControllerTest" --console=plain` +- Should cover: PATCH happy path, appId-mismatch rejected, + unauthorized (non-owner) rejected. + +--- + +## Open prep work (to figure out before executing this plan) + +_Fill in as we decide._ diff --git a/docs/dev-notes/CREATE_TOLGEE_APP_RESEARCH.md b/docs/dev-notes/CREATE_TOLGEE_APP_RESEARCH.md new file mode 100644 index 00000000000..a2aa7e232c1 --- /dev/null +++ b/docs/dev-notes/CREATE_TOLGEE_APP_RESEARCH.md @@ -0,0 +1,171 @@ +# `create-tolgee-app` — Research & Recommendations + +> Scoping notes for a future plugin-generator CLI. Not yet built. Captured here so we can iterate against it instead of re-doing the research. + +## 1. Tunneling for dev + +For Tolgee Apps the tunnel is non-negotiable because webhooks come from Tolgee's backend, not the user's browser. The iframe modules technically work over `http://localhost` if the user is testing against a Tolgee that the same browser can reach, but as soon as someone wants to test against `app.tolgee.io`, the same public URL is the only sensible default. So the tunnel needs to be: zero-account, free forever, scriptable from `npm run dev`, and accept inbound HTTPS to a random hostname. + +| Tool | Free tier | Persistent URL on free | Account needed | Node integration | Status 2026 | +|---|---|---|---|---|---| +| **Cloudflare Quick Tunnel** (`cloudflared --url`) | Truly free, no caps for HTTP/HTTPS | No (random `*.trycloudflare.com`) | No | `cloudflared` (JacobLinCool) and `untun` npm wrappers | Actively maintained, used by Shopify CLI as the default | +| Cloudflare Named Tunnel | Free | Yes (requires domain on CF) | Yes | Same wrappers | Same as above; more setup | +| ngrok | Restrictive in 2026 (DDEV publicly considered dropping it Feb 2026); single static domain only on paid | No (random unless paid) | Yes (authtoken) | First-class `@ngrok/ngrok` SDK, no binary needed | Pivoting to "Developer Gateway"; free tier increasingly squeezed | +| localtunnel | Free | Custom subdomain attempt, often taken | No | npm package exists | Frequent outages, "open-source bitrot" | +| bore | Free, self-hostable | If you host the server | No (for public server) | Rust binary, no Node SDK | Healthy but TCP-only, no HTTPS terminating | +| tunnelmole | Free | Random subdomain | No | npm | Niche, smaller community | +| Pinggy | Free, requires nothing installed (SSH) | Paid only | No | No real SDK | Nice DX, but SSH-only is fragile inside a `dev` script | + +**Recommendation: bundle the [`cloudflared` npm package by JacobLinCool](https://github.com/JacobLinCool/node-cloudflared) as the default tunnel.** It auto-installs the binary on first run (no Homebrew/Cargo step), exposes a typed `bin`/`tunnel` API, and Cloudflare's free Quick Tunnels have no caps and no account. This is exactly the choice Shopify CLI made as their default, and it's the one tunnel where webhooks (server-to-server) and iframe loads (browser-to-tunnel) both work reliably for free. + +Allow opt-out via env: `TOLGEE_DEV_TUNNEL=ngrok|cloudflared|none` and a `--tunnel-url` override (mirroring `shopify app dev`) so power users can plug in a Named Tunnel with a static domain for stable webhook URLs. + +Avoid `untun` as the primary; it's still 0.1.x and just wraps Quick Tunnels with less control than `cloudflared`. Keep `@ngrok/ngrok` in mind only if Tolgee later wants observability features (request replay) — its SDK is excellent but the free tier is hostile. + +## 2. Deployment target + +What plugins need: a small Node HTTP server (webhooks, decorators, optional plugin REST API) + static iframe bundle, ability to hold a tiny bit of state (per-install settings, API tokens), and a `npm run deploy` that does not require a credit card to demo. + +| Platform | Free | Cold start | Stateful | CLI deploy | Docker | DB add-ons | +|---|---|---|---|---|---|---| +| **Fly.io** | $5 trial only (free tier gone in 2024) | 300ms–2s, scale-to-zero | Yes (Machines + Volumes) | `flyctl deploy`, generates `fly.toml` | First-class | Postgres, LiteFS, Upstash | +| **Render** | Free web service (sleeps after 15min, 30–60s cold start), free PG dies after 30 days | Bad on free | Yes | `render` CLI + `render.yaml` blueprints | Yes | Managed PG, Redis | +| Railway | $5 one-time trial, then $5/mo | Always-on (no scale-to-zero) | Yes | `railway up` | Yes | PG, Redis, Mongo | +| **Cloudflare Workers + Pages** | Generous (100k req/day free), Durable Objects + D1 free tiers | <5ms | Yes via Durable Objects | `wrangler deploy` | No (V8 isolates) | D1 (SQLite), KV, R2, DO | +| Vercel | Free hobby, paid starts $20/seat | Edge ~ms; Node fn ~100ms | No native state | `vercel deploy` | No | Partner integrations | +| Deno Deploy | Free tier | Edge ~ms | Deno KV | `deployctl` | No | KV | +| Koyeb | 1 free nano service + $5.50 credit/mo | ~1s | Yes | `koyeb` CLI | Yes | PG (paid) | +| Heroku | No free tier | n/a | Yes | `git push heroku` | Yes (via buildpacks) | PG, Redis | + +**Recommend two defaults, picked by an interactive wizard question:** + +1. **Cloudflare Workers + Pages** as the "small/serverless" default. Pricing matches a plugin's traffic shape (mostly idle, spiky on webhooks), Durable Objects + D1 give real state for free, JWT verification via [`jose`](https://github.com/panva/jose) works natively in Workers, and `wrangler` deploy is a single command. The generator drops `wrangler.toml`, a `worker/` entry, a `pages/` directory for iframe assets, and a `.dev.vars.example`. +2. **Fly.io** as the "real Node app" default for plugins that need long-lived processes, native modules (Playwright, sharp, headless Chrome for translation screenshots), or Postgres. The generator drops `fly.toml` + a minimal `Dockerfile` (Node 22 alpine, multistage with static asset copy) and a `flyctl launch --copy-config --no-deploy` hook. + +Render is a tempting third because its free tier still exists and is friendlier than Fly to first-timers, but the 30–60s cold start makes webhooks from Tolgee miss their first delivery — bad first-run experience. Mention it in docs but don't generate config for it by default. + +Skip Heroku entirely; the user mentioned it but it's actively user-hostile (no free tier, slow dyno boots, expensive add-ons) and nothing it does is better than Fly. + +## 3. How comparable plugin systems scaffold + +**Crowdin Apps** (the closest analog). No `create-crowdin-app` CLI exists — they hand users a `git clone` of a Next.js starter. The starter does the right things: JWT verification middleware using `jose`, dynamic `manifest.json` route, OAuth credentials in `.env`, deploy target is Vercel. There's no tunnel. **Lesson: their model is good, but the absence of a CLI is the obvious gap Tolgee can fill, and the absence of a tunnel is the obvious DX win.** + +**Figma — `create-figma-plugin`** (yuanqing). esbuild-powered, sub-second rebuilds via a `watch` script, prompts for a template (vanilla, Preact, Preact+Tailwind, React, FigJam widget). Configuration lives under a `figma-plugin` key in `package.json`. No tunnel (Figma plugins run inside the editor, not over HTTP). **Lesson: fast iteration via esbuild + a tight template menu beats a giant kitchen-sink starter.** + +**Slack CLI** (`slack create`, v4.0 shipped April 2026). Templates split into "Starter app", "Automation", "AI agent". `manifest.json` is canonical, and `slack run` registers the app to a chosen workspace. Auto-upgrades itself on dev machines but never in CI or project-local installs. **Lesson: the template categories matter (boring vs AI vs automation), and the CLI must never auto-upgrade in CI.** + +**Atlassian Forge** (`forge tunnel`). The tunnel is built in and uses Cloudflare under the hood — confirms our Cloudflare choice. But Forge has a sharp edge: **manifest changes are not picked up by the tunnel; you must re-run `forge deploy`**. Code changes auto-rebundle; manifest changes don't. **Lesson to avoid: Tolgee's `npm run dev` should watch `manifest.json` (or its source) and re-POST it to Tolgee on change, so the author never has to "re-install" the app while iterating.** + +**Shopify CLI** (`app dev`, v4.0 May 2026). Defaults to a Cloudflare Quick Tunnel; `automatically_update_urls_on_dev = true` rewrites the partner dashboard URLs on every dev run. `--tunnel-url` accepts a custom tunnel. **Lesson: lift this exact pattern. The dev command should re-register the manifest URL with Tolgee on every boot.** + +**GitHub Apps / Probot** (`create-probot-app`). Templates `basic-js`, `basic-ts`, `checks-js`, `git-data-js`, `deploy-js`. Webhook payload typing is the big win — every event is fully typed. No tunnel; users wire up smee.io manually. **Lesson: ship typed webhook payloads, don't make users define event shapes themselves.** + +Patterns to lift: Shopify's auto-URL-rewrite, Forge's bundled tunnel, Probot's typed event payloads, Figma's esbuild watch loop, Slack's category-split template prompt, Crowdin's `jose`-based JWT middleware. + +## 4. Recommended `create-tolgee-app` feature list + +### Interactive wizard (use [`@clack/prompts`](https://www.npmjs.com/package/@clack/prompts), not Inquirer — modern, 4KB, TS-native, what Astro/Nuxt/Svelte's CLIs use in 2026) + +1. **App name + identifier** (kebab-case, used in manifest) +2. **Frontend framework**: `React + Vite` (default), `Next.js` (good for SSR'd iframe pages + dynamic manifest), `Vanilla TS` (smallest) +3. **Backend**: `Hono` (default — runs on Node, Workers, Bun, Deno; clean middleware story), `Express` (for users who want familiarity), `None` (frontend-only plugin) +4. **Modules to scaffold** (multiselect, prefills boilerplate file + manifest entry per choice): `project-dashboard-page`, `translation-tools-panel`, `key-edit-tab`, `key-action`, `translation-action`, `modal`, `bulk-action`, `translations-toolbar-action`, `project-menu-action`, `shortcut` +5. **Webhook events** (multiselect — drives both manifest entries and pre-typed handler stubs): translation.created, translation.updated, key.created, etc. +6. **Deploy target**: `Cloudflare Workers/Pages` (default), `Fly.io`, `None / I'll figure it out` +7. **Tolgee target**: `app.tolgee.io` (default), `Self-hosted` (asks for URL — stored as `TOLGEE_URL` in `.env`) +8. **Package manager**: detect from `npm_config_user_agent`, only ask if ambiguous +9. **Git init + first commit**: yes/no +10. **Install deps now**: yes/no + +### Files generated + +- `manifest.ts` — manifest authored in TypeScript with a [Zod](https://github.com/colinhacks/zod) schema imported from `@tolgee/apps-sdk`. `npm run build:manifest` emits `dist/manifest.json` and `tsc`-checks the shape. **This is a big DX win** — Crowdin and Slack make you author raw JSON; Zod-typed authoring catches typos before install. +- `src/modules//` — one folder per selected module with an iframe entry React component prewired with the `@tolgee/apps-sdk` postMessage handshake and JWT context. +- `src/server/` — Hono app with prewired routes: `GET /manifest.json` (dynamic), `POST /webhooks` (signature-verified), `POST /decorators` (CORS preflight handled), per-module `GET /` (iframe HTML). +- `src/server/middleware/verifyTolgeeJwt.ts` — calls `createRemoteJWKSet(new URL(TOLGEE_URL + '/.well-known/jwks.json'))` from [`jose`](https://github.com/panva/jose), validates `iss`, `aud`, `exp`, `nbf`. Cached + auto-refreshing. +- `src/server/middleware/verifyWebhookSignature.ts` — HMAC-SHA256 over raw body, `crypto.timingSafeEqual` comparison. Assumes Tolgee sends a `X-Tolgee-Signature` header; coordinate the exact header name with the Tolgee backend team. +- `src/shared/events.ts` — typed webhook event union (`type WebhookEvent = TranslationCreated | KeyCreated | …`), generated from the same Zod schemas the Tolgee backend uses (publish from `@tolgee/apps-sdk`). +- `.env.example` — `TOLGEE_URL`, `TOLGEE_APP_ID`, `TOLGEE_APP_SECRET`, `WEBHOOK_SIGNING_SECRET`, `PORT`. +- `vite.config.ts` or equivalent — esbuild-fast, with HMR for iframe content. +- Deploy config: `wrangler.toml` **or** `fly.toml` + `Dockerfile` based on wizard answer. Both include a sample `deploy` script in `package.json`. +- `.github/workflows/deploy.yml` — push-to-main deploys to the chosen target. (Skip if user said "None".) +- `tolgee-app.config.ts` — the dev server's config: which manifest source to watch, dev port, tunnel preference. +- `README.md` with a 5-line quickstart, and explicit "what's a webhook signature" / "what's the JWT for" sections. +- `.gitignore`, `tsconfig.json`, `eslint.config.js` (flat config, modern), `.prettierrc`. + +### `npm run dev` + +A single orchestrator that: + +1. Loads `.env`, validates required vars with Zod (fails loudly with a friendly message if `TOLGEE_APP_ID` is missing). +2. Starts the local server (Hono on `localhost:3000` by default). +3. Starts Vite/esbuild watch for iframe assets, with HMR. +4. Starts a Cloudflare Quick Tunnel via the `cloudflared` npm package; captures the assigned `https://.trycloudflare.com` URL. +5. Watches `manifest.ts` → recomputes manifest JSON → if the app is registered, POSTs the new tunnel URL + manifest to Tolgee's app registration endpoint with the developer's PAT. **This is the Shopify-style auto-update; Forge's mistake was not doing it.** +6. On first run, if the app isn't registered yet, opens the Tolgee install URL in the browser (`open` package or platform fallback), preloaded with the manifest URL and a deeplink back to the project. +7. Streams structured logs of incoming requests (path, JWT subject, latency) — small in-terminal request log à la ngrok inspector but free. +8. On Ctrl-C, tears down the tunnel, optionally unregisters dev URLs from Tolgee. + +### `npm run deploy` + +Per chosen target: + +- **Cloudflare**: `wrangler deploy` for the worker + `wrangler pages deploy ./dist/ui` for static assets. Reads secrets from `.env` and uploads with `wrangler secret put` on first deploy. Prints the resulting URL and offers to update the production manifest URL in Tolgee. +- **Fly**: `flyctl deploy`. On first run, runs `flyctl launch --copy-config --no-deploy` to register the app, then `flyctl secrets set` from `.env`, then deploys. Same "update prod manifest URL in Tolgee?" prompt at the end. +- **None**: builds to `dist/` and exits with a printed checklist of what env vars to wire up wherever they're going. + +### Security boilerplate the plugin author should NEVER write + +- **Tolgee JWT verification** against Tolgee's JWKS (`/.well-known/jwks.json`) using `jose.createRemoteJWKSet` with caching. Validate `iss === TOLGEE_URL`, `aud === appId`, `exp`, `nbf`. Apply to every iframe page route and every decorator endpoint. +- **Webhook signature verification** (HMAC-SHA256, raw body, `timingSafeEqual`). The Hono middleware reads raw body before any JSON parser sees it — this is the single most common webhook bug across providers. +- **CORS preflight** for decorator endpoints, allowing only `https://app.tolgee.io` (or `TOLGEE_URL`) origins by default with `Access-Control-Allow-Credentials: true` for the JWT. +- **CSP / iframe** headers on iframe routes: `Content-Security-Policy: frame-ancestors `. This is easy to forget and locks the iframe to only Tolgee. +- **Replay protection** on webhooks: 5-minute window using the JWT `iat` or a webhook `timestamp` header; reject older. + +### Bonus things plugin authors will love but won't ask for + +- **Typed webhook handlers**: `app.onWebhook('translation.created', (payload) => …)` where `payload` is inferred from a shared Zod schema. Probot-grade DX. +- **Manifest validation as a pre-commit hook** via `lefthook` or `simple-git-hooks` — `tolgee-app validate-manifest` fails the commit if a referenced module entry doesn't exist on disk, an `events` array contains a non-existent event name, or a URL is non-HTTPS. +- **`npx tolgee-app doctor`** — checks env vars, Tolgee reachability, tunnel health, JWKS fetchability, common misconfigurations. Borrowed from `next info` / `tailwindcss doctor`. +- **Module generator post-init**: `npx tolgee-app add-module bulk-action` adds a new module to manifest + scaffolds the file. Saves users from re-running the wizard. +- **Local mock Tolgee** for offline dev: a tiny fixture server that simulates webhook deliveries and JWT issuance, so tests don't need a real Tolgee. Forge's `forge tunnel --debug` does something similar and developers love it. +- **Built-in `e2e/` Playwright smoke test** that spins up the dev server, opens an iframe page in a fake-Tolgee shell, and asserts the postMessage handshake succeeds. New plugin authors break the handshake constantly; catching it in CI saves hours. +- **`tolgee-app logs --prod`** to tail logs from the chosen deploy target without learning each provider's CLI. + +## 5. Hard problems to flag for follow-up + +These are blockers that need a Tolgee-side decision before `create-tolgee-app` can ship: + +1. **Manifest registration UX.** Shopify and Forge both have a "partner dashboard"; Tolgee will need one too, and the protocol for "here's my new dev manifest URL, swap it in" needs to be a real Tolgee API. Without it, step 5 of `npm run dev` is a manual copy-paste — exactly the friction this CLI is trying to remove. Coordinate this with the Tolgee backend team before shipping. +2. **JWT issuance contract.** What exactly is in the JWT (subject, aud, scopes, project id, user id, install id)? Is there a separate token for the iframe context vs the decorator POST? Lock this down and publish the spec inside `@tolgee/apps-sdk` so the verifier middleware can be a real type, not a `Record`. +3. **Webhook signature scheme.** Is it HMAC-SHA256 over raw body (Stripe/Shopify style) or a signed JWT (GitHub-style)? Either is fine, but pick before writing the verifier or you'll bake the wrong assumption into hundreds of generated projects. +4. **State for installs.** Plugins will need to persist "user X installed me in project Y with these settings" somewhere. The CLI scaffolds the table, but the schema (and whether Tolgee provides install IDs at all) is a Tolgee-side design question. +5. **Multi-tenancy on a single deploy.** A plugin author probably wants one Fly app serving every customer install. The manifest needs to be the same JSON for all of them, but per-install state needs an install ID in every JWT. This is again a Tolgee-side contract, not something the CLI can paper over. + +## Sources + +- [ngrok Alternatives 2026 — fxTunnel](https://www.fxtun.dev/blog/free-ngrok-alternatives-2026/) +- [Top 10 Ngrok alternatives in 2026 — Pinggy](https://pinggy.io/blog/best_ngrok_alternatives/) +- [awesome-tunneling on GitHub](https://github.com/anderspitman/awesome-tunneling) +- [Cloudflare Quick Tunnels docs](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) +- [`cloudflared` npm package (JacobLinCool)](https://github.com/JacobLinCool/node-cloudflared) +- [`untun` (unjs)](https://github.com/unjs/untun) +- [`@ngrok/ngrok` JavaScript SDK](https://www.npmjs.com/package/@ngrok/ngrok) +- [Heroku's Dead — Railway vs Render vs Fly.io 2026](https://techsy.io/en/blog/railway-vs-render-vs-fly-io) +- [Fly.io Pricing 2026 — Kuberns](https://kuberns.com/blogs/flyio-pricing/) +- [Render Free Tier docs](https://render.com/docs/free) +- [Koyeb Pricing FAQ](https://www.koyeb.com/docs/faqs/pricing) +- [Cloudflare Workers vs Vercel vs Deno Deploy 2026 — ProPicked](https://propicked.com/blog/cloudflare-workers-vs-vercel-vs-deno-deploy-2026-edge-comparison) +- [Crowdin Apps Quick Start](https://support.crowdin.com/developer/crowdin-apps-quick-start/) +- [Crowdin App Descriptor](https://support.crowdin.com/developer/crowdin-apps-app-descriptor/) +- [Forge tunneling docs](https://developer.atlassian.com/platform/forge/tunneling/) +- [Forge CLI reference](https://developer.atlassian.com/platform/forge/cli-reference/) +- [Shopify CLI 4.0 release notes (May 2026)](https://no7software.co.uk/blog/shopify-cli-4-engineering-migration-2026) +- [Shopify `app dev` docs](https://shopify.dev/docs/api/shopify-cli/app/app-dev) +- [Slack CLI v4.0.0 release notes (April 2026)](https://docs.slack.dev/changelog/2026/04/10/slack-cli/) +- [Create Figma Plugin docs](https://yuanqing.github.io/create-figma-plugin/quick-start/) +- [Probot development docs](https://probot.github.io/docs/development/) +- [`jose` JWT/JWKS library](https://github.com/panva/jose) +- [Webhook Signature Verification 2026 — HookRay](https://hookray.com/blog/webhook-signature-verification-2026) +- [`@clack/prompts` vs Inquirer vs Ink 2026 — PkgPulse](https://www.pkgpulse.com/guides/ink-vs-clack-vs-enquirer-interactive-cli-nodejs-2026) +- [Zod](https://github.com/colinhacks/zod) diff --git a/docs/dev-notes/TOLGEE_APPS_ARCHITECTURE.md b/docs/dev-notes/TOLGEE_APPS_ARCHITECTURE.md new file mode 100644 index 00000000000..8e48bf87077 --- /dev/null +++ b/docs/dev-notes/TOLGEE_APPS_ARCHITECTURE.md @@ -0,0 +1,493 @@ +# Tolgee Apps — Architecture Notes + +This document describes the architecture of the **Tolgee Apps** plugin system. It is intended as a reference for anyone working on the apps subsystem (manifest handling, iframe rendering, SDK, signed tokens, webhooks) or building a plugin against it. + +Status: PoC design. Subject to revision as scopes are built. The PoC covers four extension points — **project dashboard page**, **translation tools panel**, **key-edit dialog tab**, and **action decorators** (key + translation row icons) — plus signed-token REST API access and webhooks. Inline annotations, dev CLI and ZIP delivery are planned for later scopes. + +--- + +## 1. Overview + +### 1.1 What this is + +Tolgee Apps is a plugin system that lets third-party developers extend Tolgee with custom UI, backend behavior, and event reactions. The model borrows the manifest + module pattern from **Crowdin Apps** and the cryptographic auth model from **Atlassian Connect**. + +For the PoC, plugins can contribute two kinds of UI: + +- **Project dashboard pages** — full-area pages mounted in the project's left navigation. Each entry gets its own route and a sandboxed iframe. +- **Translation tools panels** — collapsible panels inside the existing right-hand tools panel of the translations view. Each entry shows alongside Comments / History / Machine Translation / Translation Memory and receives the currently selected key/language/translation as context. + +In both cases the project/user/org identity is delivered to the iframe through a signed JWT and the plugin communicates with the Tolgee parent page via `postMessage`. + +### 1.2 Core model in one paragraph + +A plugin is a **web service hosted by its author**, described by a JSON **manifest**. An organization admin registers the plugin against their organization by giving Tolgee the manifest URL. Project admins then enable the registered plugin per-project. When a user navigates to an enabled plugin's page, Tolgee renders an iframe pointing at the plugin's `entry` URL and passes a short-lived **JWT signed by Tolgee** carrying the install / org / project / user identity. The iframe communicates with the Tolgee parent page via `postMessage`. The same JWT is also the credential the plugin uses to call Tolgee's REST API and (when present) the plugin's own backend. + +### 1.3 What is and is not in the PoC + +| Capability | PoC | Later scope | +| --- | --- | --- | +| Register plugin against organization | ✓ | | +| Enable plugin per project | ✓ | | +| `project-dashboard-page` module → sidebar entry + iframe | ✓ | | +| `translation-tools-panel` module → tools-panel tab + iframe | ✓ | | +| `key-edit-tab` module → key-edit dialog tab + iframe | ✓ | | +| `key-action` / `translation-action` icons on row + cell | ✓ | | +| `modal` module + 5 trigger surfaces (bulk, toolbar, sidebar, key-edit footer, keyboard shortcut) | ✓ | | +| Signed context token delivered to iframe | ✓ | | +| `@tolgee/apps-sdk` exposing `getContext()` | ✓ | | +| Plugin → Tolgee REST API auth (token-based) | | ✓ | +| Permission scopes + install-time consent | | ✓ | +| Webhook subscriptions | | ✓ | +| Editor extensions, inline translation annotations | | ✓ | +| Local dev CLI + tunnel | | ✓ | +| ZIP-based plugin delivery (Tolgee-served bundles) | | ✓ | + +### 1.4 What we deliberately did *not* copy from elsewhere + +- **GitHub Apps' "no embedded UI" rule** — Tolgee apps render UI inside the product, GitHub apps do not. +- **Figma's QuickJS-WASM sandbox** — far too much infra for a localization tool. We rely on browser-enforced iframe origin isolation instead. +- **Crowdin's mandatory hosted backend even for static plugins** — the ZIP delivery path (later scope) will let UI-only plugins skip hosting entirely. + +--- + +## 2. End-to-end flow + +### 2.1 Plugin author publishes a plugin + +The plugin author hosts: + +- A `manifest.json` at a stable URL (e.g. `https://my-plugin.example.com/manifest.json`) +- Whatever assets each module's `entry` URL resolves to (HTML, JS, CSS) +- Optionally: a plugin backend at any URL of their choosing (for stateful plugins; not required for pure UI plugins) + +### 2.2 Org admin registers the plugin + +In the org settings UI: + +1. Admin pastes the manifest URL. +2. Tolgee fetches the manifest, validates it, and stores a snapshot keyed by `pluginId` and the registering `orgId`. The result is a record we call an **install**, with its own `installId`. +3. Admin sees the parsed manifest (name, version, declared modules) and confirms registration. + +The manifest is re-fetched only on explicit admin action ("refresh"). Tolgee does not periodically poll plugin URLs. + +### 2.3 Project admin enables the plugin per project + +In project settings: + +1. Project admin sees the list of plugins registered in the organization. +2. Admin toggles a plugin on for this project; enablement state is persisted per project. +3. Disabling removes the plugin's presence everywhere in that project. + +### 2.4 User navigates to the plugin's page + +Per enabled plugin, each `project-dashboard-page` module in the manifest appears as an entry in the project sidebar (using `title` and `icon` from the manifest). + +When the user clicks the entry: + +1. Tolgee routes to a URL of the shape `app.tolgee.io/projects//plugins//`. The slug after `/plugins//` is derived from the module's `key`. +2. Tolgee mints a JWT (see §3.2) signed with its private key. +3. Tolgee renders a sandboxed iframe whose `src` is the plugin's `baseUrl` + `entry`, with the JWT passed in the URL. +4. The iframe loads the plugin's bundle; the SDK reads the JWT and exposes context via `tolgee.getContext()`. + +### 2.4a User opens the translations view (translation-tools-panel) + +For each enabled plugin that declares `translation-tools-panel` modules, every entry contributes one collapsible panel inside the existing right-hand tools panel — alongside Comments, History, MT, TM, etc. The panel header uses the manifest `title` and `icon`. + +1. The translations view fetches the project's enabled apps via `/v2/projects/{projectId}/apps` and merges `translation-tools-panel` modules into the panel list. +2. When the user expands the app panel, Tolgee mounts a sandboxed iframe whose `src` is `${baseUrl}${entry}`. +3. Token minting is identical to dashboard pages — one token per install, signed by Tolgee. +4. The current selection (keyId, languageId, languageTag, translationId) is delivered to the iframe in the `tolgee-app:init` payload (see §3.2 and §3.4). +5. When the user moves between keys or translation cells, Tolgee posts `tolgee-app:selection-changed` to the iframe so it can re-render against the new context. +6. When the iframe knows its content height it posts `tolgee-app:resize` and Tolgee sizes the iframe element accordingly. + +### 2.4b User clicks an action decorator + +`key-action` and `translation-action` modules surface as small icon buttons next to the existing native action icons. The icon is a **navigation shortcut** — the plugin's real UI lives in a side-panel module or a key-edit dialog tab the plugin also declares. + +1. On rendering the translations view, the webapp fetches enabled apps via `/v2/projects/{projectId}/apps` and reads their `decoratorsUrl`. For each app with at least one **dynamic** action, the webapp `POST`s to `decoratorsUrl` with the visible window of `keyIds` + `languageTags`. The plugin replies with a list of `{keyId, languageTag?, actionKey, url?}` items. Static (non-dynamic) actions show on every row without any plugin call. +2. The icon row is composed from: static manifest actions ∪ dynamic plugin replies, in `(install registration order, manifest declaration order)`. +3. When the user clicks: + - `link` action: `window.open(url, '_blank')`. The URL is either the dynamic-returned `url` or the manifest `urlTemplate` with `{keyName}` / `{keyId}` / etc. substituted. + - `panel` action (translation-row): the webapp focuses the cell, opens the side panel, expands the referenced `translation-tools-panel` module, and scrolls it into view. + - `tab` action (key-row): the webapp opens the key-edit dialog with the referenced `key-edit-tab` selected. Opening is allowed even without `keys.edit`: in that case the dialog renders only plugin tabs and hides the Save action. + +The decorators endpoint is called **directly by the webapp**, not proxied through Tolgee's backend. The webapp authenticates with the user-context JWT it would have minted for the iframe (`POST /v2/projects/{id}/apps/{installId}/token`). The plugin must set CORS to allow the webapp origin. + +### 2.5 Plugin calls Tolgee's REST API (later scope) + +```ts +fetch(`${tolgeeApiBase}/v2/projects/${ctx.projectId}/translations`, { + headers: { Authorization: `Bearer ${ctx.token}` }, +}) +``` + +Tolgee validates its own signature on the JWT, enforces the install's granted scopes plus the user's project role, and executes the call. + +### 2.6 Plugin calls its own backend (later scope) + +```ts +fetch('https://my-plugin.example.com/api/save', { + headers: { Authorization: `Bearer ${ctx.token}` }, +}) +``` + +The plugin backend verifies the JWT against Tolgee's published public key (JWKS endpoint) and trusts the claims. No separate login system on the plugin side. + +--- + +## 3. Reference + +### 3.1 Manifest schema (PoC) + +The `icon` field on every module accepts either an emoji (e.g. `"⚙️"`) or a Tolgee native icon name from `@untitled-ui/icons-react` (e.g. `"Settings01"`) or `tg.component/CustomIcons` (e.g. `"Stars"`). Auto-detection: if the string matches a registered icon component, it renders as the native React icon; otherwise it renders as literal text (the emoji path). Unknown names fall back to literal text — choose names from the `@untitled-ui/icons-react` exports to get the native look. + +```json +{ + "id": "tolgee-dev-plugin", + "name": "Tolgee Dev Plugin", + "version": "0.1.0", + "baseUrl": "https://my-plugin.example.com", + "decoratorsUrl": "https://my-plugin.example.com/decorators", + "modules": { + "project-dashboard-page": [ + { + "key": "hello", + "title": "Hello World", + "icon": "👋", + "entry": "/" + } + ], + "translation-tools-panel": [ + { + "key": "activity", + "title": "Activity", + "icon": "📈", + "entry": "/tools-panel" + } + ], + "key-edit-tab": [ + { + "key": "audit", + "title": "Audit", + "icon": "🛡️", + "entry": "/key-edit-tab/audit" + } + ], + "key-action": [ + { + "key": "view-source", + "type": "link", + "icon": "🔗", + "tooltip": "View source", + "urlTemplate": "https://example.com/source/{keyName}" + }, + { + "key": "open-audit", + "type": "tab", + "icon": "🛡️", + "tooltip": "Audit info", + "dynamic": true, + "tabKey": "audit" + } + ], + "translation-action": [ + { + "key": "show-activity", + "type": "panel", + "icon": "📈", + "tooltip": "Show activity", + "dynamic": true, + "panelKey": "activity" + } + ] + } +} +``` + +| Field | Type | Description | +| --- | --- | --- | +| `id` | string | Globally unique plugin identifier. | +| `name` | string | Human-readable name; shown in admin UI and sidebar. | +| `version` | string | Plugin version (semver recommended). | +| `baseUrl` | string | Origin where the plugin's assets are served. Module `entry` paths are resolved against this. Must be HTTPS in production. | +| `modules.project-dashboard-page[]` | array | Pages this plugin contributes to the project sidebar. | +| `modules.project-dashboard-page[].key` | string | Unique within the plugin; used as the URL slug under Tolgee's namespace. | +| `modules.project-dashboard-page[].title` | string | Sidebar entry title. | +| `modules.project-dashboard-page[].icon` | string | Sidebar entry icon (emoji or native icon name — see §3.1). | +| `modules.project-dashboard-page[].entry` | string | Path relative to `baseUrl`; the iframe will load this URL. | +| `modules.translation-tools-panel[]` | array | Panels this plugin contributes to the translations view's right-hand tools panel. | +| `modules.translation-tools-panel[].key` | string | Unique within the plugin; used as part of the panel id. | +| `modules.translation-tools-panel[].title` | string | Panel header title. | +| `modules.translation-tools-panel[].icon` | string | Panel header icon (emoji or native icon name — see §3.1). | +| `modules.translation-tools-panel[].entry` | string | Path relative to `baseUrl`; the iframe will load this URL when the panel is expanded. | +| `decoratorsUrl` | string | Absolute URL the webapp `POST`s to for dynamic action decorators. Plugin must serve it with CORS headers permitting the Tolgee origin. | +| `modules.key-edit-tab[]` | array | Tabs this plugin contributes to the key-edit dialog. | +| `modules.key-edit-tab[].key` | string | Unique within the plugin; referenced by `key-action[].tabKey`. | +| `modules.key-edit-tab[].title` | string | Tab label. | +| `modules.key-edit-tab[].icon` | string | Tab icon. | +| `modules.key-edit-tab[].entry` | string | Path relative to `baseUrl`; the iframe will load this URL. | +| `modules.key-action[]` | array | Icon-button actions added to the key row. | +| `modules.key-action[].key` | string | Unique within the plugin; matches `actionKey` in dynamic responses. | +| `modules.key-action[].type` | string | `link` or `tab`. | +| `modules.key-action[].icon` / `.tooltip` | string | Rendered on the button. | +| `modules.key-action[].dynamic` | boolean | When `true`, icon only shows for keys the plugin returns in its decorators response. | +| `modules.key-action[].urlTemplate` | string | Required for static `link`. Interpolated tokens: `{keyName}`, `{keyNamespace}`, `{keyId}`, `{projectId}`. | +| `modules.key-action[].tabKey` | string | For `type: tab`, references a declared `key-edit-tab[].key`. | +| `modules.key-action[].visibility` | enum | `always` or `on-hover`. Default `on-hover`. May be overridden per-item via the dynamic decorators response. | +| `modules.translation-action[]` | array | Icon-button actions added to translation cells. | +| `modules.translation-action[].type` | string | `link` or `panel`. | +| `modules.translation-action[].panelKey` | string | For `type: panel`, references a declared `translation-tools-panel[].key`. | +| `modules.translation-action[].urlTemplate` | string | For static `link`. Adds `{languageTag}`, `{languageId}`, `{translationId}` to the supported tokens. | +| `modules.translation-action[].visibility` | enum | `always` or `on-hover`. Default `on-hover`. May be overridden per-item via the dynamic decorators response. | +| `modules.modal[]` | array | Iframe modal definitions. Triggered by any `*-action` of type `modal` (and shortcuts). | +| `modules.modal[].key` | string | Unique within the plugin; referenced by `modalKey`. | +| `modules.modal[].title` | string | Dialog title shown in Tolgee's modal chrome. | +| `modules.modal[].entry` | string | Path relative to `baseUrl` loaded into the iframe. | +| `modules.modal[].width` / `.height` | int | Default modal size in px. Clamped to 90vw / 90vh at runtime. | +| `modules.bulk-action[]` | array | Items appended to the bulk-actions bar (visible when ≥1 keys selected). Each item has `key`, `title`, `icon?`, `type: link \| modal`, and either `urlTemplate` or `modalKey`. The modal iframe additionally receives `selectedKeyIds`. | +| `modules.translations-toolbar-action[]` | array | Items appended to the translations view toolbar (next to +KEY). Same shape as bulk-action. | +| `modules.project-menu-action[]` | array | Sidebar entries that don't navigate to a route — clicking opens a modal (or external link). Same shape. | +| `modules.shortcut[]` | array | Global keyboard shortcut registry. Each entry has `key`, `combination` (mousetrap-style, e.g. `Mod+Shift+E`), `type: link \| modal`, and either `urlTemplate` or `modalKey`. `Mod` resolves to ⌘ on macOS and Ctrl elsewhere. | + +Later scopes will extend `modules` with additional types (`editor-right-panel`, `translation-decorator`, `custom-mt`, `modal`, …) and add top-level fields (`backendBaseUrl`, optionally `assets` when ZIP delivery lands). + +### 3.2 Context token (JWT) schema + +```jsonc +{ + "iss": "tolgee.io", // issuer + "installId": "...", // org's installation of this plugin + "pluginId": "...", + "orgId": "...", + "projectId": "...", + "userId": "...", + "locale": "en", // user's UI locale + "scopes": [], // granted permission scopes (empty in PoC) + "exp": 1736000000 // short TTL (~1h planned) +} +``` + +Signed by Tolgee's private key. For the PoC the token is delivered as a query string parameter on the iframe URL. In a later scope it can be delivered via `postMessage` instead, to avoid the token leaking through `Referer` headers or browser history. + +Plugin backends verify the JWT using Tolgee's public key, published at a well-known JWKS endpoint (`/.well-known/jwks.json`). Tolgee verifies its own signature on incoming REST API calls. + +### 3.3 URL spaces + +Two URL spaces meet: + +| Field | Whose URL | Example | What it controls | +| --- | --- | --- | --- | +| Tolgee-side URL slug | Tolgee | `app.tolgee.io/projects/123/plugins/tolgee-dev-plugin/hello` | Where the plugin page mounts inside Tolgee — needed for deep links, browser history, multi-page disambiguation. Slug derived from `pluginId` + module `key`. | +| `entry` | Plugin | `${baseUrl}/` | What Tolgee fetches into the iframe. | + +For the PoC, the manifest only specifies `entry`. Tolgee derives its in-product slug from the plugin id + module key — no explicit `path` field is required. + +### 3.4 postMessage protocol + +The iframe and the Tolgee parent page communicate via `window.postMessage` only. Same-origin policy guarantees the iframe cannot read or write the parent's DOM, cookies, or storage directly. + +#### 3.4.1 Handshake + +1. On mount the iframe posts `{ type: 'tolgee-app:ready' }` to `window.parent`. +2. When Tolgee receives `ready` and has a token in hand, it replies with `tolgee-app:init` (payload below). + +The init payload is the same envelope for every module type; modules just ignore fields they don't care about. + +| Field | Type | Notes | +| --- | --- | --- | +| `type` | `'tolgee-app:init'` | | +| `token` | string | Signed JWT, see §3.2. | +| `apiUrl` | string | Tolgee API base URL the iframe should target. | +| `organizationId` | number | Always set. | +| `projectId` | number | Always set. | +| `keyId` | number \| null | `translation-tools-panel` only: the currently selected key, if any. | +| `languageId` | number \| null | `translation-tools-panel` only: the currently focused language, if any. | +| `languageTag` | string \| null | `translation-tools-panel` only: BCP 47 tag of the focused language. | +| `translationId` | number \| null | `translation-tools-panel` only: the focused translation cell, if any. | + +#### 3.4.2 Selection updates (translation-tools-panel) + +When the user moves between keys or translation cells while the tools-panel iframe is mounted, Tolgee posts: + +```jsonc +{ + "type": "tolgee-app:selection-changed", + "keyId": 42, + "languageId": 7, + "languageTag": "en", + "translationId": 1234 +} +``` + +The selection identifiers are not minted into the JWT — they would change too often. The token only carries the install / org / project / user / scopes claims. Per-key authorization is checked at REST-API call time against the user's project role. + +#### 3.4.3 Iframe sizing + +For modules that live inside a fixed-size container (currently `translation-tools-panel`), the iframe can request a height: + +```jsonc +{ "type": "tolgee-app:resize", "height": 240 } +``` + +Tolgee applies the value to the iframe element (clamped to a reasonable range). For full-area modules (`project-dashboard-page`) the iframe stretches and the host ignores resize messages. + +### 3.5 Decorator dynamic protocol + +For modules declared `dynamic: true`, the webapp queries the plugin for per-row data as the visible window of keys/translations changes. + +**Request** (webapp → plugin, frontend-direct): + +``` +POST {decoratorsUrl} +Authorization: Bearer +Content-Type: application/json +{ + "projectId": 42, + "keyIds": [1, 2, 3], + "languageTags": ["en", "de"] +} +``` + +The JWT is the same install-context token minted via `POST /v2/projects/{id}/apps/{installId}/token`. Plugin verifies it offline against Tolgee's published public key (PoC dev-plugins skip verification). + +**Response**: + +```jsonc +{ + "items": [ + { "keyId": 1, "actionKey": "open-audit" }, + { "keyId": 2, "languageTag": "de", "actionKey": "show-activity", "count": 3, "visibility": "always" }, + { "keyId": 3, "actionKey": "view-source", "url": "https://example.com/custom-3" } + ] +} +``` + +Rules: +- `actionKey` must match a `key-action` or `translation-action` `key`. Items the webapp can't resolve are dropped. +- For `link` actions, a returned `url` overrides the manifest `urlTemplate`. Missing `url` falls back to the template. +- For `panel`/`tab` actions, presence in the response is enough — no extra fields needed. +- Items without `languageTag` apply to the key row regardless of language. Items with `languageTag` apply only to that specific translation cell. +- **`count`** (integer, optional): when `> 0`, renders a small number badge over the icon (styled like the native unresolved-comments badge). `count === 0` hides the badge but keeps the icon; absent/null is treated the same. Valid for any action type and for both static and dynamic actions. +- **`visibility`** (`always` \| `on-hover`, optional): overrides the manifest's `visibility` for this specific (keyId, [languageTag]) instance. Precedence: dynamic-response `visibility` > manifest `visibility` > default `on-hover`. Lets a plugin keep an icon hover-only by default but elevate it to always-visible for items that need attention. + +**CORS**: the plugin's `decoratorsUrl` must include `Access-Control-Allow-Origin` matching the Tolgee webapp's origin (or `*` for dev), and must allow the `Authorization` header. + +**Caching**: the webapp caches responses per `(installId, sorted keyIds, sorted languageTags)` for ~30 seconds. The plugin can rely on webhooks (`SET_TRANSLATIONS`, `CREATE_KEY`, etc. — see below) to know when its data changes; a future iteration will let plugins push cache invalidations to Tolgee. + +### 3.6 Webhook event names + +A manifest's `webhooks.events` array may contain **any value matching a Tolgee `ActivityType` enum name**, e.g. `SET_TRANSLATIONS`, `CREATE_KEY`, `BATCH_KEY_RESTORE`, `GLOSSARY_TERM_CREATE`, `TASK_FINISH`, …. The full list of typed payload shapes is generated into `@tginternal/client` as the `webhooks` interface (one entry per activity type); the dev-plugin's `onWebhook(payload, type, handler)` utility narrows by `payload.activityData?.type` to give a fully-typed payload inside the handler. + +Manifest validation rejects any event name that doesn't match an `ActivityType`. Unknown names return `app_manifest_invalid`. + +For batch events (e.g. `BATCH_*` activities) the webhook payload doesn't inline every modified entity — it only carries the `revisionId`. To retrieve the actual modifications, pass that id to the [Get modified entities by revision](/api/get-modified-entities-by-revision) endpoint. The response is paginated and supports filtering by entity class (e.g. `Translation`, `Key`). Summary information about the revision itself is available via [Get single revision](/api/get-single-revision). + +**Future** (`{branchId}`, `{branchName}`) — added when Tolgee gains branch support. + +#### 3.4.4 SDK surface (later scope) + +The SDK will wrap the raw API to provide an async request/response style: + +```ts +const ctx = await tolgee.getContext() +``` + +Under the hood: + +1. SDK generates a UUID correlation id. +2. Sends `{ type, id, params }` via `window.parent.postMessage(..., TOLGEE_ORIGIN)`. +3. Listens for a reply with matching `replyTo: id`. +4. Resolves the Promise with the reply's `result`. + +The Tolgee webapp runs a dispatcher that routes `type` to a handler (`getContext`, `refreshToken`, `apiCall`, …) and posts the result back. + +**Security rules — applied on both sides:** + +- When Tolgee posts to the iframe: `targetOrigin` must be the plugin's exact origin. Never `*`. +- When the iframe receives a message: `event.origin` must match the Tolgee origin. Reject otherwise. +- The SDK enforces both. Plugin authors never see the raw API. + +Hand-rolling the correlation-id plumbing is fine for the PoC. For later polish, [`comlink`](https://github.com/GoogleChromeLabs/comlink) and [`penpal`](https://github.com/Aaronius/penpal) wrap postMessage into proxy-feeling RPC APIs and are worth evaluating before scaling the SDK surface. + +### 3.7 Modal triggers + +A `modal` module defines the iframe content; the trigger module determines **where** in the Tolgee UI it can be opened from. Each trigger entry has `type: link | modal`; modal types reference a declared modal by `modalKey`. Five trigger anchors are wired today: + +| Trigger module | UI anchor | Extra `tolgee-app:init` payload | +| --- | --- | --- | +| `bulk-action` | Bulk-actions bar (visible when ≥1 keys selected). | `selectedKeyIds: number[]` | +| `translations-toolbar-action` | Top of the translations view, next to the +KEY button. | (project context only) | +| `project-menu-action` | Project sidebar — sits alongside `project-dashboard-page` entries but opens a modal instead of routing. | (project context only) | +| `shortcut` | Global keyboard shortcut bound in the translations view. | The currently-focused selection if any, else project context only. | + +#### Modal lifecycle + +- The webapp owns the dialog chrome (title bar + close ×). Backdrop click and ESC close it. +- The plugin can post `{ type: 'tolgee-app:close' }` from inside its iframe to close itself programmatically. +- A new `open()` call replaces the currently-open modal (single-slot policy). + +#### Shortcut combination grammar + +`combination` is a mousetrap-style string of `+`-separated tokens, last one being the key: + +| Token | Meaning | +| --- | --- | +| `Mod` | ⌘ on macOS, Ctrl elsewhere | +| `Ctrl` / `Meta` / `Shift` / `Alt` | explicit modifiers | +| (any single character) | the trigger key — matched case-insensitively against `event.key` | + +Examples: `Mod+Shift+E`, `Ctrl+K`, `Shift+/`. Last-registered shortcut wins on collisions; no conflict warning. + +### 3.5 Iframe sandboxing + +The iframe must run in an origin distinct from Tolgee's webapp so the browser blocks DOM/cookie/storage access. Two viable approaches: + +| Approach | How | Trade-off | +| --- | --- | --- | +| **Per-plugin subdomain** | Serve / proxy plugin content under `plugin-.plugins.tolgee.io` | Cleanest origin isolation; requires wildcard DNS + cert; needed if plugins ever want their own cookies/localStorage | +| **`