initial relay scaffold

This commit is contained in:
local
2026-05-11 20:03:27 -05:00
commit b9d86fa303
58 changed files with 7609 additions and 0 deletions
+124
View File
@@ -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
},
)
+15
View File
@@ -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)
+107
View File
@@ -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
},
)
+54
View File
@@ -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
},
)
+55
View File
@@ -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
},
)
+57
View File
@@ -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
},
)
+59
View File
@@ -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
},
)
+5
View File
@@ -0,0 +1,5 @@
import { sdk } from './sdk'
export const { createBackup, restoreInit } = sdk.setupBackups(
async ({ effects }) => sdk.Backups.ofVolumes('main'),
)
+12
View File
@@ -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 {}
})
+64
View File
@@ -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 },
}),
),
}),
)
+20
View File
@@ -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>
+8
View File
@@ -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)
+11
View File
@@ -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)
+18
View File
@@ -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)
+8
View File
@@ -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.
})
+34
View File
@@ -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]
})
+37
View File
@@ -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: [],
})
})
+18
View File
@@ -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.'
+38
View File
@@ -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: {},
})
+9
View File
@@ -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)
+4
View File
@@ -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
+7
View File
@@ -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: [],
})
+13
View File
@@ -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 }) => {},
},
})