initial relay scaffold
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// Operator-facing knob for tier-quota tuning without a code change or
|
||||
// redeploy. The schema is { core: TierConfig, pro: TierConfig,
|
||||
// max: TierConfig } where TierConfig is
|
||||
// { lifetime: number|null, monthly: number|null, geminiCapMonthly: number|null }
|
||||
// null means "no cap on this dimension." The relay reads this on every
|
||||
// request via configFile's live-reload.
|
||||
const inputSpec = InputSpec.of({
|
||||
// Core tier knobs.
|
||||
core_lifetime: Value.number({
|
||||
name: 'Core — Lifetime Credits',
|
||||
description:
|
||||
'Total credits a Core (unlicensed) install can ever spend. Default 5.',
|
||||
required: true,
|
||||
default: 5,
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
integer: true,
|
||||
step: 1,
|
||||
units: 'credits',
|
||||
placeholder: null,
|
||||
}),
|
||||
// Pro tier knobs.
|
||||
pro_monthly: Value.number({
|
||||
name: 'Pro — Monthly Credits',
|
||||
description:
|
||||
'Total credits a Pro user gets each calendar month. Resets on the 1st. Default 50.',
|
||||
required: true,
|
||||
default: 50,
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
integer: true,
|
||||
step: 1,
|
||||
units: 'credits',
|
||||
placeholder: null,
|
||||
}),
|
||||
pro_gemini_cap: Value.number({
|
||||
name: 'Pro — Gemini Cap (monthly)',
|
||||
description:
|
||||
'Within the Pro monthly allowance, how many credits may be served via Gemini (the rest spill to the operator-hardware fallback). Default 25.',
|
||||
required: true,
|
||||
default: 25,
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
integer: true,
|
||||
step: 1,
|
||||
units: 'credits',
|
||||
placeholder: null,
|
||||
}),
|
||||
// Max tier knobs.
|
||||
max_gemini_cap: Value.number({
|
||||
name: 'Max — Gemini Cap (monthly)',
|
||||
description:
|
||||
'Max-tier users get unlimited total credits but a capped slice goes via Gemini. Default 50.',
|
||||
required: true,
|
||||
default: 50,
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
integer: true,
|
||||
step: 1,
|
||||
units: 'credits',
|
||||
placeholder: null,
|
||||
}),
|
||||
})
|
||||
|
||||
export const adjustTierQuotas = sdk.Action.withInput(
|
||||
'adjust-tier-quotas',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Adjust Tier Quotas',
|
||||
description:
|
||||
'Tune the per-tier monthly credit caps and Gemini exposure without redeploying. Changes apply to the next request — no restart needed.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
let parsed: any = {}
|
||||
try {
|
||||
parsed = JSON.parse(config?.relay_tier_quotas_json || '{}')
|
||||
} catch {
|
||||
parsed = {}
|
||||
}
|
||||
return {
|
||||
core_lifetime: parsed?.core?.lifetime ?? 5,
|
||||
pro_monthly: parsed?.pro?.monthly ?? 50,
|
||||
pro_gemini_cap: parsed?.pro?.geminiCapMonthly ?? 25,
|
||||
max_gemini_cap: parsed?.max?.geminiCapMonthly ?? 50,
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
const quotas = {
|
||||
core: {
|
||||
lifetime: input.core_lifetime ?? 5,
|
||||
monthly: null,
|
||||
geminiCapMonthly: null,
|
||||
},
|
||||
pro: {
|
||||
lifetime: null,
|
||||
monthly: input.pro_monthly ?? 50,
|
||||
geminiCapMonthly: input.pro_gemini_cap ?? 25,
|
||||
},
|
||||
max: {
|
||||
lifetime: null,
|
||||
monthly: null,
|
||||
geminiCapMonthly: input.max_gemini_cap ?? 50,
|
||||
},
|
||||
}
|
||||
await configFile.merge(effects, {
|
||||
relay_tier_quotas_json: JSON.stringify(quotas),
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { setGeminiKey } from './setGeminiKey'
|
||||
import { setKeysatBaseUrl } from './setKeysatBaseUrl'
|
||||
import { setParakeetUrl } from './setParakeetUrl'
|
||||
import { setGemmaUrl } from './setGemmaUrl'
|
||||
import { setAdminPassword } from './setAdminPassword'
|
||||
import { adjustTierQuotas } from './adjustTierQuotas'
|
||||
|
||||
export const actions = sdk.Actions.of()
|
||||
.addAction(setGeminiKey)
|
||||
.addAction(setKeysatBaseUrl)
|
||||
.addAction(setParakeetUrl)
|
||||
.addAction(setGemmaUrl)
|
||||
.addAction(setAdminPassword)
|
||||
.addAction(adjustTierQuotas)
|
||||
@@ -0,0 +1,107 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
import { randomBytes, scryptSync } from 'crypto'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const SCRYPT_KEYLEN = 64
|
||||
|
||||
// Mirror of Recap's setAdminPassword — same shape so server-side
|
||||
// admin-auth code can be lifted with minimal change.
|
||||
const inputSpec = InputSpec.of({
|
||||
relay_admin_username: Value.text({
|
||||
name: 'Admin Username',
|
||||
description: 'Username for the relay admin dashboard. Defaults to "admin".',
|
||||
required: true,
|
||||
default: 'admin',
|
||||
minLength: 1,
|
||||
maxLength: 64,
|
||||
}),
|
||||
relay_admin_password: Value.text({
|
||||
name: 'Admin Password',
|
||||
description:
|
||||
'Password for the relay admin dashboard. Must be at least 8 characters. Leave blank to disable /admin entirely (useful while testing /relay/* endpoints).',
|
||||
required: false,
|
||||
default: null,
|
||||
masked: true,
|
||||
minLength: 0,
|
||||
maxLength: 256,
|
||||
}),
|
||||
relay_admin_password_confirm: Value.text({
|
||||
name: 'Confirm Password',
|
||||
description: 'Re-enter the password to confirm.',
|
||||
required: false,
|
||||
default: null,
|
||||
masked: true,
|
||||
minLength: 0,
|
||||
maxLength: 256,
|
||||
}),
|
||||
})
|
||||
|
||||
export const setAdminPassword = sdk.Action.withInput(
|
||||
'set-admin-password',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Admin Password',
|
||||
description:
|
||||
"Gate the relay's /admin dashboard. The public /relay/* endpoints are unaffected — they're per-call authenticated via X-Recap-Install-Id + Authorization headers.",
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return {
|
||||
relay_admin_username: config?.relay_admin_username || 'admin',
|
||||
relay_admin_password: undefined,
|
||||
relay_admin_password_confirm: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
const username = (input.relay_admin_username || '').trim()
|
||||
const password = input.relay_admin_password || ''
|
||||
const confirm = input.relay_admin_password_confirm || ''
|
||||
|
||||
if (!username) throw new Error('Username is required.')
|
||||
|
||||
if (password === '' && confirm === '') {
|
||||
await configFile.merge(effects, {
|
||||
relay_admin_username: username,
|
||||
relay_admin_password_hash: '',
|
||||
relay_admin_password_salt: '',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (password !== confirm) {
|
||||
throw new Error('Password and confirmation do not match.')
|
||||
}
|
||||
if (password.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters.')
|
||||
}
|
||||
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const hash = scryptSync(password, salt, SCRYPT_KEYLEN).toString('hex')
|
||||
|
||||
const existing = await configFile.read().once()
|
||||
const sessionSecret =
|
||||
existing?.relay_admin_session_secret &&
|
||||
existing.relay_admin_session_secret.length > 0
|
||||
? existing.relay_admin_session_secret
|
||||
: randomBytes(32).toString('hex')
|
||||
|
||||
await configFile.merge(effects, {
|
||||
relay_admin_username: username,
|
||||
relay_admin_password_hash: hash,
|
||||
relay_admin_password_salt: salt,
|
||||
relay_admin_session_secret: sessionSecret,
|
||||
})
|
||||
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// The operator's Gemini API key. This is the relay's primary backend
|
||||
// — Recap requests for both transcribe and analyze go to Gemini first,
|
||||
// and only spill to the optional Parakeet/Gemma backends once a user
|
||||
// exceeds their tier's monthly Gemini cap.
|
||||
//
|
||||
// Free key from https://aistudio.google.com/apikey. Track usage in
|
||||
// the Google AI Studio dashboard to know what tier pricing should be.
|
||||
const inputSpec = InputSpec.of({
|
||||
relay_gemini_api_key: Value.text({
|
||||
name: 'Gemini API Key',
|
||||
description:
|
||||
'The relay\'s Google Gemini API key. Used for transcribe + analyze forwarding. Get one at https://aistudio.google.com/apikey',
|
||||
required: true,
|
||||
default: null,
|
||||
masked: true,
|
||||
minLength: 1,
|
||||
maxLength: 256,
|
||||
}),
|
||||
})
|
||||
|
||||
export const setGeminiKey = sdk.Action.withInput(
|
||||
'set-gemini-key',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Gemini API Key',
|
||||
description:
|
||||
"The operator's Gemini key. Required — the relay will refuse to serve traffic until this is set.",
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return {
|
||||
relay_gemini_api_key: config?.relay_gemini_api_key || undefined,
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, {
|
||||
relay_gemini_api_key: input.relay_gemini_api_key,
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// Optional Gemma/Ollama endpoint for the operator-hardware analysis
|
||||
// fallback. Counterpart to setParakeetUrl — Parakeet handles transcribe
|
||||
// overflow, this handles analyze overflow.
|
||||
const inputSpec = InputSpec.of({
|
||||
relay_gemma_base_url: Value.text({
|
||||
name: 'Gemma Base URL',
|
||||
description:
|
||||
"URL of the operator's Gemma / Ollama / OpenAI-compatible analysis endpoint. Used as the overflow path once a user exceeds their monthly Gemini cap. Leave empty to hard-cap at the Gemini limit. Example: http://192.168.1.87:11434",
|
||||
required: false,
|
||||
default: '',
|
||||
minLength: 0,
|
||||
maxLength: 256,
|
||||
patterns: [
|
||||
{
|
||||
regex: '^(https?://.+)?$',
|
||||
description: 'Must be empty or start with http:// or https://',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
export const setGemmaUrl = sdk.Action.withInput(
|
||||
'set-gemma-url',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Gemma URL',
|
||||
description:
|
||||
'Optional. Where the relay forwards analysis requests once a user exceeds their monthly Gemini cap. Leave empty to disable the fallback.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return {
|
||||
relay_gemma_base_url: config?.relay_gemma_base_url || '',
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, {
|
||||
relay_gemma_base_url: (input.relay_gemma_base_url || '').trim(),
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// Where the relay calls to validate licenses. Defaults to the public
|
||||
// Keysat endpoint. Operators running Keysat on the same Start9 server
|
||||
// can override to the internal hostname (e.g. http://keysat.startos:3000)
|
||||
// for a lower-latency hot path — every relay request hits this for the
|
||||
// cached online check.
|
||||
const inputSpec = InputSpec.of({
|
||||
relay_keysat_base_url: Value.text({
|
||||
name: 'Keysat Base URL',
|
||||
description:
|
||||
"URL of the Keysat license server. Defaults to https://keysat.xyz. If you're running Keysat as a co-located StartOS package, override to the internal hostname (http://keysat.startos:<port>) to skip the public-internet roundtrip.",
|
||||
required: true,
|
||||
default: 'https://keysat.xyz',
|
||||
minLength: 8,
|
||||
maxLength: 256,
|
||||
patterns: [
|
||||
{
|
||||
regex: '^https?://.+$',
|
||||
description: 'Must start with http:// or https://',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
export const setKeysatBaseUrl = sdk.Action.withInput(
|
||||
'set-keysat-base-url',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Keysat URL',
|
||||
description:
|
||||
"Where the relay validates Recap user licenses. Defaults to https://keysat.xyz — override to a co-located internal hostname if Keysat is on the same Start9 server.",
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return {
|
||||
relay_keysat_base_url: config?.relay_keysat_base_url || 'https://keysat.xyz',
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, {
|
||||
relay_keysat_base_url: (input.relay_keysat_base_url || '').trim(),
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// Optional Parakeet endpoint for the operator-hardware fallback path.
|
||||
// When a Pro/Max user exceeds their Gemini monthly cap, the relay
|
||||
// routes transcribe requests here instead. Empty disables the fallback
|
||||
// — over-cap users get 402.
|
||||
//
|
||||
// In a typical setup this points at the operator's NVIDIA Spark or
|
||||
// similar local GPU box running the NeMo / Parakeet HTTP wrapper.
|
||||
const inputSpec = InputSpec.of({
|
||||
relay_parakeet_base_url: Value.text({
|
||||
name: 'Parakeet Base URL',
|
||||
description:
|
||||
'URL of the operator\'s Parakeet (or any Whisper-API-compatible) transcription endpoint. Used as the overflow path once a user exceeds their monthly Gemini cap. Leave empty to hard-cap at the Gemini limit. Example: http://192.168.1.87:8000',
|
||||
required: false,
|
||||
default: '',
|
||||
minLength: 0,
|
||||
maxLength: 256,
|
||||
patterns: [
|
||||
{
|
||||
regex: '^(https?://.+)?$',
|
||||
description: 'Must be empty or start with http:// or https://',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
export const setParakeetUrl = sdk.Action.withInput(
|
||||
'set-parakeet-url',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Parakeet URL',
|
||||
description:
|
||||
"Optional. Where the relay forwards transcription requests once a user exceeds their monthly Gemini cap. Leave empty to disable the operator-hardware fallback.",
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return {
|
||||
relay_parakeet_base_url: config?.relay_parakeet_base_url || '',
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, {
|
||||
relay_parakeet_base_url: (input.relay_parakeet_base_url || '').trim(),
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const { createBackup, restoreInit } = sdk.setupBackups(
|
||||
async ({ effects }) => sdk.Backups.ofVolumes('main'),
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import { sdk } from './sdk'
|
||||
|
||||
// Recap declares Ollama as an OPTIONAL dependency in the manifest.
|
||||
// We do not return it here because we don't want to enforce a runtime
|
||||
// requirement on it — Recap runs fine using cloud providers
|
||||
// (Gemini/Anthropic/OpenAI) when Ollama is not installed. The optional
|
||||
// declaration in the manifest is what surfaces it as a suggested
|
||||
// install on the Marketplace; this empty result keeps it from blocking
|
||||
// startup.
|
||||
export const setDependencies = sdk.setupDependencies(async ({ effects }) => {
|
||||
return {}
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { FileHelper } from '@start9labs/start-sdk'
|
||||
import { Volume } from '@start9labs/start-sdk/package/lib/util/Volume'
|
||||
import { z } from 'zod'
|
||||
|
||||
const mainVolume = new Volume('main')
|
||||
|
||||
// Operator-side configuration for the Recap Relay package. All fields
|
||||
// are optional and ship with sensible defaults — the relay will boot
|
||||
// even with an empty config, but will refuse to serve traffic until at
|
||||
// least relay_gemini_api_key and relay_admin_password_hash are set.
|
||||
export const configFile = FileHelper.json(
|
||||
{
|
||||
base: mainVolume,
|
||||
subpath: 'config/relay-config.json',
|
||||
},
|
||||
z.object({
|
||||
// ── Backend credentials ──
|
||||
// The relay's Gemini API key. Used for all transcribe + analyze
|
||||
// forwarding until a user exceeds their tier's Gemini cap (then
|
||||
// overflows to operator hardware below). Empty disables the
|
||||
// Gemini backend entirely — relay will then either route to
|
||||
// hardware (if configured) or 503 every request.
|
||||
relay_gemini_api_key: z.string().default(''),
|
||||
|
||||
// ── Operator hardware (optional fallback) ──
|
||||
// When a Pro/Max user exceeds their monthly Gemini cap, the relay
|
||||
// routes overflow here. Leave empty to hard-cap at the Gemini limit
|
||||
// and return 402 once exceeded (no fallback).
|
||||
relay_parakeet_base_url: z.string().default(''),
|
||||
relay_gemma_base_url: z.string().default(''),
|
||||
|
||||
// ── License server ──
|
||||
// URL of the Keysat license server used for the cached online
|
||||
// license-validation check. Defaults to the public endpoint;
|
||||
// operators co-located with Keysat on the same Start9 server can
|
||||
// override to the internal `http://keysat.startos:<port>` hostname
|
||||
// for a lower-latency hot path.
|
||||
relay_keysat_base_url: z.string().default('https://keysat.xyz'),
|
||||
|
||||
// ── Admin dashboard auth ──
|
||||
// Username + scrypt-hashed password + session secret for the
|
||||
// /admin/* dashboard. Same shape Recap uses (see Recap's
|
||||
// server/admin-auth.js for the hash + verify code). Empty hash
|
||||
// disables /admin entirely — useful while testing the public
|
||||
// /relay/* endpoints.
|
||||
relay_admin_username: z.string().default(''),
|
||||
relay_admin_password_hash: z.string().default(''),
|
||||
relay_admin_password_salt: z.string().default(''),
|
||||
relay_admin_session_secret: z.string().default(''),
|
||||
|
||||
// ── Tier quotas (operator-adjustable without redeploy) ──
|
||||
// JSON blob driving credits.js. Defaults match the v1 product
|
||||
// spec: Core lifetime-5, Pro 50/mo with 25 Gemini cap, Max
|
||||
// unlimited with 50 Gemini cap. Operators can tweak via the
|
||||
// "Adjust Tier Quotas" action without a code change or restart.
|
||||
relay_tier_quotas_json: z.string().default(
|
||||
JSON.stringify({
|
||||
core: { lifetime: 5, monthly: null, geminiCapMonthly: null },
|
||||
pro: { lifetime: null, monthly: 50, geminiCapMonthly: 25 },
|
||||
max: { lifetime: null, monthly: null, geminiCapMonthly: 50 },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
export const DEFAULT_LANG = 'en_US'
|
||||
|
||||
const dict = {
|
||||
// main.ts
|
||||
'Starting Recap...': 0,
|
||||
'Web Interface': 1,
|
||||
'Recap is ready': 2,
|
||||
'Recap is not responding': 3,
|
||||
|
||||
// interfaces.ts
|
||||
'Web UI': 4,
|
||||
'The web interface for Recap — browse, search, and manage your transcript library': 5,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Plumbing. DO NOT EDIT.
|
||||
*/
|
||||
export type I18nKey = keyof typeof dict
|
||||
export type LangDict = Record<(typeof dict)[I18nKey], string>
|
||||
export default dict
|
||||
@@ -0,0 +1,4 @@
|
||||
import { LangDict } from './default'
|
||||
|
||||
// English-only for now. Add translations here as needed.
|
||||
export default {} satisfies Record<string, LangDict>
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Plumbing. DO NOT EDIT this file.
|
||||
*/
|
||||
import { setupI18n } from '@start9labs/start-sdk'
|
||||
import defaultDict, { DEFAULT_LANG } from './dictionaries/default'
|
||||
import translations from './dictionaries/translations'
|
||||
|
||||
export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG)
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Plumbing. DO NOT EDIT.
|
||||
*/
|
||||
export { createBackup } from './backups'
|
||||
export { main } from './main'
|
||||
export { init, uninit } from './init'
|
||||
export { actions } from './actions'
|
||||
import { buildManifest } from '@start9labs/start-sdk'
|
||||
import { manifest as sdkManifest } from './manifest'
|
||||
import { versionGraph } from './versions'
|
||||
export const manifest = buildManifest(versionGraph, sdkManifest)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { setDependencies } from '../dependencies'
|
||||
import { setInterfaces } from '../interfaces'
|
||||
import { versionGraph } from '../versions'
|
||||
import { actions } from '../actions'
|
||||
import { restoreInit } from '../backups'
|
||||
import { setup } from './setup'
|
||||
|
||||
export const init = sdk.setupInit(
|
||||
restoreInit,
|
||||
versionGraph,
|
||||
setup,
|
||||
setInterfaces,
|
||||
setDependencies,
|
||||
actions,
|
||||
)
|
||||
|
||||
export const uninit = sdk.setupUninit(versionGraph)
|
||||
@@ -0,0 +1,8 @@
|
||||
import { sdk } from '../sdk'
|
||||
|
||||
// Recap needs no special initialization.
|
||||
// Directories are created by docker_entrypoint.sh and
|
||||
// config is loaded from the persistent volume at runtime.
|
||||
export const setup = sdk.setupOnInit(async (effects, kind) => {
|
||||
// Nothing to do on install, update, restore, or rebuild.
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { i18n } from './i18n'
|
||||
import { sdk } from './sdk'
|
||||
import { uiPort } from './utils'
|
||||
|
||||
// Single HTTP interface on port 3002. Operators wire a public hostname
|
||||
// (e.g. relay.yourdomain.com) to this interface via StartTunnel; Recap
|
||||
// installs point their "Set Relay URL" action at that hostname. The
|
||||
// /admin/* paths require admin auth (set via "Set Admin Password"
|
||||
// action); the /relay/* paths are authenticated per-call via
|
||||
// X-Recap-Install-Id + optional Authorization: Bearer LIC1-... headers.
|
||||
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
|
||||
const apiMulti = sdk.MultiHost.of(effects, 'api-multi')
|
||||
const apiOrigin = await apiMulti.bindPort(uiPort, {
|
||||
protocol: 'http',
|
||||
})
|
||||
const api = sdk.createInterface(effects, {
|
||||
name: i18n('Relay Endpoint'),
|
||||
id: 'api',
|
||||
description: i18n(
|
||||
'HTTP endpoint for Recap clients to relay transcribe + analyze ' +
|
||||
'calls. Also serves the operator admin dashboard at /admin/.',
|
||||
),
|
||||
type: 'ui',
|
||||
masked: false,
|
||||
schemeOverride: null,
|
||||
username: null,
|
||||
path: '',
|
||||
query: {},
|
||||
})
|
||||
|
||||
const apiReceipt = await apiOrigin.export([api])
|
||||
|
||||
return [apiReceipt]
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { i18n } from './i18n'
|
||||
import { sdk } from './sdk'
|
||||
import { uiPort } from './utils'
|
||||
|
||||
export const main = sdk.setupMain(async ({ effects }) => {
|
||||
console.info(i18n('Starting Recap Relay...'))
|
||||
|
||||
return sdk.Daemons.of(effects).addDaemon('primary', {
|
||||
subcontainer: await sdk.SubContainer.of(
|
||||
effects,
|
||||
{ imageId: 'main' },
|
||||
sdk.Mounts.of().mountVolume({
|
||||
volumeId: 'main',
|
||||
subpath: null,
|
||||
mountpoint: '/data',
|
||||
readonly: false,
|
||||
}),
|
||||
'recap-relay-sub',
|
||||
),
|
||||
exec: {
|
||||
command: [
|
||||
'dumb-init',
|
||||
'--',
|
||||
'/usr/local/bin/docker_entrypoint.sh',
|
||||
],
|
||||
},
|
||||
ready: {
|
||||
display: i18n('Relay Endpoint'),
|
||||
fn: () =>
|
||||
sdk.healthCheck.checkPortListening(effects, uiPort, {
|
||||
successMessage: i18n('Relay is accepting connections'),
|
||||
errorMessage: i18n('Relay is not responding'),
|
||||
}),
|
||||
},
|
||||
requires: [],
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
export const short =
|
||||
'Credit-metered relay backend for Recap clients.'
|
||||
|
||||
export const long =
|
||||
'Recap Relay is the operator-side service that fronts Gemini (and ' +
|
||||
'optionally a local Parakeet+Gemma setup) for Recap installs. It ' +
|
||||
"tracks per-install credit balances, enforces tier-based monthly " +
|
||||
'quotas, and proxies transcribe/analyze calls so Core users can ' +
|
||||
'summarize a handful of videos without paying and paid tiers get ' +
|
||||
'metered access on the operator dime. Designed to be paired with ' +
|
||||
'a Keysat license server.'
|
||||
|
||||
export const alertInstall =
|
||||
'Recap Relay needs at least a Gemini API key + an admin password ' +
|
||||
'set via the StartOS actions before it can serve traffic. Forward ' +
|
||||
"a public hostname (e.g. relay.yourdomain.com) to this service's " +
|
||||
"Web Interface via StartTunnel, then point your Recap install's " +
|
||||
'"Set Relay URL" action at that hostname.'
|
||||
@@ -0,0 +1,38 @@
|
||||
import { setupManifest } from '@start9labs/start-sdk'
|
||||
import { alertInstall, long, short } from './i18n'
|
||||
|
||||
export const manifest = setupManifest({
|
||||
id: 'recap-relay',
|
||||
title: 'Recap Relay',
|
||||
license: 'Proprietary',
|
||||
packageRepo: 'https://ten31.xyz',
|
||||
upstreamRepo: 'https://ten31.xyz',
|
||||
marketingUrl: 'https://ten31.xyz',
|
||||
donationUrl: null,
|
||||
docsUrls: [],
|
||||
description: { short, long },
|
||||
volumes: ['main'],
|
||||
images: {
|
||||
main: {
|
||||
source: {
|
||||
dockerBuild: {
|
||||
workdir: '.',
|
||||
dockerfile: './Dockerfile',
|
||||
},
|
||||
},
|
||||
arch: ['x86_64', 'aarch64'],
|
||||
},
|
||||
},
|
||||
alerts: {
|
||||
install: alertInstall,
|
||||
update: null,
|
||||
uninstall: null,
|
||||
restore: null,
|
||||
start: null,
|
||||
stop: null,
|
||||
},
|
||||
// Relay has no required dependencies — Gemini is internet-fronted
|
||||
// and the optional Parakeet/Gemma backends are at user-configured
|
||||
// URLs (typically a separate machine on the operator's LAN).
|
||||
dependencies: {},
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { StartSdk } from '@start9labs/start-sdk'
|
||||
import { manifest } from './manifest'
|
||||
|
||||
/**
|
||||
* Plumbing. DO NOT EDIT.
|
||||
*
|
||||
* The exported "sdk" const is used throughout this package codebase.
|
||||
*/
|
||||
export const sdk = StartSdk.of().withManifest(manifest).build(true)
|
||||
@@ -0,0 +1,4 @@
|
||||
// Shared constants used across the package codebase.
|
||||
// Port 3002 to keep it distinct from Recap's 3001 if anyone ever
|
||||
// co-installs both packages on the same host for testing.
|
||||
export const uiPort = 3002
|
||||
@@ -0,0 +1,7 @@
|
||||
import { VersionGraph } from '@start9labs/start-sdk'
|
||||
import { v_0_1_0 } from './v0.1.0'
|
||||
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_0_1_0,
|
||||
other: [],
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_1_0 = VersionInfo.of({
|
||||
version: '0.1.0:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Initial release. Two-endpoint relay (transcribe + analyze) backed by Gemini, with per-install-id credit ledger, job-id deduplication, cached license validation against Keysat, and StartOS actions for operator configuration. Parakeet+Gemma fallback is wired but inert in v0.1.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user